From acd491e087c12b189a064f58aff215769bfd6489 Mon Sep 17 00:00:00 2001 From: ThejithaR Date: Thu, 5 Feb 2026 17:26:13 +0530 Subject: [PATCH 01/17] Add In-Flow Extension Action Execution Support - Introduced InFlowExtensionExecutor to handle execution of In-Flow Extension actions during flow execution. - Created InFlowExtensionRequestBuilder to build action execution requests from FlowContext. - Implemented InFlowExtensionResponseProcessor to process responses from In-Flow Extension actions, handling context updates for properties, user claims, and user inputs. - Added OperationExecutionResult class to encapsulate the result of operation executions. - Updated ActionExecutorConfig to include configuration for In-Flow Extension actions. - Enhanced Action model to support In-Flow Extension action type. - Modified ActionManagementConfig to manage versions and properties for In-Flow Extension actions. --- .../execution/api/model/ActionType.java | 3 +- .../action/execution/api/model/User.java | 16 + ...ActionExecutionServiceComponentHolder.java | 22 + .../internal/util/ActionExecutorConfig.java | 12 +- .../management/api/constant/ErrorMessage.java | 8 +- .../action/management/api/model/Action.java | 11 +- .../api/service/ActionManagementService.java | 33 + .../impl/ActionDTOModelResolverFactory.java | 2 + .../service/impl/ActionConverterFactory.java | 10 +- .../impl/ActionManagementServiceImpl.java | 64 +- .../CacheBackedActionManagementService.java | 7 + .../internal/util/ActionManagementConfig.java | 11 +- .../management/model/ActionTypesTest.java | 10 +- .../pom.xml | 32 +- .../flow/execution/engine/Constants.java | 3 + .../engine/core/FlowExecutionEngine.java | 25 +- .../engine/graph/TaskExecutionNode.java | 1 + .../FlowExecutionEngineDataHolder.java | 2 + .../flow/execution/engine/model/FlowUser.java | 16 + .../InputValidationServiceTest.java | 6 +- .../src/test/resources/testng.xml | 1 + .../pom.xml | 304 ++++++ .../extensions/InFlowExtensionConstants.java | 163 +++ .../executor/HierarchicalPrefixMatcher.java | 110 ++ .../executor/InFlowExtensionExecutor.java | 476 +++++++++ .../InFlowExtensionRequestBuilder.java | 823 +++++++++++++++ .../InFlowExtensionResponseProcessor.java | 711 +++++++++++++ .../executor/JWEEncryptionUtil.java | 262 +++++ .../executor/PathTypeAnnotationUtil.java | 418 ++++++++ .../internal/InFlowExtensionDataHolder.java | 90 ++ .../InFlowExtensionServiceComponent.java | 179 ++++ .../InFlowExtensionActionConverter.java | 211 ++++ ...InFlowExtensionActionDTOModelResolver.java | 604 +++++++++++ .../InFlowExtensionContextTreeBuilder.java | 295 ++++++ .../InFlowExtensionContextTreeMetadata.java | 70 ++ .../InFlowExtensionContextTreeNode.java | 205 ++++ .../InFlowExtensionContextTreeService.java | 56 + .../inflow/extensions/model/AccessConfig.java | 153 +++ .../inflow/extensions/model/ContextPath.java | 66 ++ .../inflow/extensions/model/Encryption.java | 60 ++ .../model/FlowContextHandoverConfig.java | 118 +++ .../model/InFlowExtensionAction.java | 304 ++++++ .../model/InFlowExtensionEvent.java | 199 ++++ .../model/InFlowExtensionRequest.java | 34 + .../model/OperationExecutionResult.java | 62 ++ .../InFlowExtensionContextFilterUtil.java | 179 ++++ .../extensions/InFlowExtensionTestUtils.java | 62 ++ .../HierarchicalPrefixMatcherTest.java | 165 +++ .../executor/InFlowExtensionExecutorTest.java | 616 +++++++++++ .../InFlowExtensionRequestBuilderTest.java | 811 ++++++++++++++ .../InFlowExtensionResponseProcessorTest.java | 999 ++++++++++++++++++ .../executor/PathTypeAnnotationUtilTest.java | 618 +++++++++++ .../FlowContextHandoverConfigTestHelper.java | 40 + ...InFlowExtensionContextTreeBuilderTest.java | 433 ++++++++ .../extensions/model/AccessConfigTest.java | 205 ++++ .../model/InFlowExtensionEventTest.java | 106 ++ .../model/OperationExecutionResultTest.java | 68 ++ .../test/resources/repository/conf/carbon.xml | 687 ++++++++++++ .../src/test/resources/testng.xml | 43 + .../flow-orchestration-framework/pom.xml | 1 + .../pom.xml | 73 ++ features/flow-orchestration-framework/pom.xml | 1 + .../resources/identity.xml.j2 | 6 + ....identity.core.server.feature.default.json | 2 + pom.xml | 11 + 65 files changed, 11351 insertions(+), 43 deletions(-) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java create mode 100755 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml create mode 100644 features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java index a7ff01fc0297..203edb9ae14d 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java @@ -29,7 +29,8 @@ public enum ActionType { PRE_UPDATE_PASSWORD, PRE_UPDATE_PROFILE, AUTHENTICATION, - PRE_ISSUE_ID_TOKEN; + PRE_ISSUE_ID_TOKEN, + IN_FLOW_EXTENSION; public String getDisplayName() { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java index f26378fe0d4e..fe37ca5d3513 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java @@ -20,7 +20,9 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * This class models the User. @@ -30,6 +32,7 @@ public class User { private final String id; private final List claims = new ArrayList<>(); + private final Map userCredentials = new HashMap<>(); private final List groups = new ArrayList<>(); private final List roles = new ArrayList<>(); private Organization organization; @@ -65,6 +68,7 @@ public User(Builder builder) { this.id = builder.id; this.claims.addAll(builder.claims); + this.userCredentials.putAll(builder.userCredentials); this.groups.addAll(builder.groups); this.roles.addAll(builder.roles); this.organization = builder.organization; @@ -84,6 +88,11 @@ public List getClaims() { return Collections.unmodifiableList(claims); } + public Map getUserCredentials() { + + return Collections.unmodifiableMap(userCredentials); + } + public List getGroups() { return Collections.unmodifiableList(groups); @@ -126,6 +135,7 @@ public static class Builder { private final String id; private final List claims = new ArrayList<>(); + private final Map userCredentials = new HashMap<>(); private final List groups = new ArrayList<>(); private final List roles = new ArrayList<>(); private Organization organization; @@ -187,6 +197,12 @@ public Builder accessingOrganization(Organization accessingOrganization) { return this; } + public Builder userCredentials(Map userCredentials) { + + this.userCredentials.putAll(userCredentials); + return this; + } + public User build() { return new User(this); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java index d5f90106b0d0..05f2708d7fc9 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java @@ -18,6 +18,7 @@ package org.wso2.carbon.identity.action.execution.internal.component; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; import org.wso2.carbon.identity.rule.evaluation.api.service.RuleEvaluationService; import org.wso2.carbon.identity.secret.mgt.core.SecretManager; @@ -36,6 +37,7 @@ public class ActionExecutionServiceComponentHolder { private SecretManager secretManager; private SecretResolveManager secretResolveManager; private RealmService realmService; + private ActionExecutorService actionExecutorService; private ActionExecutionServiceComponentHolder() { @@ -115,4 +117,24 @@ public void setRealmService(RealmService realmService) { this.realmService = realmService; } + + /** + * Get the ActionExecutorService instance. + * + * @return ActionExecutorService instance. + */ + public ActionExecutorService getActionExecutorService() { + + return actionExecutorService; + } + + /** + * Set the ActionExecutorService instance. + * + * @param actionExecutorService ActionExecutorService instance. + */ + public void setActionExecutorService(ActionExecutorService actionExecutorService) { + + this.actionExecutorService = actionExecutorService; + } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java index 2e215b8f4186..c93967db4de3 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java @@ -87,6 +87,8 @@ public boolean isExecutionForActionTypeEnabled(ActionType actionType) { return isActionTypeEnabled(ActionTypeConfig.PRE_UPDATE_PROFILE.getActionTypeEnableProperty()); case PRE_ISSUE_ID_TOKEN: return isActionTypeEnabled(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getActionTypeEnableProperty()); + case IN_FLOW_EXTENSION: + return isActionTypeEnabled(ActionTypeConfig.IN_FLOW_EXTENSION.getActionTypeEnableProperty()); default: return false; } @@ -333,6 +335,8 @@ public String getRetiredUpToVersion(ActionType actionType) { return getVersion(ActionTypeConfig.PRE_UPDATE_PROFILE.getRetiredUpToVersionProperty()); case PRE_ISSUE_ID_TOKEN: return getVersion(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getRetiredUpToVersionProperty()); + case IN_FLOW_EXTENSION: + return getVersion(ActionTypeConfig.IN_FLOW_EXTENSION.getRetiredUpToVersionProperty()); default: return null; } @@ -417,7 +421,13 @@ private enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.PreIssueIdToken.ActionRequest.AllowedHeaders.Header", "Actions.Types.PreIssueIdToken.ActionRequest.AllowedParameters.Parameter", - "Actions.Types.PreIssueIdToken.Version.RetiredUpTo"); + "Actions.Types.PreIssueIdToken.Version.RetiredUpTo"), + IN_FLOW_EXTENSION("Actions.Types.InFlowExtension.Enable", + "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", + "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", + "Actions.Types.InFlowExtension.ActionRequest.AllowedHeaders.Header", + "Actions.Types.InFlowExtension.ActionRequest.AllowedParameters.Parameter", + "Actions.Types.InFlowExtension.Version.RetiredUpTo"); private final String actionTypeEnableProperty; private final String excludedHeadersProperty; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java index 852da3047640..ef1ceaf5b84d 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java @@ -44,7 +44,13 @@ public enum ErrorMessage { ERROR_MAXIMUM_ATTRIBUTES_LIMIT_EXCEEDED("60011", "Maximum attributes limit exceeded.", "The number of configured attributes: %s exceeds the maximum allowed limit: %s"), ERROR_INVALID_ATTRIBUTES("60012", "Invalid attribute provided.", - "%s"), + "The provided %s attribute is not available in the system."), + ERROR_EMPTY_ATTRIBUTE_VALUE("60013", "Invalid attribute provided.", + "Each attribute must be a non-empty string."), + ERROR_UNSUPPORTED_ATTRIBUTE("60014", "Unsupported attribute provided.", + "The attribute %s is not supported to be shared with the extension."), + ERROR_ACTION_NAME_ALREADY_EXISTS("60015", "Action name already exists.", + "An action with the name '%s' already exists for the given action type."), // Server errors. ERROR_WHILE_ADDING_ACTION("65001", "Error while adding Action.", diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java index 6ff616ae12c4..b35aea2318ab 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java @@ -75,7 +75,13 @@ public enum ActionTypes { "PRE_ISSUE_ID_TOKEN", "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", - Category.PRE_POST); + Category.PRE_POST), + IN_FLOW_EXTENSION( + "inFlowExtension", + "IN_FLOW_EXTENSION", + "In-Flow Extension", + "Configure an extension point within any flow via a custom service.", + Category.IN_FLOW_EXTENSION); private final String pathParam; private final String actionType; @@ -131,7 +137,8 @@ public static ActionTypes[] filterByCategory(Category category) { */ public enum Category { PRE_POST, - IN_FLOW + IN_FLOW, + IN_FLOW_EXTENSION } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java index ac233ffdb0fc..fa370a5ffe27 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java @@ -128,4 +128,37 @@ Action updateAction(String actionType, String actionId, Action action, String te */ Action updateActionEndpointAuthentication(String actionType, String actionId, Authentication authentication, String tenantDomain) throws ActionMgtException; + + /** + * Check whether the given action name is available (unique) within the specified action type. + * When {@code excludeActionId} is {@code null} the check covers all existing actions of that + * type (creation scenario). When non-null the action with that ID is excluded from the + * uniqueness check (update scenario). + * + * @param actionType Action Type path parameter. + * @param name Action name to check. + * @param excludeActionId Action ID to exclude from the uniqueness check, or {@code null} for + * creation scenarios where no action should be excluded. + * @param tenantDomain Tenant domain. + * @return {@code true} if the name is not already used by another action of the same type + * (i.e., the caller may safely use this name); {@code false} if the name is already taken. + * @throws ActionMgtException If an error occurs while checking name availability. + */ + boolean isActionNameAvailable(String actionType, String name, String excludeActionId, String tenantDomain) + throws ActionMgtException; + + /** + * Convenience overload for creation scenarios — delegates to + * {@link #isActionNameAvailable(String, String, String, String)} with {@code excludeActionId = null}. + * + * @param actionType Action Type path parameter. + * @param name Action name to check. + * @param tenantDomain Tenant domain. + * @return {@code true} if the name is available; {@code false} if already taken. + * @throws ActionMgtException If an error occurs while checking name availability. + */ + default boolean isActionNameAvailable(String actionType, String name, String tenantDomain) + throws ActionMgtException { + return isActionNameAvailable(actionType, name, null, tenantDomain); + } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java index 10de38ca1a09..6fdc4186b4af 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java @@ -46,6 +46,8 @@ public static ActionDTOModelResolver getActionDTOModelResolver(Action.ActionType return actionDTOModelResolvers.get(Action.ActionTypes.PRE_UPDATE_PASSWORD); case PRE_ISSUE_ACCESS_TOKEN: return actionDTOModelResolvers.get(Action.ActionTypes.PRE_ISSUE_ACCESS_TOKEN); + case IN_FLOW_EXTENSION: + return actionDTOModelResolvers.get(Action.ActionTypes.IN_FLOW_EXTENSION); default: return null; } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionConverterFactory.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionConverterFactory.java index a4a50dd8a307..ec8ec72c4a3a 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionConverterFactory.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionConverterFactory.java @@ -39,15 +39,7 @@ private ActionConverterFactory() { public static ActionConverter getActionConverter(Action.ActionTypes actionType) { - switch (actionType) { - case PRE_UPDATE_PROFILE: - return actionConverters.get(Action.ActionTypes.PRE_UPDATE_PROFILE); - case PRE_UPDATE_PASSWORD: - return actionConverters.get(Action.ActionTypes.PRE_UPDATE_PASSWORD); - case PRE_ISSUE_ACCESS_TOKEN: - default: - return null; - } + return actionConverters.get(actionType); } public static void registerActionConverter(ActionConverter actionConverter) { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index 3aafdac868a1..c61ce1acd8dd 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -26,6 +26,7 @@ import org.wso2.carbon.identity.action.management.api.exception.ActionMgtException; import org.wso2.carbon.identity.action.management.api.exception.ActionMgtServerException; import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.Action.ActionTypes; import org.wso2.carbon.identity.action.management.api.model.ActionDTO; import org.wso2.carbon.identity.action.management.api.model.Authentication; import org.wso2.carbon.identity.action.management.api.model.EndpointConfig; @@ -78,6 +79,10 @@ public Action addAction(String actionType, Action action, String tenantDomain) t castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action); // Check whether the maximum allowed actions per type is reached. validateMaxActionsPerType(resolvedActionType, tenantDomain); + // Check whether the action name is unique within the action type for in-flow extensions. + if (resolvedActionType.equals(ActionTypes.IN_FLOW_EXTENSION.getActionType())) { + validateActionNameUniqueness(resolvedActionType, action.getName(), null, tenantId); + } String generatedActionId = UUID.randomUUID().toString(); ActionDTO creatingActionDTO = buildActionDTOForCreation(resolvedActionType, generatedActionId, action); @@ -161,6 +166,10 @@ public Action updateAction(String actionType, String actionId, Action action, St Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); ActionValidatorFactory.getActionValidator(castedActionType).doPreUpdateActionValidations( castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action); + if (action.getName() != null && + Action.ActionTypes.IN_FLOW_EXTENSION.equals(castedActionType)) { + validateActionNameUniqueness(resolvedActionType, action.getName(), actionId, tenantId); + } ActionDTO updatingActionDTO = buildActionDTOForUpdate(resolvedActionType, actionId, action); DAO_FACADE.updateAction(updatingActionDTO, existingActionDTO, tenantId); @@ -171,6 +180,25 @@ public Action updateAction(String actionType, String actionId, Action action, St return buildAction(resolvedActionType, updatedActionDTO); } + @Override + public boolean isActionNameAvailable(String actionType, String name, String excludeActionId, + String tenantDomain) throws ActionMgtException { + + if (name == null) { + throw ActionManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_INVALID_ACTION_REQUEST_FIELD, "Action name"); + } + // actionType is the URL path param (e.g., "inFlowExtension"); resolve to the internal enum name. + String resolvedActionType = getActionTypeFromPath(actionType); + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + List actionDTOS = DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId); + // noneMatch returns true when no existing action has this name → name is available. + // When excludeActionId is null (creation), no action is excluded from the check. + return actionDTOS.stream() + .noneMatch(dto -> name.equalsIgnoreCase(dto.getName()) && + (excludeActionId == null || !excludeActionId.equals(dto.getId()))); + } + private String resolveActionVersionAtUpdating(Action updatingAction, ActionDTO existingActionDTO) { String updatingActionVersion = updatingAction.getActionVersion(); @@ -317,8 +345,10 @@ private String getActionTypeFromPath(String actionType) throws ActionMgtClientEx */ private void validateMaxActionsPerType(String actionType, String tenantDomain) throws ActionMgtException { - // In-flow actions are not limited by the maximum actions per action type; eg: AUTHENTICATION action type. - if (Action.ActionTypes.Category.IN_FLOW.equals(Action.ActionTypes.valueOf(actionType).getCategory())) { + // In-flow and extension actions are not limited by the maximum actions per action type. + Action.ActionTypes.Category category = Action.ActionTypes.valueOf(actionType).getCategory(); + if (Action.ActionTypes.Category.IN_FLOW.equals(category) + || Action.ActionTypes.Category.IN_FLOW_EXTENSION.equals(category)) { return; } Map actionsCountPerType = getActionsCountPerType(tenantDomain); @@ -351,6 +381,29 @@ private ActionDTO checkIfActionExists(String actionType, String actionId, String return actionDTO; } + /** + * Validate that the action name is unique within the given action type. + * + * @param actionType Action type. + * @param name Action name to validate. + * @param excludeId Action ID to exclude (for update). Null for creation. + * @param tenantId Tenant ID. + * @throws ActionMgtException If a duplicate name is found. + */ + private void validateActionNameUniqueness(String actionType, String name, String excludeId, int tenantId) + throws ActionMgtException { + + List existingActions = DAO_FACADE.getActionsByActionType(actionType, tenantId); + boolean duplicateExists = existingActions.stream() + .filter(dto -> excludeId == null || !excludeId.equals(dto.getId())) + .anyMatch(dto -> name.equalsIgnoreCase(dto.getName())); + + if (duplicateExists) { + throw ActionManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name); + } + } + /** * For action creation operation, builds an `ActionDTO` object based on the provided action type, action ID, and * action model. This method resolves the action type and status, applies necessary transformations, and constructs @@ -365,8 +418,11 @@ private ActionDTO buildActionDTOForCreation(String actionType, String actionId, throws ActionMgtServerException { Action.ActionTypes resolvedActionType = Action.ActionTypes.valueOf(actionType); - Action.Status resolvedStatus = resolvedActionType.getCategory() == Action.ActionTypes.Category.IN_FLOW ? - Action.Status.ACTIVE : Action.Status.INACTIVE; + // PRE_POST actions start INACTIVE (require explicit activation). + // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, IN_FLOW_EXTENSION) + // start ACTIVE and can be used immediately. + Action.Status resolvedStatus = resolvedActionType.getCategory() == Action.ActionTypes.Category.PRE_POST ? + Action.Status.INACTIVE : Action.Status.ACTIVE; String actionVersion = ActionManagementConfig.getInstance().getLatestVersion(resolvedActionType); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java index 5e2e0dd0f3b6..19180ca29400 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java @@ -182,6 +182,13 @@ public Action updateActionEndpointAuthentication(String actionType, String actio return updatedAction; } + @Override + public boolean isActionNameAvailable(String actionType, String name, String excludeActionId, + String tenantDomain) throws ActionMgtException { + + return ACTION_MGT_SERVICE.isActionNameAvailable(actionType, name, excludeActionId, tenantDomain); + } + private void updateCache(Action action, ActionCacheEntry entry, ActionTypeCacheKey cacheKey, String tenantDomain) { if (LOG.isDebugEnabled()) { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java index f670ce106e7e..fd4acd913c41 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java @@ -94,7 +94,11 @@ public String getLatestVersion(ActionTypes actionType) throws ActionMgtServerExc return getVersion( ActionTypeConfig.PRE_UPDATE_PROFILE.getLatestVersionProperty(), actionType); case PRE_ISSUE_ID_TOKEN: - return getVersion(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getLatestVersionProperty(), actionType); + return getVersion( + ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getLatestVersionProperty(), actionType); + case IN_FLOW_EXTENSION: + return getVersion( + ActionTypeConfig.IN_FLOW_EXTENSION.getLatestVersionProperty(), actionType); default: throw new ActionMgtServerException("Unsupported action type: " + actionType); } @@ -140,6 +144,11 @@ public enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.ExcludedHeaders.Header", "Actions.Types.PreIssueIdToken.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.Latest" + ), + IN_FLOW_EXTENSION( + "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", + "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", + "Actions.Types.InFlowExtension.Version.Latest" ); private final String excludedHeadersProperty; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java index 250805692876..a8053186cba7 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java @@ -52,7 +52,11 @@ public Object[][] actionTypesProvider() { {Action.ActionTypes.PRE_ISSUE_ID_TOKEN, "preIssueIdToken", "PRE_ISSUE_ID_TOKEN", "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", - Action.ActionTypes.Category.PRE_POST} + Action.ActionTypes.Category.PRE_POST}, + {Action.ActionTypes.IN_FLOW_EXTENSION, "inFlowExtension", "IN_FLOW_EXTENSION", + "In-Flow Extension", + "Configure an extension point within any flow via a custom service.", + Action.ActionTypes.Category.IN_FLOW_EXTENSION} }; } @@ -76,7 +80,9 @@ public Object[][] filterByCategoryProvider() { new Action.ActionTypes[]{Action.ActionTypes.PRE_ISSUE_ACCESS_TOKEN, Action.ActionTypes.PRE_UPDATE_PASSWORD, Action.ActionTypes.PRE_UPDATE_PROFILE, Action.ActionTypes.PRE_REGISTRATION, Action.ActionTypes.PRE_ISSUE_ID_TOKEN}}, - {Action.ActionTypes.Category.IN_FLOW, new Action.ActionTypes[]{Action.ActionTypes.AUTHENTICATION}} + {Action.ActionTypes.Category.IN_FLOW, new Action.ActionTypes[]{Action.ActionTypes.AUTHENTICATION}}, + {Action.ActionTypes.Category.IN_FLOW_EXTENSION, + new Action.ActionTypes[]{Action.ActionTypes.IN_FLOW_EXTENSION}} }; } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml index 3a31922ad86d..c88ff2a0a700 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml @@ -20,7 +20,7 @@ org.wso2.carbon.identity.framework identity-framework - 7.11.94-SNAPSHOT + 7.11.88-SNAPSHOT ../../../pom.xml @@ -52,10 +52,6 @@ org.ops4j.pax.logging pax-logging-api - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.application.authentication.framework - com.fasterxml.jackson.core jackson-databind @@ -70,6 +66,10 @@ org.wso2.carbon.identity.framework org.wso2.carbon.identity.flow.mgt + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.central.log.mgt + org.wso2.carbon.identity.framework org.wso2.carbon.identity.input.validation.mgt @@ -165,12 +165,22 @@ org.wso2.carbon.identity.user.action.api.exception; version="${carbon.identity.package.import.version.range}", org.wso2.carbon.core.util; version="${carbon.kernel.package.import.version.range}", - org.wso2.carbon.identity.event.*; version="${carbon.identity.package.import.version.range}" + org.wso2.carbon.identity.event.*; version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.central.log.mgt.utils; + version="${carbon.identity.package.import.version.range}", + com.fasterxml.jackson.core.*; version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.databind.*; + version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.annotation.*; + version="${com.fasterxml.jackson.annotation.version.range}", + org.wso2.carbon.utils; version="${carbon.kernel.package.import.version.range}", + org.slf4j; version="${org.slf4j.imp.pkg.version.range}" !org.wso2.carbon.identity.flow.execution.internal, org.wso2.carbon.identity.flow.execution.engine.util, org.wso2.carbon.identity.flow.execution.engine, + org.wso2.carbon.identity.flow.execution.engine.internal, org.wso2.carbon.identity.flow.execution.engine.core, org.wso2.carbon.identity.flow.execution.engine.cache, org.wso2.carbon.identity.flow.execution.engine.store, @@ -185,6 +195,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + src/test/resources/testng.xml + + + org.apache.maven.plugins maven-checkstyle-plugin diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java index f12a4dd23e94..d21fd09ed825 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java @@ -166,6 +166,9 @@ public enum ErrorMessages { ERROR_CODE_POLICY_CONSENT_FAILURE("65033", "Error occurred during policy consent processing.", "Error occurred while processing policy consent for the %s request of flow id: %s."), + ERROR_CODE_INFLOW_EXTENSION_ERROR("65034", + "%s", + "%s"), // Client errors. ERROR_CODE_INVALID_FLOW_ID("60001", diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java index abe67392b055..9b36cd0b707b 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java @@ -35,6 +35,7 @@ import org.wso2.carbon.identity.flow.mgt.model.DataDTO; import org.wso2.carbon.identity.flow.mgt.model.GraphConfig; import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; +import org.wso2.carbon.identity.flow.mgt.model.StepDTO; import java.util.Map; @@ -233,19 +234,20 @@ private NodeResponse triggerNode(NodeConfig nodeConfig, FlowExecutionContext con private FlowExecutionStep resolveStepForPrompt(GraphConfig graph, NodeConfig currentNode, FlowExecutionContext context, NodeResponse nodeResponse) throws FlowEngineServerException { - DataDTO dataDTO = graph.getNodePageMappings().get(currentNode.getId()).getData(); + StepDTO stepDTO = graph.getNodePageMappings().get(currentNode.getId()); - DataDTO finalDataDTO = null; - if (dataDTO != null) { - finalDataDTO = new DataDTO.Builder() - .components(dataDTO.getComponents()) - .requiredParams(nodeResponse.getRequiredData()) - .optionalParams(nodeResponse.getOptionalData()) - .additionalData(nodeResponse.getAdditionalInfo()) - .build(); - handleError(finalDataDTO, nodeResponse); + DataDTO.Builder dataDTOBuilder = new DataDTO.Builder() + .requiredParams(nodeResponse.getRequiredData()) + .optionalParams(nodeResponse.getOptionalData()) + .additionalData(nodeResponse.getAdditionalInfo()); + + if (stepDTO != null && stepDTO.getData() != null) { + dataDTOBuilder.components(stepDTO.getData().getComponents()); } + DataDTO finalDataDTO = dataDTOBuilder.build(); + handleError(finalDataDTO, nodeResponse); + // When the END node is reached, mark the flow status as COMPLETE, set the step type to REDIRECTION, // and assign the redirect URL. Note: all END nodes are expected to be of type PROMPT_ONLY. if (END_NODE_ID.equals(currentNode.getId())) { @@ -254,9 +256,6 @@ private FlowExecutionStep resolveStepForPrompt(GraphConfig graph, NodeConfig cur "end node. Changing the flow status to COMPLETE, step type to REDIRECTION and setting " + "the redirect URL."); } - if (finalDataDTO == null ) { - finalDataDTO = new DataDTO(); - } finalDataDTO.setRedirectURL(FlowExecutionEngineUtils.resolveCompletionRedirectionUrl(context)); return new FlowExecutionStep.Builder() .flowId(context.getContextIdentifier()) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java index 88f1a0edd6f1..c57ff63bd380 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java @@ -142,6 +142,7 @@ private NodeResponse handleIncompleteStatus(FlowExecutionContext context, Execut .type(VIEW) .requiredData(response.getRequiredData()) .optionalData(response.getOptionalData()) + .additionalInfo(response.getAdditionalInfo()) .error(response.getErrorMessage()) .build(); case STATUS_USER_INPUT_REQUIRED: diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java index adf1b6225b53..f1775598ea45 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java @@ -209,6 +209,7 @@ public void setFederatedAssociationManager(FederatedAssociationManager federated this.federatedAssociationManager = federatedAssociationManager; } + public IdentityEventService getIdentityEventService() { return identityEventService; @@ -219,3 +220,4 @@ public void setIdentityEventService(IdentityEventService identityEventService) { this.identityEventService = identityEventService; } } + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java index b07022dca958..187684ebac0c 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java @@ -123,6 +123,14 @@ public void addClaims(Map claims) { this.claims.putAll(claims); } + public void setClaims(Map claims) { + + this.claims.clear(); + if (claims != null) { + this.claims.putAll(claims); + } + } + public Object getClaim(String claimUri) { return this.claims.get(claimUri); @@ -174,6 +182,14 @@ public void addFederatedAssociation(String idpName, String idpSubject) { this.federatedAssociations.put(idpName, idpSubject); } + public void setFederatedAssociations(Map federatedAssociations) { + + this.federatedAssociations.clear(); + if (federatedAssociations != null) { + this.federatedAssociations.putAll(federatedAssociations); + } + } + /** * Check whether the user credentials are managed locally. * diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/validation/InputValidationServiceTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/validation/InputValidationServiceTest.java index 854640844dc2..663a8ac4a6df 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/validation/InputValidationServiceTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/validation/InputValidationServiceTest.java @@ -880,7 +880,7 @@ public void testValidateUserInputsWithDuplicateClaimValue() ClaimConstants.ClaimUniquenessScope.ACROSS_USERSTORES)).thenReturn(true); claimValidationUtilMock.when(() -> ClaimValidationUtil.isClaimDuplicated(anyString(), anyString())) .thenReturn(true); - // Duplicate claim validation failure should result in RETRY status. + // Duplicate claim validation failure should result in STATUS_RETRY status. ExecutorResponse response = inputValidationService.resolveInputValidationResponse(FlowExecutionContext); Assert.assertEquals(response.getResult(), STATUS_RETRY); } @@ -931,7 +931,7 @@ public void testValidateUserInputsWithServerError() when(mockClaimService.getLocalClaim(anyString(), anyString())) .thenThrow(new ClaimMetadataException("Test server error")); - // Server errors during input validation result in RETRY status from resolveInputValidationResponse. + // Server errors during input validation result in STATUS_RETRY status from resolveInputValidationResponse. ExecutorResponse response = inputValidationService.resolveInputValidationResponse(FlowExecutionContext); Assert.assertEquals(response.getResult(), STATUS_RETRY); } @@ -1519,7 +1519,7 @@ public void testValidatePasswordFormatServerErrorThrowsServerException() when(mockInputValidationService.getInputValidationConfiguration(anyString())) .thenThrow(new InputValidationMgtException("65000", "Server error", "Server error")); - // Server errors during password validation result in RETRY status from resolveInputValidationResponse. + // Server errors during password validation result in STATUS_RETRY status from resolveInputValidationResponse. ExecutorResponse response = inputValidationService.resolveInputValidationResponse(FlowExecutionContext); Assert.assertEquals(response.getResult(), STATUS_RETRY); Assert.assertNotNull(response.getErrorCode()); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/resources/testng.xml index 83303d82a279..11195f2fd1ce 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/resources/testng.xml +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/resources/testng.xml @@ -28,6 +28,7 @@ + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml new file mode 100644 index 000000000000..23d0a6c989a0 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml @@ -0,0 +1,304 @@ + + + + + org.wso2.carbon.identity.framework + identity-framework + 7.11.70-SNAPSHOT + ../../../pom.xml + + + 4.0.0 + org.wso2.carbon.identity.flow.inflow.extensions + bundle + WSO2 Carbon - Identity Flow In-Flow Extensions + WSO2 flow engine in-flow extensions + http://www.wso2.com + + + + org.wso2.carbon + org.wso2.carbon.core + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.base + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.core + + + org.ops4j.pax.logging + pax-logging-api + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.core + jackson-core + provided + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.mgt + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.execution.engine + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.action.execution + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.action.management + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.claim.metadata.mgt + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.central.log.mgt + + + org.testng + testng + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-testng + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.application.authentication.framework + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.testutil + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.user.action + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.certificate.management + + + org.wso2.orbit.com.nimbusds + nimbus-jose-jwt + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + ${project.artifactId} + ${project.artifactId} + + org.wso2.carbon.identity.flow.inflow.extensions.internal, + org.wso2.carbon.identity.flow.inflow.extensions.util + + + javax.xml.parsers; version="${javax.xml.parsers.import.pkg.version}", + org.xml.sax, + org.w3c.dom, + org.osgi.framework; version="${osgi.framework.imp.pkg.version.range}", + org.apache.axiom.om; version="${axiom.osgi.version.range}", + org.apache.xerces.util; resolution:=optional, + org.apache.commons.logging; version="${import.package.version.commons.logging}", + org.apache.commons.collections; version="${commons-collections.wso2.osgi.version.range}", + org.osgi.service.component; version="${osgi.service.component.imp.pkg.version.range}", + org.wso2.carbon.context; version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.user.core.*;version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.utils.multitenancy;version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.user.api; version="${carbon.user.api.imp.pkg.version.range}", + org.wso2.carbon.identity.base; version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.core.*; version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.application.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.claim.metadata.mgt.*; + version="${carbon.identity.package.import.version.range}", + javax.servlet.http; version="${imp.pkg.version.javax.servlet}", + org.wso2.carbon.identity.flow.execution.engine; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.graph; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt.exception; + version="${carbon.identity.package.import.version.range}", + com.nimbusds.jose.*; version="${nimbusds.osgi.version.range}", + com.nimbusds.jwt; version="${nimbusds.osgi.version.range}", + org.wso2.carbon.identity.user.action.api.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.core.util; version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.identity.action.execution.api.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.action.management.api.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.central.log.mgt.utils; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.service; + version="${carbon.identity.package.import.version.range}", + com.fasterxml.jackson.core.*; version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.databind.*; + version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.annotation.*; + version="${com.fasterxml.jackson.annotation.version.range}", + org.wso2.carbon.utils; version="${carbon.kernel.package.import.version.range}", + org.slf4j; version="${org.slf4j.imp.pkg.version.range}" + + + !org.wso2.carbon.identity.flow.inflow.extensions.internal, + org.wso2.carbon.identity.flow.inflow.extensions, + org.wso2.carbon.identity.flow.inflow.extensions.executor, + org.wso2.carbon.identity.flow.inflow.extensions.model, + org.wso2.carbon.identity.flow.inflow.extensions.management, + org.wso2.carbon.identity.flow.inflow.extensions.metadata; + version="${carbon.identity.package.export.version}" + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + src/test/resources/testng.xml + + + ${project.build.testOutputDirectory} + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + true + + + + org.codehaus.mojo + findbugs-maven-plugin + + true + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + org/wso2/carbon/identity/flow/inflow/extensions/internal/** + + + + + default-prepare-agent + + prepare-agent + + + + default-prepare-agent-integration + + prepare-agent-integration + + + + default-report + + report + + + + default-report-integration + + report-integration + + + + default-check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.45 + + + + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 21 + + + + + + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java new file mode 100644 index 000000000000..a620e514dc9e --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Constants for the In-Flow Extension executor pipeline. + * + *

Keys are shared across the executor, request builder, and response processor + * via the {@link org.wso2.carbon.identity.action.execution.api.model.FlowContext} + * handoff mechanism. Path prefixes drive operation routing in the response processor.

+ */ +public class InFlowExtensionConstants { + + private InFlowExtensionConstants() { + + } + + // ---- FlowContext pipeline keys ---- + public static final String FLOW_EXECUTION_CONTEXT_KEY = "flowExecutionContext"; + public static final String PATH_TYPE_ANNOTATIONS_KEY = "pathTypeAnnotations"; + public static final String MODIFY_PATHS_KEY = "modifyPaths"; + public static final String PENDING_CLAIMS_KEY = "pendingClaims"; + public static final String PENDING_CREDENTIALS_KEY = "pendingCredentials"; + public static final String PENDING_PROPERTIES_KEY = "pendingProperties"; + public static final String PENDING_REDIRECT_URL_KEY = "pendingRedirectUrl"; + + // ---- Response info keys (FAILED path) ---- + public static final String FAILURE_TYPE_KEY = "failureType"; + public static final String IN_FLOW_EXTENSION_FAILURE_TYPE = "IN_FLOW_EXTENSION_FAILURE"; + public static final String FAILURE_MESSAGE_KEY = "failureMessage"; + public static final String FAILURE_DESCRIPTION_KEY = "failureDescription"; + + // ---- Context path prefixes ---- + public static final String PROPERTIES_PATH_PREFIX = "/properties/"; + public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; + public static final String USER_CREDENTIALS_PATH_PREFIX = "/user/credentials/"; + + // ---- Miscellaneous ---- + public static final String ACTION_ID_METADATA_KEY = "actionId"; + + /** + * Constants for In-Flow Extension action management (action properties stored in + * IDN_ACTION_PROPERTIES, certificate naming, and expose-path limits). + */ + public static final class ActionManagement { + + public static final String ACCESS_CONFIG_EXPOSE = "ACCESS_CONFIG_EXPOSE"; + public static final String ACCESS_CONFIG_MODIFY = "ACCESS_CONFIG_MODIFY"; + public static final String OVERRIDE_KEY_SEPARATOR = ":"; + public static final String ACCESS_CONFIG_EXPOSE_PREFIX = ACCESS_CONFIG_EXPOSE + OVERRIDE_KEY_SEPARATOR; + public static final String ACCESS_CONFIG_MODIFY_PREFIX = ACCESS_CONFIG_MODIFY + OVERRIDE_KEY_SEPARATOR; + public static final int MAX_EXPOSE_PATHS = 50; + public static final String ICON_URL = "ICON_URL"; + public static final String CERTIFICATE = "CERTIFICATE"; + public static final String CERTIFICATE_NAME_PREFIX = "ACTIONS:"; + + private ActionManagement() { } + } + + /** + * Diagnostic log constants for the In-Flow Extension layer. + */ + public static final class Log { + + public static final String COMPONENT_ID = "inflow-extension"; + + private Log() { + + } + + /** + * Action IDs for diagnostic events emitted by the In-Flow Extension layer. + */ + public static final class ActionIDs { + + public static final String EXECUTE = "execute-inflow-extension"; + public static final String PROCESS_RESPONSE = "process-inflow-extension-response"; + + private ActionIDs() { + + } + } + } + + /** + * Compile-time default handover policy constants. + * + *

These constants define which {@code FlowExecutionContext} and {@code FlowUser} + * fields are handed to the action framework during in-flow extension execution. + * {@code "properties"} is intentionally excluded from {@link #INCLUDED_ATTRIBUTES}: + * it is always modifiable via the executor response path (context tree always exposes + * it with MODIFY ops), but must not be forwarded to external services by default.

+ * + *

When the toml-based dynamic config PR is merged, these constants serve as the + * documented defaults for {@code identity.xml.j2}.

+ */ + public static final class HandoverPolicy { + + private HandoverPolicy() { } + + /** Attribute name for the {@code flowUser} field. When present in + * {@link #INCLUDED_ATTRIBUTES}, {@code fullUserPassthrough} is set to true. */ + public static final String ATTR_FLOW_USER = "flowUser"; + + /** Context identifier; always copied by the filter regardless of config. */ + public static final String ATTR_CONTEXT_IDENTIFIER = "contextIdentifier"; + + /** User-credentials property name; requires per-entry {@code char[]} cloning. */ + public static final String ATTR_USER_CREDENTIALS = "userCredentials"; + + /** + * Top-level {@code FlowExecutionContext} fields that are handed to the action framework. + * Corresponds to the future toml key: + * {@code flow_execution_context.handover.filtering.included_attributes}. + */ + public static final Set INCLUDED_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "contextIdentifier", + "tenantDomain", + "applicationId", + "flowType", + "callbackUrl", + "portalUrl", + "flowUser" // presence sets fullUserPassthrough = true + // "properties" intentionally excluded — sensitive flow-state data + ))); + + /** + * {@code FlowUser} fields that are handed over when full-passthrough is not active. + * Corresponds to the future toml key: + * {@code flow_execution_context.handover.filtering.included_user_attributes}. + */ + public static final Set INCLUDED_USER_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "userId", + "username", + "userStoreDomain", + "claims", + "userCredentials" + ))); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java new file mode 100644 index 000000000000..e61d75868057 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import java.util.List; + +/** + * Utility class for hierarchical prefix-based path matching used in In-Flow Extension + * access control. + * Provides area-gate checks via {@link #anyExposedUnder(String, List)} and exact + * leaf-path checks via {@link #isExposedPath(String, List)}. + */ +public final class HierarchicalPrefixMatcher { + + // Context area prefix constants + public static final String USER_PREFIX = "/user/"; + public static final String USER_CLAIMS_PREFIX = "/user/claims/"; + public static final String USER_CREDENTIALS_PREFIX = "/user/credentials/"; + public static final String USER_ID_PATH = "/user/userId"; + public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; + + public static final String PROPERTIES_PREFIX = "/properties/"; + + public static final String FLOW_PREFIX = "/flow/"; + public static final String FLOW_TENANT_PATH = "/flow/tenantDomain"; + public static final String FLOW_APP_ID_PATH = "/flow/applicationId"; + public static final String FLOW_TYPE_PATH = "/flow/flowType"; + public static final String FLOW_CALLBACK_URL_PATH = "/flow/callbackUrl"; + public static final String FLOW_PORTAL_URL_PATH = "/flow/portalUrl"; + + private HierarchicalPrefixMatcher() { + + } + + /** + * Check if a path is read-only (in /flow/ area). + * + * @param path The path to check + * @return true if the path is in a read-only area + */ + public static boolean isReadOnly(String path) { + + if (path == null) { + return false; + } + return path.startsWith(FLOW_PREFIX); + } + + /** + * Check if any leaf path in the list falls under the given area prefix. + * + *

Used as an area-gate check before iterating over a data block — e.g., to decide + * whether to include any claims, credentials, or properties in the outgoing request. + * The {@code areaPrefix} always ends with {@code /} (e.g. {@code /user/claims/}). + * The {@code leafPaths} list contains only exact leaf paths with no trailing {@code /}.

+ * + * @param areaPrefix The area prefix to check (must end with {@code /}). + * @param leafPaths The list of exposed leaf paths. + * @return {@code true} if at least one leaf path starts with the area prefix. + */ + public static boolean anyExposedUnder(String areaPrefix, List leafPaths) { + + if (areaPrefix == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + for (String path : leafPaths) { + if (path != null && path.startsWith(areaPrefix)) { + return true; + } + } + return false; + } + + /** + * Check if an exact leaf path is present in the expose list. + * + *

Used for leaf-level filtering — e.g., to decide whether a specific claim URI, + * credential key, or scalar field should be included in the outgoing request. + * The {@code leafPath} has no trailing {@code /}. + * The {@code leafPaths} list contains only exact leaf paths.

+ * + * @param leafPath The exact path to look up. + * @param leafPaths The list of exposed leaf paths. + * @return {@code true} if the path is present in the list. + */ + public static boolean isExposedPath(String leafPath, List leafPaths) { + + if (leafPath == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + return leafPaths.contains(leafPath); + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java new file mode 100644 index 000000000000..ff89a11d48dd --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java @@ -0,0 +1,476 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; +import org.wso2.carbon.utils.DiagnosticLog; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; +import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.inflow.extensions.util.InFlowExtensionContextFilterUtil; +import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; +import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Executes In-Flow Extension actions during flow execution by delegating to + * {@link ActionExecutorService} and mapping the result to an {@link ExecutorResponse}. + * On success, pending context updates (claims, credentials, properties) are forwarded + * to the flow engine through the response object. + */ +public class InFlowExtensionExecutor implements Executor { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class); + private static final String EXECUTOR_NAME = "InFlowExtensionExecutor"; + private static final String CONFIG_PARAM_ACTION_TYPE = "actionType"; + private static final String CONFIG_PARAM_ACTION_ID = "actionId"; + + + + @Override + public String getName() { + + return EXECUTOR_NAME; + } + + @Override + public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineException { + + String actionId = getMetadataValue(context, InFlowExtensionConstants.ACTION_ID_METADATA_KEY); + if (actionId == null || actionId.isEmpty()) { + LOG.warn("No action ID configured for In-Flow Extension executor. Cannot execute."); + triggerDiagnosticFailure(null, + "In-Flow Extension action execution failed: action ID is not configured."); + return buildErrorResponse("Extension is not configured.", + "The In-Flow Extension action is missing required configuration. " + + "Contact your administrator."); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Executing In-Flow Extension action. actionId: " + actionId + + ", flowType: " + context.getFlowType() + + ", tenant: " + context.getTenantDomain()); + } + + ActionExecutorService actionExecutorService = getActionExecutorService(); + if (actionExecutorService == null) { + LOG.error("ActionExecutorService is not available. In-Flow Extension cannot execute. actionId: " + actionId); + triggerDiagnosticFailure(actionId, + "In-Flow Extension action execution failed: ActionExecutorService is unavailable."); + throw new FlowEngineException("ActionExecutorService is not available."); + } + + if (!actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)) { + LOG.debug("In-Flow Extension action execution is disabled."); + triggerDiagnosticFailure(actionId, + "In-Flow Extension action execution failed: action type is disabled."); + return buildErrorResponse("Extension execution is disabled.", + "The In-Flow Extension action type is currently disabled on this server."); + } + + try { + // Hand the action framework only a FILTERED copy of the FlowExecutionContext + // (non-whitelisted fields nulled out). Policy is sourced from compile-time constants. + FlowExecutionContext filteredContext = InFlowExtensionContextFilterUtil.filter( + context, FlowContextHandoverConfig.defaultPolicy()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, filteredContext); + + ActionExecutionStatus executionStatus = actionExecutorService.execute( + ActionType.IN_FLOW_EXTENSION, actionId, flowContext, context.getTenantDomain()); + + ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context); + + // On success, extract pending context updates collected by the response processor + // and forward them to TaskExecutionNode via ExecutorResponse fields. + if (ExecutorStatus.STATUS_COMPLETE.equals(executionResponse.getResult())) { + applyPendingContextUpdates(executionResponse, flowContext, actionId); + } + + if (ExecutorStatus.STATUS_RETRY.equals(executionResponse.getResult())) { + applyRetryMetadata(executionResponse, actionId); + } + + return executionResponse; + + } catch (ActionExecutionException e) { + logActionExecutionException(e, actionId); + triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: " + e.getMessage()); + return buildErrorResponse("An error occurred while processing the extension. Please try again.", + "The external extension service could not complete the request. " + + "If the problem persists, contact your administrator."); + } + } + + @Override + public List getInitiationData() { + + return Collections.emptyList(); + } + + @Override + public ExecutorResponse rollback(FlowExecutionContext context) { + + return null; + } + + /** + * Map the {@link ActionExecutionStatus} to an {@link ExecutorResponse}. + * Performs status translation and (for INCOMPLETE/redirect) generates the OTFI used by + * {@code FlowExecutionService} to swap caches on resume — same pattern as {@code MagicLinkExecutor}. + * + * @param executionStatus The status returned by ActionExecutorService. + * @param flowContext The action {@link FlowContext} where the response processor stashed the redirect URL. + * @param context The engine {@link FlowExecutionContext} (used for OTFI collision-guard). + * @return The ExecutorResponse for the flow execution engine. + */ + private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionStatus, + FlowContext flowContext, FlowExecutionContext context) { + + ExecutorResponse response = new ExecutorResponse(); + + if (executionStatus == null) { + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage("Extension did not return a response."); + response.setErrorDescription("The In-Flow Extension action did not return a status. Please try again."); + return response; + } + + switch (executionStatus.getStatus()) { + case SUCCESS: + response.setResult(ExecutorStatus.STATUS_COMPLETE); + return response; + + case FAILED: + handleFailedStatus(response, executionStatus); + return response; + + case ERROR: + handleErrorStatus(response, executionStatus); + return response; + + case INCOMPLETE: + return handleIncompleteExecutionStatus(response, flowContext, context); + + default: + return handleUnknownExecutionStatus(response, executionStatus); + } + } + + /** + * Build a user-facing error message from the failure details returned by the external service. + * Prefers the failureDescription (human-readable). Falls back to failureReason if description is absent. + * + * @param failure The failure object from the external service. + * @return A display-ready error message string. + */ + private String buildUserFacingErrorMessage(Failure failure) { + + String description = failure.getFailureDescription(); + String reason = failure.getFailureReason(); + + if (description != null && !description.isEmpty()) { + return description; + } + if (reason != null && !reason.isEmpty()) { + return reason; + } + return "The operation could not be completed due to an external service failure."; + } + + private ExecutorResponse buildErrorResponse(String errorMessage, String errorDescription) { + + ExecutorResponse response = new ExecutorResponse(); + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage(errorMessage); + response.setErrorDescription(errorDescription); + return response; + } + + private void applyRetryMetadata(ExecutorResponse response, String actionId) { + + Map additionalInfo = response.getAdditionalInfo(); + if (additionalInfo == null) { + additionalInfo = new HashMap<>(); + } + additionalInfo.put(InFlowExtensionConstants.FAILURE_TYPE_KEY, + InFlowExtensionConstants.IN_FLOW_EXTENSION_FAILURE_TYPE); + response.setAdditionalInfo(additionalInfo); + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension action returned FAILED. actionId: " + actionId + + ", reason: " + additionalInfo.get(InFlowExtensionConstants.FAILURE_MESSAGE_KEY)); + } + } + + private void handleFailedStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { + + response.setResult(ExecutorStatus.STATUS_RETRY); + Failure failure = (Failure) executionStatus.getResponse(); + if (failure == null) { + return; + } + + Map failureInfo = new HashMap<>(); + if (failure.getFailureReason() != null) { + failureInfo.put(InFlowExtensionConstants.FAILURE_MESSAGE_KEY, failure.getFailureReason()); + } + if (failure.getFailureDescription() != null) { + failureInfo.put(InFlowExtensionConstants.FAILURE_DESCRIPTION_KEY, failure.getFailureDescription()); + } + response.setAdditionalInfo(failureInfo); + response.setErrorMessage(buildUserFacingErrorMessage(failure)); + } + + private void handleErrorStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { + + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + Error error = (Error) executionStatus.getResponse(); + if (error == null) { + return; + } + + response.setErrorMessage(stripI18nBraces(error.getErrorMessage())); + response.setErrorDescription(stripI18nBraces(error.getErrorDescription())); + } + + private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse response, FlowContext flowContext, + FlowExecutionContext context) { + + String redirectUrl = flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class); + if (redirectUrl == null || redirectUrl.isEmpty()) { + // Defensive: response processor should have rejected this earlier. + LOG.debug("In-Flow Extension returned INCOMPLETE without a redirect URL."); + triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "In-Flow Extension returned INCOMPLETE without a redirect URL."); + return buildErrorResponse("Extension returned INCOMPLETE without a redirect URL.", + "The external extension returned an incomplete response. Please try again."); + } + + String otfi = generateUniqueOtfi(context.getContextIdentifier()); + Map redirectProps = new HashMap<>(); + redirectProps.put(Constants.OTFI, otfi); + response.setContextProperty(redirectProps); + + String urlWithFlowId = appendFlowId(redirectUrl, otfi); + Map redirectInfo = new HashMap<>(); + redirectInfo.put(Constants.REDIRECT_URL, urlWithFlowId); + response.setAdditionalInfo(redirectInfo); + + response.setResult(ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + triggerDiagnosticSuccess(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "In-Flow Extension returned INCOMPLETE with a redirect URL."); + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension returned INCOMPLETE. Redirect initiated and flowId (OTFI) generated."); + } + + return response; + } + + private ExecutorResponse handleUnknownExecutionStatus(ExecutorResponse response, + ActionExecutionStatus executionStatus) { + + LOG.warn("Unknown execution status: " + executionStatus.getStatus()); + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage("Extension returned an unexpected response."); + response.setErrorDescription("The In-Flow Extension returned an unrecognised status. Please try again."); + return response; + } + + private String generateUniqueOtfi(String currentContextIdentifier) { + + // Avoid accidental collision with the current context identifier. + String otfi = UUID.randomUUID().toString(); + while (otfi.equals(currentContextIdentifier)) { + otfi = UUID.randomUUID().toString(); + } + return otfi; + } + + private String appendFlowId(String redirectUrl, String otfi) { + + String separator = redirectUrl.contains("?") ? "&" : "?"; + return redirectUrl + separator + "flowId=" + otfi; + } + + @SuppressWarnings("unchecked") + private void applyPendingContextUpdates(ExecutorResponse response, FlowContext flowContext, String actionId) { + + Map pendingClaims = + flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + if (pendingClaims != null && !pendingClaims.isEmpty()) { + response.setUpdatedUserClaims(pendingClaims); + } + + Map pendingCredentials = + flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class); + if (pendingCredentials != null && !pendingCredentials.isEmpty()) { + response.setUserCredentials(pendingCredentials); + } + + Map pendingProperties = + flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + if (pendingProperties != null && !pendingProperties.isEmpty()) { + response.setContextProperty(pendingProperties); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension action succeeded. actionId: " + actionId + + ", pendingClaims: " + (pendingClaims != null ? pendingClaims.size() : 0) + + ", pendingCredentials: " + (pendingCredentials != null ? pendingCredentials.size() : 0) + + ", pendingProperties: " + (pendingProperties != null ? pendingProperties.size() : 0)); + } + } + + private void triggerDiagnosticFailure(String actionId, String resultMessage) { + + triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.EXECUTE, actionId, resultMessage); + } + + private void triggerDiagnosticFailure(String diagnosticActionId, String actionId, String resultMessage) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) + .resultMessage(resultMessage) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED); + + if (actionId != null) { + builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); + } + + LoggerUtils.triggerDiagnosticLogEvent(builder); + } + + private void triggerDiagnosticSuccess(String diagnosticActionId, String actionId, String resultMessage) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) + .resultMessage(resultMessage) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); + + if (actionId != null) { + builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); + } + + LoggerUtils.triggerDiagnosticLogEvent(builder); + } + + private ActionExecutorService getActionExecutorService() { + + return InFlowExtensionDataHolder.getInstance().getActionExecutorService(); + } + + /** + * Log an {@link ActionExecutionException} at the appropriate level based on its root cause. + * Config and contract violations are logged at WARN; infrastructure and unexpected failures at ERROR. + */ + private void logActionExecutionException(ActionExecutionException e, String actionId) { + + Throwable cause = e.getCause(); + if (cause instanceof ActionExecutionRequestBuilderException) { + LOG.warn("In-Flow Extension action '" + actionId + + "' request build failed. Check action access configuration: " + e.getMessage()); + } else if (cause instanceof ActionExecutionResponseProcessorException) { + LOG.error("In-Flow Extension action '" + actionId + + "' response processing failed (extension contract violation or internal error).", e); + } else { + LOG.error("Error executing In-Flow Extension action '" + actionId + "'.", e); + } + } + + /** + * Strip the {@code {{...}}} wrapper from an i18n key so the JSP error page can resolve it + * via {@code AuthenticationEndpointUtil.i18n(resourceBundle, key)}. Raw text values (without + * the wrapper) and {@code null} are returned unchanged. + */ + private static String stripI18nBraces(String value) { + + if (value == null) { + return null; + } + if (value.startsWith("{{") && value.endsWith("}}") && value.length() > 4) { + return value.substring(2, value.length() - 2); + } + return value; + } + + /** + * Read a single metadata value from the current node's executor configuration. + * + * @param context The FlowExecutionContext. + * @param key The metadata key. + * @return The value, or {@code null} if not found. + */ + private String getMetadataValue(FlowExecutionContext context, String key) { + + NodeConfig currentNode = context.getCurrentNode(); + if (currentNode == null) { + return null; + } + ExecutorDTO executorConfig = currentNode.getExecutorConfig(); + if (executorConfig == null) { + return null; + } + Map metadata = executorConfig.getMetadata(); + if (metadata == null || metadata.isEmpty()) { + return null; + } + return metadata.get(key); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java new file mode 100644 index 000000000000..584fdd165626 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java @@ -0,0 +1,823 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.inflow.extensions.model.*; +import org.wso2.carbon.utils.DiagnosticLog; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; +import org.wso2.carbon.identity.action.execution.api.model.Application; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Header; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.Organization; +import org.wso2.carbon.identity.action.execution.api.model.Tenant; +import org.wso2.carbon.identity.action.execution.api.model.User; +import org.wso2.carbon.identity.action.execution.api.model.UserClaim; +import org.wso2.carbon.identity.action.execution.api.model.UserStore; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.core.context.IdentityContext; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class is responsible for building the {@link ActionExecutionRequest} for In-Flow Extension + * actions. + * + *

Responsibility: expose-based filtering and request construction. + * It receives a {@link FlowContext} containing the full {@link FlowExecutionContext}, the expose + * list, and the access config. It filters the {@link FlowExecutionContext} data according + * to the expose configuration and maps the result into the {@link InFlowExtensionEvent} model. + * Modify paths from the access config are converted to a single REPLACE {@link AllowedOperation}.

+ */ +public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuilder { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionRequestBuilder.class); + private static final int MAX_DIAGNOSTIC_PATHS = 20; + private static final String DIAGNOSTIC_REASON = "reason"; + private static final String REASON_UNSUPPORTED_ACTION = "unsupported-action-model"; + private static final String REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS = "omitted-encrypted-expose-paths"; + + @Override + public ActionType getSupportedActionType() { + + return ActionType.IN_FLOW_EXTENSION; + } + + @Override + public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContext, + ActionExecutionRequestContext actionExecutionContext) + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = getFlowExecutionContextOrThrow(flowContext); + ResolvedActionConfig resolvedActionConfig = resolveActionConfig(actionExecutionContext, execCtx.getFlowType()); + if (resolvedActionConfig.isFallback()) { + return buildFallbackRequest(flowContext, execCtx); + } + + AccessConfig accessConfig = resolvedActionConfig.getAccessConfig(); + Encryption encryption = resolvedActionConfig.getEncryption(); + + List exposePaths = resolveExposePaths(accessConfig); + List modifyPaths = resolveModifyPaths(accessConfig); + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); + + List allowedOperations = buildAllowedOperations(accessConfig, flowContext); + String certificatePEM = resolveOutboundCertificate(accessConfig, encryption); + ExposeResolution exposeResolution = pruneEncryptedExposePathsWithoutCertificate( + exposePaths, accessConfig, certificatePEM); + + triggerRequestBuildDiagnostic(execCtx, exposeResolution.getEffectiveExposePaths(), + modifyPaths, encryption, exposeResolution.getOmittedEncryptedExposePaths()); + + InFlowExtensionEvent event = buildEvent(execCtx, exposeResolution.getEffectiveExposePaths(), + accessConfig, certificatePEM); + return buildRequestPayload(execCtx, event, allowedOperations); + } + + /** + * Build the allowed operations advertised to the external extension service. Always + * includes a REDIRECT operation so the extension may signal external redirection. A + * REPLACE operation is added only when the access config defines modify paths. Path + * type annotations (e.g. {@code []} or {@code [schema]}) are stripped and stored in + * the {@link FlowContext} under {@link InFlowExtensionConstants#PATH_TYPE_ANNOTATIONS_KEY} + * for the response processor. + * + * @param accessConfig The access config containing modify paths (may be null). + * @param flowContext The FlowContext to store path annotations. + * @return List of allowed operations (REPLACE if applicable, plus REDIRECT). + */ + private List buildAllowedOperations(AccessConfig accessConfig, + FlowContext flowContext) { + + List allowedOps = new ArrayList<>(); + + if (hasModifyPaths(accessConfig)) { + AllowedModifyExtraction extraction = extractAllowedModifyPaths(accessConfig.getModify()); + addReplaceOperationIfAny(allowedOps, extraction.getCleanPaths()); + storeAnnotationsIfAny(flowContext, extraction.getPathTypeAnnotations()); + + if (LOG.isDebugEnabled() && !extraction.getSkippedPaths().isEmpty()) { + LOG.debug("Skipped " + extraction.getSkippedPaths().size() + + " modify path(s) due to invalid or missing path definitions."); + } + } + + addRedirectOperation(allowedOps); + + return allowedOps; + } + + /** + * Build the {@link InFlowExtensionEvent} from the {@link FlowExecutionContext}, + * filtering data according to the expose configuration. + * Values for expose paths with {@code encrypted: true} are JWE-encrypted using the + * external service's certificate before being included in the event. + * + * @param context The FlowExecutionContext (full source of truth). + * @param expose The expose prefix list controlling which data is included. + * @param accessConfig The access config (may be null if no encryption). + * @param certificatePEM The external service's certificate PEM for JWE encryption (may be null). + * @return The InFlowExtensionEvent. + */ + private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + InFlowExtensionEvent.Builder eventBuilder = new InFlowExtensionEvent.Builder(); + eventBuilder.request(buildRequest()); + + applyTenant(eventBuilder, context, expose); + applyOrganization(eventBuilder); + applyApplication(eventBuilder, context, expose); + applyUserAndUserStore(eventBuilder, context, expose, accessConfig, certificatePEM); + applyFlowMetadata(eventBuilder, context, expose); + applyFlowProperties(eventBuilder, context, expose, accessConfig, certificatePEM); + + return eventBuilder.build(); + } + + /** + * Build the {@link InFlowExtensionRequest} carrying the inbound HTTP request's additional + * headers. Headers are sourced from the {@link IdentityContext} thread-local populated by + * {@code IdentityContextCreatorValve}. The action framework filters these against the + * action's allowed-headers list before dispatching. + * + * @return Populated request (empty headers list if no inbound request is available). + */ + private InFlowExtensionRequest buildRequest() { + + InFlowExtensionRequest request = new InFlowExtensionRequest(); + + org.wso2.carbon.identity.core.context.model.Request inboundRequest = + IdentityContext.getThreadLocalIdentityContext().getRequest(); + if (inboundRequest == null) { + return request; + } + + List
headers = new ArrayList<>(); + for (org.wso2.carbon.identity.core.context.model.Header coreHeader : inboundRequest.getHeaders()) { + if (coreHeader.getName() == null) { + continue; + } + List values = coreHeader.getValue(); + String[] valueArray = values != null + ? values.toArray(new String[0]) : new String[0]; + headers.add(new Header(coreHeader.getName(), valueArray)); + } + request.setAdditionalHeaders(headers); + + return request; + } + + /** + * Build the {@link User} model from {@link FlowUser}, filtering by expose config. + * Encrypts credential and claim values for expose paths marked as encrypted. + * + * @param flowUser The FlowUser from the FlowExecutionContext. + * @param expose The expose prefix list. + * @param accessConfig The access config with encryption flags (may be null). + * @param certificatePEM The certificate PEM for JWE encryption (may be null). + * @return The filtered User model with encrypted values where configured. + */ + private User buildUser(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + User.Builder userBuilder = new User.Builder(resolveUserId(flowUser, expose)); + List userClaims = buildFilteredClaims(flowUser, expose, accessConfig, certificatePEM); + if (!userClaims.isEmpty()) { + userBuilder.claims(userClaims); + } + + Map filteredCredentials = + buildFilteredCredentials(flowUser, expose, accessConfig, certificatePEM); + if (!filteredCredentials.isEmpty()) { + userBuilder.userCredentials(filteredCredentials); + } + + return userBuilder.build(); + } + + /** + * Filter a map to only include entries whose paths are exposed. + * Values for expose paths marked as encrypted are JWE-encrypted. + * + * @param map The source map. + * @param areaPrefix The area prefix (e.g. "/properties/"). + * @param expose The expose prefix list. + * @param accessConfig The access config with encryption flags (may be null). + * @param certificatePEM The certificate PEM for JWE encryption (may be null). + * @param The value type. + * @return A new map containing only exposed entries, with encrypted values where configured. + */ + @SuppressWarnings("unchecked") + private Map filterMap(Map map, String areaPrefix, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (map == null) { + return Collections.emptyMap(); + } + + Map filtered = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + String fullPath = areaPrefix + entry.getKey(); + if (isLeafExposed(fullPath, expose)) { + T value = entry.getValue(); + if (value != null && shouldEncrypt(fullPath, accessConfig, certificatePEM)) { + value = (T) encryptValue(String.valueOf(value), certificatePEM); + } + filtered.put(entry.getKey(), value); + } + } + return filtered; + } + + private FlowExecutionContext getFlowExecutionContextOrThrow(FlowContext flowContext) + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = flowContext.getValue( + InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); + if (execCtx == null) { + throw new ActionExecutionRequestBuilderException("FlowExecutionContext not found in FlowContext."); + } + return execCtx; + } + + private ResolvedActionConfig resolveActionConfig(ActionExecutionRequestContext actionExecutionContext, + String flowType) { + + Action rawAction = actionExecutionContext.getAction(); + if (rawAction instanceof InFlowExtensionAction) { + InFlowExtensionAction ext = (InFlowExtensionAction) rawAction; + return new ResolvedActionConfig(ext.resolveAccessConfig(flowType), ext.getEncryption(), false); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("No InFlowExtensionAction resolved. Falling back to an empty request body."); + } + return new ResolvedActionConfig(null, null, true); + } + + private List resolveExposePaths(AccessConfig accessConfig) { + + if (accessConfig == null || accessConfig.getExpose() == null) { + return Collections.emptyList(); + } + return accessConfig.getExposePaths(); + } + + private List resolveModifyPaths(AccessConfig accessConfig) { + + if (accessConfig == null || accessConfig.getModify() == null) { + return Collections.emptyList(); + } + return accessConfig.getModify(); + } + + private String resolveOutboundCertificate(AccessConfig accessConfig, Encryption encryption) { + + if (accessConfig == null || encryption == null || encryption.getCertificate() == null) { + return null; + } + return encryption.getCertificate().getCertificateContent(); + } + + private ExposeResolution pruneEncryptedExposePathsWithoutCertificate(List exposePaths, + AccessConfig accessConfig, + String certificatePEM) { + + if (certificatePEM != null || accessConfig == null || exposePaths.isEmpty()) { + return new ExposeResolution(exposePaths, Collections.emptyList()); + } + + List effectiveExposePaths = new ArrayList<>(); + List omittedEncryptedExposePaths = new ArrayList<>(); + for (String exposePath : exposePaths) { + if (accessConfig.isExposePathEncrypted(exposePath)) { + omittedEncryptedExposePaths.add(exposePath); + } else { + effectiveExposePaths.add(exposePath); + } + } + + if (!omittedEncryptedExposePaths.isEmpty()) { + LOG.warn("Outbound certificate is not configured. Omitted " + omittedEncryptedExposePaths.size() + + " encrypted expose path(s)."); + triggerOmittedExposeDiagnostic(omittedEncryptedExposePaths); + } + + return new ExposeResolution(effectiveExposePaths, omittedEncryptedExposePaths); + } + + private ActionExecutionRequest buildFallbackRequest(FlowContext flowContext, FlowExecutionContext execCtx) { + + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, Collections.emptyList()); + List allowedOperations = buildAllowedOperations(null, flowContext); + triggerFallbackDiagnostic(execCtx); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowId(execCtx.getContextIdentifier()) + .build(); + return buildRequestPayload(execCtx, event, allowedOperations); + } + + private ActionExecutionRequest buildRequestPayload(FlowExecutionContext execCtx, InFlowExtensionEvent event, + List allowedOperations) { + + return new ActionExecutionRequest.Builder() + .actionType(ActionType.IN_FLOW_EXTENSION) + .flowId(execCtx.getContextIdentifier()) + .event(event) + .allowedOperations(allowedOperations) + .build(); + } + + private void triggerRequestBuildDiagnostic(FlowExecutionContext execCtx, List exposePaths, + List modifyPaths, Encryption encryption, + List omittedEncryptedExposePaths) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("Building request for In-Flow Extension action.") + .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam("flowType", execCtx.getFlowType()) + .configParam("exposePaths", exposePaths.size()) + .configParam("modifyPaths", modifyPaths.size()) + .configParam("outboundEncryption", encryption != null) + .inputParam("exposePathKeys", limitForDiagnostic(exposePaths)) + .inputParam("modifyPathKeys", limitForDiagnostic(extractModifyPathKeys(modifyPaths))) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); + + if (!omittedEncryptedExposePaths.isEmpty()) { + diagnosticLogBuilder + .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) + .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)); + } + + LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); + } + + private List extractModifyPathKeys(List modifyPaths) { + + List modifyPathKeys = new ArrayList<>(); + for (ContextPath modifyPath : modifyPaths) { + if (modifyPath != null && modifyPath.getPath() != null) { + modifyPathKeys.add(modifyPath.getPath()); + } + } + return modifyPathKeys; + } + + private List limitForDiagnostic(List values) { + + if (values.size() <= MAX_DIAGNOSTIC_PATHS) { + return values; + } + return new ArrayList<>(values.subList(0, MAX_DIAGNOSTIC_PATHS)); + } + + private void triggerFallbackDiagnostic(FlowExecutionContext execCtx) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("No InFlowExtensionAction resolved. Built minimal fallback request.") + .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam("flowType", execCtx.getFlowType()) + .configParam(DIAGNOSTIC_REASON, REASON_UNSUPPORTED_ACTION) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + + private void triggerOmittedExposeDiagnostic(List omittedEncryptedExposePaths) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("Omitted encrypted expose paths because outbound certificate is not configured.") + .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) + .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + + private boolean hasModifyPaths(AccessConfig accessConfig) { + + return accessConfig != null && accessConfig.getModify() != null && !accessConfig.getModify().isEmpty(); + } + + private AllowedModifyExtraction extractAllowedModifyPaths(List modifyPaths) { + + List cleanPaths = new ArrayList<>(); + Map pathTypeAnnotations = new HashMap<>(); + List skippedPaths = new ArrayList<>(); + + for (ContextPath modifyPath : modifyPaths) { + String rawPath = modifyPath == null ? null : modifyPath.getPath(); + if (rawPath == null) { + skippedPaths.add(""); + } else { + String[] strippedPath = PathTypeAnnotationUtil.stripAnnotation(rawPath); + String cleanPath = strippedPath[0]; + String annotation = strippedPath[1]; + + boolean isAnnotationValid = annotation == null || PathTypeAnnotationUtil + .validateAnnotationLimits(annotation); + if (isAnnotationValid) { + if (annotation != null) { + pathTypeAnnotations.put(cleanPath, annotation); + } + cleanPaths.add(cleanPath); + } else { + LOG.warn("Annotation for path " + cleanPath + + " exceeds maximum attribute limit. Skipping path."); + skippedPaths.add(cleanPath); + } + } + } + + return new AllowedModifyExtraction(cleanPaths, pathTypeAnnotations, skippedPaths); + } + + private void addReplaceOperationIfAny(List allowedOperations, List cleanPaths) { + + if (cleanPaths.isEmpty()) { + return; + } + + AllowedOperation replaceOp = new AllowedOperation(); + replaceOp.setOp(Operation.REPLACE); + replaceOp.setPaths(cleanPaths); + allowedOperations.add(replaceOp); + } + + private void storeAnnotationsIfAny(FlowContext flowContext, Map pathTypeAnnotations) { + + if (!pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + } + + private void addRedirectOperation(List allowedOperations) { + + AllowedOperation redirectOp = new AllowedOperation(); + redirectOp.setOp(Operation.REDIRECT); + allowedOperations.add(redirectOp); + } + + private void applyTenant(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(HierarchicalPrefixMatcher.FLOW_TENANT_PATH, expose)) { + return; + } + + String tenantDomain = context.getTenantDomain(); + if (tenantDomain != null) { + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + eventBuilder.tenant(new Tenant(String.valueOf(tenantId), tenantDomain)); + } + } + + private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder) { + + org.wso2.carbon.identity.core.context.model.Organization coreOrg = + IdentityContext.getThreadLocalIdentityContext().getOrganization(); + if (coreOrg == null) { + return; + } + + eventBuilder.organization(new Organization.Builder() + .id(coreOrg.getId()) + .name(coreOrg.getName()) + .orgHandle(coreOrg.getOrganizationHandle()) + .depth(coreOrg.getDepth()) + .build()); + } + + private void applyApplication(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(HierarchicalPrefixMatcher.FLOW_APP_ID_PATH, expose)) { + return; + } + + String appId = context.getApplicationId(); + if (appId != null) { + eventBuilder.application(new Application(appId, null)); + } + } + + private void applyUserAndUserStore(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(HierarchicalPrefixMatcher.USER_PREFIX, expose)) { + return; + } + + FlowUser flowUser = context.getFlowUser(); + if (flowUser == null) { + return; + } + + eventBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); + if (isLeafExposed(HierarchicalPrefixMatcher.USER_STORE_DOMAIN_PATH, expose) + && flowUser.getUserStoreDomain() != null) { + eventBuilder.userStore(new UserStore(flowUser.getUserStoreDomain())); + } + } + + private void applyFlowMetadata(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_TYPE_PATH, expose)) { + eventBuilder.flowType(context.getFlowType()); + } + + eventBuilder.flowId(context.getContextIdentifier()); + + if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_CALLBACK_URL_PATH, expose) + && context.getCallbackUrl() != null) { + eventBuilder.callbackUrl(context.getCallbackUrl()); + } + + if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_PORTAL_URL_PATH, expose) + && context.getPortalUrl() != null) { + eventBuilder.portalUrl(context.getPortalUrl()); + } + } + + private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(HierarchicalPrefixMatcher.PROPERTIES_PREFIX, expose)) { + return; + } + + Map properties = context.getProperties(); + if (properties != null && !properties.isEmpty()) { + eventBuilder.flowProperties( + filterMap(properties, HierarchicalPrefixMatcher.PROPERTIES_PREFIX, + expose, accessConfig, certificatePEM)); + } + } + + private String resolveUserId(FlowUser flowUser, List expose) { + + if (isLeafExposed(HierarchicalPrefixMatcher.USER_ID_PATH, expose)) { + return flowUser.getUserId(); + } + return null; + } + + private List buildFilteredClaims(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX, expose)) { + return Collections.emptyList(); + } + + Map claims = flowUser.getClaims(); + if (claims == null || claims.isEmpty()) { + return Collections.emptyList(); + } + + List userClaims = new ArrayList<>(); + for (Map.Entry claim : claims.entrySet()) { + String claimPath = HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX + claim.getKey(); + if (isLeafExposed(claimPath, expose)) { + String claimValue = claim.getValue(); + if (claimValue != null && shouldEncrypt(claimPath, accessConfig, certificatePEM)) { + claimValue = encryptValue(claimValue, certificatePEM); + } + userClaims.add(new UserClaim(claim.getKey(), claimValue)); + } + } + return userClaims; + } + + private Map buildFilteredCredentials(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(HierarchicalPrefixMatcher.USER_CREDENTIALS_PREFIX, expose)) { + return Collections.emptyMap(); + } + + Map credentials = flowUser.getUserCredentials(); + if (credentials == null || credentials.isEmpty()) { + return Collections.emptyMap(); + } + + Map filteredCredentials = new HashMap<>(); + for (Map.Entry entry : credentials.entrySet()) { + String credentialPath = HierarchicalPrefixMatcher.USER_CREDENTIALS_PREFIX + entry.getKey(); + if (isLeafExposed(credentialPath, expose)) { + char[] credentialValue = entry.getValue(); + String plaintext = new String(credentialValue); + java.util.Arrays.fill(credentialValue, '\0'); + + filteredCredentials.put(entry.getKey(), + toEncryptedOrPlainCredentialChars(plaintext, credentialPath, accessConfig, certificatePEM)); + } + } + return filteredCredentials; + } + + private char[] toEncryptedOrPlainCredentialChars(String plaintext, String credentialPath, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (shouldEncrypt(credentialPath, accessConfig, certificatePEM)) { + return encryptValue(plaintext, certificatePEM).toCharArray(); + } + return plaintext.toCharArray(); + } + + private static class ResolvedActionConfig { + + private final AccessConfig accessConfig; + private final Encryption encryption; + private final boolean fallback; + + private ResolvedActionConfig(AccessConfig accessConfig, Encryption encryption, boolean fallback) { + + this.accessConfig = accessConfig; + this.encryption = encryption; + this.fallback = fallback; + } + + private AccessConfig getAccessConfig() { + + return accessConfig; + } + + private Encryption getEncryption() { + + return encryption; + } + + private boolean isFallback() { + + return fallback; + } + } + + private static class ExposeResolution { + + private final List effectiveExposePaths; + private final List omittedEncryptedExposePaths; + + private ExposeResolution(List effectiveExposePaths, List omittedEncryptedExposePaths) { + + this.effectiveExposePaths = effectiveExposePaths; + this.omittedEncryptedExposePaths = omittedEncryptedExposePaths; + } + + private List getEffectiveExposePaths() { + + return effectiveExposePaths; + } + + private List getOmittedEncryptedExposePaths() { + + return omittedEncryptedExposePaths; + } + } + + private static class AllowedModifyExtraction { + + private final List cleanPaths; + private final Map pathTypeAnnotations; + private final List skippedPaths; + + private AllowedModifyExtraction(List cleanPaths, Map pathTypeAnnotations, + List skippedPaths) { + + this.cleanPaths = cleanPaths; + this.pathTypeAnnotations = pathTypeAnnotations; + this.skippedPaths = skippedPaths; + } + + private List getCleanPaths() { + + return cleanPaths; + } + + private Map getPathTypeAnnotations() { + + return pathTypeAnnotations; + } + + private List getSkippedPaths() { + + return skippedPaths; + } + } + + /** + * Check if any exposed leaf path falls under the given area prefix. + * Used as a gate before iterating a data block (claims, credentials, properties). + */ + private boolean isAreaExposed(String areaPrefix, List expose) { + + return HierarchicalPrefixMatcher.anyExposedUnder(areaPrefix, expose); + } + + /** + * Check if a specific leaf path is present in the expose list. + * Used for exact leaf-level inclusion decisions. + */ + private boolean isLeafExposed(String leafPath, List expose) { + + return HierarchicalPrefixMatcher.isExposedPath(leafPath, expose); + } + + /** + * Determine if a value at the given path should be JWE-encrypted before sending to the + * external service. Only expose paths with {@code encrypted: true} trigger outbound encryption. + * + * @param path The expose path. + * @param accessConfig The access config with encryption flags. + * @param certificatePEM The certificate PEM (null if no encryption configured). + * @return {@code true} if the value should be encrypted. + */ + private boolean shouldEncrypt(String path, AccessConfig accessConfig, String certificatePEM) { + + if (certificatePEM == null || accessConfig == null) { + return false; + } + return accessConfig.isExposePathEncrypted(path); + } + + /** + * JWE-encrypt a plaintext value using the external service's certificate. + * + * @param plaintext The value to encrypt. + * @param certificatePEM The external service's certificate PEM. + * @return The JWE compact serialization string. + * @throws ActionExecutionRequestBuilderException If encryption fails. + */ + private String encryptValue(String plaintext, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + try { + return JWEEncryptionUtil.encrypt(plaintext, certificatePEM); + } catch (Exception e) { + throw new ActionExecutionRequestBuilderException( + "Failed to JWE-encrypt outbound value for In-Flow Extension action.", e); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java new file mode 100644 index 000000000000..1df1c1a4f765 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java @@ -0,0 +1,711 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.ErrorStatus; +import org.wso2.carbon.identity.action.execution.api.model.FailedStatus; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Incomplete; +import org.wso2.carbon.identity.action.execution.api.model.IncompleteStatus; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.action.execution.api.model.SuccessStatus; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.inflow.extensions.model.AccessConfig; +import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.inflow.extensions.model.OperationExecutionResult; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.utils.DiagnosticLog; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class is responsible for processing the response from In-Flow Extension actions. + * + *

Responsibility: operation processing and collecting context updates into pending maps + * that are stored in {@link FlowContext} for the executor to forward to {@code TaskExecutionNode} + * via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse} fields. + * It processes {@code REPLACE} operations on flow properties, user claims, and user credentials.

+ * + *

Only {@code REPLACE} operations are supported. The {@code allowedOperations} list + * (derived from modify paths and sent to the external service in the request, enforced + * upstream by {@code ActionExecutorServiceImpl}) is the sole mechanism for gating which + * operations are permitted. This processor performs additional validations:

+ *
    + *
  • Read-only areas: No modifications allowed to {@code /flow/} paths.
  • + *
+ */ +public class InFlowExtensionResponseProcessor implements ActionExecutionResponseProcessor { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionResponseProcessor.class); + + /** Shared error message for null-value REPLACE operations. */ + private static final String ERROR_VALUE_REQUIRED_FOR_REPLACE = "Value is required for REPLACE operation."; + + /** Diagnostic log parameter key for the action type. */ + private static final String DIAG_PARAM_ACTION_TYPE = "actionType"; + + @Override + public ActionType getSupportedActionType() { + + return ActionType.IN_FLOW_EXTENSION; + } + + @Override + @SuppressWarnings("unchecked") + public ActionExecutionStatus processSuccessResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = flowContext.getValue( + InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); + String tenantDomain = execCtx != null ? execCtx.getTenantDomain() : null; + + // Read path type annotations set by the request builder. + // Maps clean paths to annotation content. + Map pathTypeAnnotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + if (pathTypeAnnotations == null) { + pathTypeAnnotations = Collections.emptyMap(); + } + + // Reconstruct AccessConfig from the resolved modify paths stored by the request builder. + // This reuses AccessConfig.isModifyPathEncrypted() for canonical prefix-based encryption checking. + List modifyPaths = flowContext.getValue( + InFlowExtensionConstants.MODIFY_PATHS_KEY, List.class); + AccessConfig accessConfig = modifyPaths != null ? new AccessConfig(null, modifyPaths) : null; + + // Accumulate pending updates — applied by TaskExecutionNode via ExecutorResponse fields. + Map pendingClaims = new HashMap<>(); + Map pendingCredentials = new HashMap<>(); + Map pendingProperties = new HashMap<>(); + + List results = new ArrayList<>(); + + List operations = + responseContext.getActionInvocationResponse().getOperations(); + + if (operations != null && !operations.isEmpty()) { + for (PerformableOperation operation : operations) { + operation = decryptOperationValueIfNeeded(operation, accessConfig, tenantDomain); + results.add(processOperation( + operation, pathTypeAnnotations, pendingClaims, pendingCredentials, + pendingProperties, tenantDomain)); + } + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension SUCCESS response contained no operations. No context updates applied."); + } + } + + // Store non-empty pending maps in FlowContext for the executor to forward to TaskExecutionNode. + if (!pendingClaims.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_CLAIMS_KEY, pendingClaims); + } + if (!pendingCredentials.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, pendingCredentials); + } + if (!pendingProperties.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, pendingProperties); + } + + logOperationExecutionResults(results); + + return new SuccessStatus.Builder() + .setSuccess(new InFlowExtensionSuccess()) + .setResponseContext(Collections.emptyMap()) + .build(); + } + + + /** + * Process a single operation by validating and collecting it into the appropriate pending map. + * Updates are not applied directly — they are stored in the pending maps and forwarded to + * {@code TaskExecutionNode} via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse}. + * + * @param operation The operation to process. + * @param pathTypeAnnotations Map of clean paths to their type annotations from allowed operations. + * @param pendingClaims Accumulator map for user claim updates. + * @param pendingCredentials Accumulator map for user credential updates. + * @param pendingProperties Accumulator map for flow property updates. + * @param tenantDomain Tenant domain, used for claim URI validation. + * @return The result of the operation execution. + */ + private OperationExecutionResult processOperation(PerformableOperation operation, + Map pathTypeAnnotations, + Map pendingClaims, + Map pendingCredentials, + Map pendingProperties, + String tenantDomain) { + + // Reject null/empty path early to prevent NPE in subsequent startsWith checks. + String path = operation.getPath(); + if (path == null || path.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Operation path is null or empty."); + } + + // Only REPLACE is supported; reject any other operation type. + if (operation.getOp() != Operation.REPLACE) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Unsupported operation type: " + operation.getOp() + ". Only REPLACE is supported."); + } + + // Check if operation is on a read-only area. + if (HierarchicalPrefixMatcher.isReadOnly(path)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Path is in a read-only area. Modifications not allowed: " + path); + } + + // Route to appropriate handler based on path prefix. + if (path.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { + return handlePropertyOperation(operation, pathTypeAnnotations, pendingProperties); + } else if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + return handleUserClaimOperation(operation, pendingClaims, tenantDomain); + } else if (path.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { + return handleUserCredentialOperation(operation, pendingCredentials); + } + + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Unknown path prefix. Supported: " + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX + + ", " + InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX + + ", " + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); + } + + /** + * Handle operation on flow properties — collect into pending properties map. + * + *

Only flat property paths are supported (e.g., {@code /properties/riskScore}). + * Value coercion is applied based on path type annotations from the request builder via + * {@link PathTypeAnnotationUtil#coerceValue}.

+ * + * @param operation The performable operation. + * @param pathTypeAnnotations Path type annotations map from request builder. + * @param pendingProperties Accumulator map for property updates. + */ + private OperationExecutionResult handlePropertyOperation(PerformableOperation operation, + Map pathTypeAnnotations, Map pendingProperties) { + + String propertyName = extractNameFromPath(operation.getPath(), + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX); + + if (propertyName == null || propertyName.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid property path. Property name is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + // Validate complex object structure against annotation schema before coercion. + if (!PathTypeAnnotationUtil.validateValueAgainstAnnotation( + operation.getPath(), operation.getValue(), pathTypeAnnotations)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Value does not match annotation schema for path: " + operation.getPath()); + } + + // Coerce value based on path type annotation. + Object coercedValue = PathTypeAnnotationUtil.coerceValue( + operation.getPath(), operation.getValue(), pathTypeAnnotations); + + // Enforce array item limits after coercion. + if (!PathTypeAnnotationUtil.enforceArrayItemLimit( + operation.getPath(), coercedValue, pathTypeAnnotations)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Array value exceeds maximum item limit for path: " + operation.getPath()); + } + + pendingProperties.put(propertyName, coercedValue); + + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "Property replace applied."); + } + + /** + * Handle REPLACE operation on user claims — validate the claim URI then collect into pending + * claims map. Validation gates: + *
    + *
  1. Claim URI must be in the WSO2 local claim dialect ({@code http://wso2.org/claims/}).
  2. + *
  3. Identity-system claims ({@code http://wso2.org/claims/identity/}) are rejected.
  4. + *
  5. If {@link org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService} + * is available, the claim URI must correspond to a registered local claim; unknown + * claims are rejected. When the service is unavailable the check is skipped + * (fail-open).
  6. + *
+ * The value is always stringified via {@code String.valueOf()}. + * + * @param operation The performable operation. + * @param pendingClaims Accumulator map for user claim updates. + * @param tenantDomain Tenant domain for claim existence lookup. + */ + private OperationExecutionResult handleUserClaimOperation(PerformableOperation operation, + Map pendingClaims, String tenantDomain) { + + String claimUri = extractNameFromPath(operation.getPath(), + InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX); + + if (claimUri == null || claimUri.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid claim path. Claim URI is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + // Validate claim URI — must be local dialect, not an identity sub-claim. + if (!claimUri.startsWith(PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Claim URI must be in the local dialect (" + + PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX + "): " + claimUri); + } + if (claimUri.startsWith(PathTypeAnnotationUtil.IDENTITY_CLAIM_URI_PREFIX)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Identity-system claims cannot be modified by In-Flow Extensions: " + claimUri); + } + + // Verify claim existence via ClaimMetadataManagementService when available. + String claimValidationFailure = validateLocalClaimExists(claimUri, tenantDomain); + if (claimValidationFailure != null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + claimValidationFailure); + } + + pendingClaims.put(claimUri, String.valueOf(operation.getValue())); + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "User claim replace applied."); + } + + /** + * Validate that the given claim URI corresponds to a registered local claim in the tenant. + * Returns {@code null} if the claim is valid or if the service is unavailable (fail-open). + * Returns a descriptive failure message if validation fails. + * + * @param claimUri Local claim URI to check. + * @param tenantDomain Tenant domain for the lookup. + * @return Failure reason string, or {@code null} when validation passes or is skipped. + */ + private String validateLocalClaimExists(String claimUri, String tenantDomain) { + + org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService claimService = + InFlowExtensionDataHolder.getInstance().getClaimMetadataManagementService(); + if (claimService == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("ClaimMetadataManagementService is unavailable. Skipping claim existence check for: " + + claimUri); + } + return null; + } + try { + java.util.Optional localClaim = claimService.getLocalClaim(claimUri, tenantDomain); + if (!localClaim.isPresent()) { + return "Unknown local claim URI. Claim is not registered in the system: " + claimUri; + } + return null; + } catch (ClaimMetadataException e) { + LOG.warn("Failed to look up claim URI '" + claimUri + "' in tenant '" + tenantDomain + + "'. Skipping claim existence check.", e); + return null; + } + } + + /** + * Handle REPLACE operation on user credentials — collect into pending credentials map. + * No key validation is applied; any credential key is accepted. The value is converted + * to {@code char[]} immediately to avoid holding the secret as a plain {@code String} + * any longer than necessary. + * + * @param operation The performable operation. + * @param pendingCredentials Accumulator map for user credential updates. + */ + private OperationExecutionResult handleUserCredentialOperation(PerformableOperation operation, + Map pendingCredentials) { + + String credentialKey = extractNameFromPath(operation.getPath(), + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); + + if (credentialKey == null || credentialKey.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid credential path. Credential key is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + pendingCredentials.put(credentialKey, String.valueOf(operation.getValue()).toCharArray()); + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "User credential replace applied."); + } + + /** + * Extract the name/key from the operation path after the prefix. + */ + private String extractNameFromPath(String path, String prefix) { + + if (path == null || !path.startsWith(prefix)) { + return null; + } + + String remaining = path.substring(prefix.length()); + + if (remaining.endsWith("/")) { + remaining = remaining.substring(0, remaining.length() - 1); + } + + return remaining; + } + + @Override + public ActionExecutionStatus processIncompleteResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + // Contract: INCOMPLETE must carry a REDIRECT op. Every other op is intentionally + // discarded — the extension is expected to resend them on the resume call after redirect. + List operations = + responseContext.getActionInvocationResponse().getOperations(); + IncompleteOpScan scan = scanIncompleteOperations(operations); + + validateRedirectPresent(scan.redirectUrl); + logIgnoredIncompleteOps(scan.ignoredOpCount); + + flowContext.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, scan.redirectUrl); + debugLogRedirectHost(scan.redirectUrl); + logIncompleteSuccess(); + + return new IncompleteStatus.Builder() + .responseContext(Collections.emptyMap()) + .build(); + } + + /** + * Scan the operation list from an INCOMPLETE response to extract the redirect URL and count + * non-REDIRECT operations that will be ignored. + * + * @param operations List of operations from the INCOMPLETE response (may be null). + * @return Scan result holding the redirect URL and ignored-op count. + */ + private IncompleteOpScan scanIncompleteOperations(List operations) { + + if (operations == null) { + return new IncompleteOpScan(null, 0); + } + String redirectUrl = null; + int ignoredOpCount = 0; + for (PerformableOperation op : operations) { + if (op.getOp() == Operation.REDIRECT) { + redirectUrl = op.getUrl(); + } else { + ignoredOpCount++; + } + } + return new IncompleteOpScan(redirectUrl, ignoredOpCount); + } + + /** + * Assert that a redirect URL was found in the INCOMPLETE response; throw and emit diagnostics + * if it is absent. + */ + private void validateRedirectPresent(String redirectUrl) + throws ActionExecutionResponseProcessorException { + + if (redirectUrl != null && !redirectUrl.isEmpty()) { + return; + } + LOG.warn("In-Flow Extension INCOMPLETE response is missing a REDIRECT operation."); + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage( + "INCOMPLETE response from In-Flow Extension is missing a REDIRECT operation.") + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + throw new ActionExecutionResponseProcessorException( + "INCOMPLETE response from In-Flow Extension must contain a REDIRECT operation."); + } + + /** Log the number of non-REDIRECT ops discarded in an INCOMPLETE response, if any. */ + private void logIgnoredIncompleteOps(int ignoredOpCount) { + + if (ignoredOpCount > 0 && LOG.isDebugEnabled()) { + LOG.debug("Ignored " + ignoredOpCount + " non-REDIRECT operation(s) on INCOMPLETE response. " + + "REPLACE ops are by-contract dropped — the extension is expected to resend " + + "them on the resume call after callback."); + } + } + + /** Log the redirect URL host at DEBUG level after parsing the URI. */ + private void debugLogRedirectHost(String redirectUrl) { + + if (!LOG.isDebugEnabled()) { + return; + } + try { + String host = new java.net.URI(redirectUrl).getHost(); + LOG.debug("In-Flow Extension INCOMPLETE: redirect URL host resolved: " + host); + } catch (java.net.URISyntaxException ignored) { + LOG.debug("In-Flow Extension INCOMPLETE: redirect URL stored in flow context."); + } + } + + /** Emit a diagnostic success event after a valid INCOMPLETE response is processed. */ + private void logIncompleteSuccess() { + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage( + "In-Flow Extension INCOMPLETE response processed. Redirect URL stored in flow context.") + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + } + + /** Lightweight value holder for the result of scanning INCOMPLETE response operations. */ + private static final class IncompleteOpScan { + + private final String redirectUrl; + private final int ignoredOpCount; + + private IncompleteOpScan(String redirectUrl, int ignoredOpCount) { + + this.redirectUrl = redirectUrl; + this.ignoredOpCount = ignoredOpCount; + } + } + + @Override + public ActionExecutionStatus processErrorResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + String errorMessage = responseContext.getActionInvocationResponse().getErrorMessage(); + String errorDescription = responseContext.getActionInvocationResponse().getErrorDescription(); + + if (LOG.isDebugEnabled()) { + LOG.debug("Processing error response from In-Flow Extension. Error: " + errorMessage + + ", Description: " + errorDescription); + } + + return new ErrorStatus(new Error(errorMessage, errorDescription)); + } + + @Override + public ActionExecutionStatus processFailureResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + String failureReason = responseContext.getActionInvocationResponse().getFailureReason(); + String failureDescription = responseContext.getActionInvocationResponse().getFailureDescription(); + + if (LOG.isDebugEnabled()) { + LOG.debug("Processing failure response from In-Flow Extension. Reason: " + failureReason + + ", Description: " + failureDescription); + } + + return new FailedStatus(new Failure(failureReason, failureDescription)); + } + + /** + * Log operation execution results for diagnostics and debugging. + */ + private void logOperationExecutionResults(List results) { + + if (results.isEmpty()) { + return; + } + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + List> operationDetailsList = new ArrayList<>(); + results.forEach(result -> { + Map details = new HashMap<>(); + details.put("operation", result.getOperation().getOp() + " path: " + + result.getOperation().getPath()); + details.put("status", result.getStatus().toString()); + details.put("message", result.getMessage()); + operationDetailsList.add(details); + }); + + DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_RESPONSE); + diagnosticLogBuilder + .inputParam("executedOperations", operationDetailsList) + .resultMessage("Processed operations for " + getSupportedActionType().getDisplayName() + + " action.") + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS) + .build(); + LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); + } + + if (LOG.isDebugEnabled()) { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + try { + String summary = mapper.writeValueAsString(results); + LOG.debug(String.format("Processed response for action type: %s. Results: %s", + getSupportedActionType(), summary)); + } catch (JsonProcessingException e) { + LOG.debug("Error occurred while logging operation execution results.", e); + } + } + } + + /** + * Inner class representing a successful In-Flow Extension execution result. + */ + public static class InFlowExtensionSuccess implements Success { + + // Marker class for successful execution. + } + + // ========================= Inbound decryption ========================= + + /** + * Decrypt the operation value if the operation path has encryption enabled in the AccessConfig. + * Uses {@link AccessConfig#isModifyPathEncrypted(String)} for canonical checking. + * For operations with encrypted paths, checks if the value looks like a JWE compact + * serialization and decrypts it using the IS's private key. + * + * @param operation The operation to potentially decrypt. + * @param accessConfig The access config with encryption flags (may be null). + * @param tenantDomain Tenant domain for IS private key resolution. + * @return The operation with decrypted value, or the original operation if no decryption needed. + */ + private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation operation, + AccessConfig accessConfig, String tenantDomain) + throws ActionExecutionResponseProcessorException { + + if (accessConfig == null || operation.getValue() == null) { + return operation; + } + + // Check if this operation path has encryption enabled via modify paths in AccessConfig. + if (!accessConfig.isModifyPathEncrypted(operation.getPath())) { + return operation; + } + + // For encrypted modify paths the value MUST be a JWE compact serialization. + // Accepting plaintext would make the encryption flag advisory; it is enforced here. + Object value = operation.getValue(); + + if (!(value instanceof String)) { + emitEncryptionContractViolation(operation.getPath(), + "Value for encrypted modify path is not a String."); + throw new ActionExecutionResponseProcessorException( + "Value for encrypted modify path '" + operation.getPath() + + "' must be a JWE-encrypted string, but received a non-String value."); + } + + String stringValue = (String) value; + if (!JWEEncryptionUtil.isJWEEncrypted(stringValue)) { + emitEncryptionContractViolation(operation.getPath(), + "Value for encrypted modify path is not JWE-encrypted."); + throw new ActionExecutionResponseProcessorException( + "Value for encrypted modify path '" + operation.getPath() + + "' must be JWE-encrypted, but received a plaintext value."); + } + + try { + String decrypted = JWEEncryptionUtil.decrypt(stringValue, tenantDomain); + PerformableOperation decryptedOp = new PerformableOperation(); + decryptedOp.setOp(operation.getOp()); + decryptedOp.setPath(operation.getPath()); + decryptedOp.setValue(decrypted); + if (LOG.isDebugEnabled()) { + LOG.debug("Successfully decrypted inbound JWE value for path: " + operation.getPath()); + } + return decryptedOp; + } catch (Exception e) { + LOG.error("Failed to decrypt inbound JWE value for path '" + operation.getPath() + + "', tenant: " + tenantDomain, e); + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage("Failed to decrypt inbound JWE value for modify path.") + .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .inputParam("path", operation.getPath()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + throw new ActionExecutionResponseProcessorException( + "Failed to decrypt inbound JWE value for path: " + operation.getPath(), e); + } + } + + // ========================= Decryption diagnostics helpers ========================= + + /** + * Emit a diagnostic failure event for a broken encryption contract on a modify path. + * Extracted to keep {@link #decryptOperationValueIfNeeded} readable. + */ + private void emitEncryptionContractViolation(String path, String reason) { + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage(reason) + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .inputParam("path", path) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java new file mode 100644 index 000000000000..d456fbfd6861 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.RSADecrypter; +import com.nimbusds.jose.crypto.RSAEncrypter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.base.MultitenantConstants; +import org.wso2.carbon.core.util.KeyStoreManager; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Utility class for JWE encryption and decryption operations used by In-Flow Extension actions. + * + *

Outbound encryption (IS → external service): uses the external service's X.509 + * certificate public key with {@code RSA-OAEP-256} + {@code A256GCM}. This follows the same + * pattern as {@code PasswordUpdatingUser.encryptCredential()}.

+ * + *

Inbound decryption (external service → IS): uses the IS server's RSA private key + * retrieved via {@link KeyStoreManager} for the tenant. This follows the same + * pattern as {@code UserAssertionUtils.getPrivateKey()}.

+ */ +public final class JWEEncryptionUtil { + + private static final Log LOG = LogFactory.getLog(JWEEncryptionUtil.class); + + /** Cache of resolved private keys with TTL, keyed by tenant ID. */ + private static final Map PRIVATE_KEYS = new ConcurrentHashMap<>(); + private static final long PRIVATE_KEY_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(30); + + private JWEEncryptionUtil() { + + } + + /** + * Encrypt a plaintext string using JWE with the external service's X.509 certificate. + * Uses RSA-OAEP-256 key encryption and A256GCM content encryption. + * + * @param plaintext The plaintext JSON string to encrypt. + * @param certificatePEM The external service's X.509 certificate in PEM format. + * @return JWE compact serialization string (5-part dot-separated). + * @throws ActionExecutionException If encryption fails. + */ + public static String encrypt(String plaintext, String certificatePEM) throws ActionExecutionException { + + try { + X509Certificate certificate = parsePEMCertificate(certificatePEM); + RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); + + JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) + .contentType("application/json") + .build(); + + JWEObject jweObject = new JWEObject(header, new Payload(plaintext)); + jweObject.encrypt(new RSAEncrypter(publicKey)); + + return jweObject.serialize(); + } catch (JOSEException e) { + throw new ActionExecutionException( + "Failed to JWE-encrypt data for In-Flow Extension action.", e); + } + } + + /** + * Decrypt a JWE compact string using the IS server's RSA private key. + * The private key is resolved via {@link KeyStoreManager} for the tenant. + * + * @param jweString The JWE compact serialization string. + * @param tenantDomain The tenant domain to resolve the private key. + * @return Decrypted plaintext string. + * @throws ActionExecutionException If decryption fails. + */ + public static String decrypt(String jweString, String tenantDomain) throws ActionExecutionException { + + try { + JWEObject jweObject = JWEObject.parse(jweString); + + Key privateKey = getPrivateKey(tenantDomain); + + RSADecrypter decrypter = new RSADecrypter((RSAPrivateKey) privateKey); + jweObject.decrypt(decrypter); + + return jweObject.getPayload().toString(); + } catch (ParseException e) { + throw new ActionExecutionException( + "Failed to parse JWE string from In-Flow Extension response.", e); + } catch (JOSEException e) { + throw new ActionExecutionException( + "Failed to decrypt JWE value from In-Flow Extension response.", e); + } catch (ActionExecutionException e) { + throw e; + } catch (Exception e) { + throw new ActionExecutionException( + "Error resolving IS private key for In-Flow Extension JWE decryption.", e); + } + } + + /** + * Retrieve the IS server's RSA private key for the given tenant. + * Uses {@link KeyStoreManager} directly, bypassing IdentityKeyStoreResolver, + * to avoid requiring a protocol-specific keystore mapping. + * + * @param tenantDomain The tenant domain. + * @return The RSA private key. + * @throws ActionExecutionException If retrieval fails. + */ + private static Key getPrivateKey(String tenantDomain) throws ActionExecutionException { + + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + String cacheKey = String.valueOf(tenantId); + CachedKey cached = PRIVATE_KEYS.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.getKey(); + } + + try { + IdentityTenantUtil.initializeRegistry(tenantId); + KeyStoreManager keyStoreManager = KeyStoreManager.getInstance(tenantId); + Key privateKey; + + if (MultitenantConstants.SUPER_TENANT_DOMAIN_NAME.equals(tenantDomain)) { + privateKey = keyStoreManager.getDefaultPrivateKey(); + } else { + String tenantKeyStoreName = tenantDomain.trim().replace(".", "-") + ".jks"; + privateKey = keyStoreManager.getPrivateKey(tenantKeyStoreName, tenantDomain); + } + + PRIVATE_KEYS.put(cacheKey, new CachedKey(privateKey)); + return privateKey; + } catch (Exception e) { + throw new ActionExecutionException( + "Error retrieving private key for tenant: " + tenantDomain, e); + } + } + + /** + * Detect whether a string value is a JWE compact serialization. + * A JWE compact serialization has exactly 5 dot-separated Base64url-encoded parts. + * + * @param value The value to check. + * @return {@code true} if the value appears to be a JWE compact string. + */ + public static boolean isJWEEncrypted(String value) { + + if (value == null || value.isEmpty()) { + return false; + } + // Count dots — JWE compact serialization has exactly 4 dots (5 parts). + int dotCount = 0; + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) == '.') { + dotCount++; + if (dotCount > 4) { + return false; + } + } + } + return dotCount == 4; + } + + /** + * Parse a Base64-encoded PEM X.509 certificate string into an {@link X509Certificate} object. + * Expects the input to be a fully Base64-encoded string of a standard PEM file. + * + * @param base64EncodedPem The Base64 encoded PEM string. + * @return The parsed X509Certificate. + * @throws ActionExecutionException If parsing or decoding fails. + */ + public static X509Certificate parsePEMCertificate(String base64EncodedPem) throws ActionExecutionException { + + if (base64EncodedPem == null || base64EncodedPem.trim().isEmpty()) { + throw new ActionExecutionException("Certificate string is null or empty."); + } + + try { + byte[] decodedPemBytes = java.util.Base64.getDecoder().decode(base64EncodedPem.trim()); + + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decodedPemBytes)); + certificate.checkValidity(); + return certificate; + + } catch (CertificateExpiredException e) { + throw new ActionExecutionException( + "Certificate has expired for In-Flow Extension action.", e); + } catch (CertificateNotYetValidException e) { + throw new ActionExecutionException( + "Certificate is not yet valid for In-Flow Extension action.", e); + } catch (IllegalArgumentException e) { + throw new ActionExecutionException( + "Failed to decode certificate: Input is not valid Base64.", e); + } catch (Exception e) { + throw new ActionExecutionException( + "Failed to parse the decoded PEM certificate for In-Flow Extension action.", e); + } + } + + /** + * Cache wrapper for private keys with TTL support. + */ + private static class CachedKey { + + private final Key key; + private final long createdAt; + + CachedKey(Key key) { + + this.key = key; + this.createdAt = System.currentTimeMillis(); + } + + Key getKey() { + + return key; + } + + boolean isExpired() { + + return (System.currentTimeMillis() - createdAt) > PRIVATE_KEY_CACHE_TTL_MS; + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java new file mode 100644 index 000000000000..5d0de0248d4e --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class for path type annotation parsing, stripping, and value coercion. + * + *

Path type annotations use a trailing brace expression at the end of a modify path + * to declare the expected data type for that path. The unified format uses curly braces:

+ *
    + *
  • {@code /properties/risk-factor{String}} — primary data type.
  • + *
  • {@code /properties/risk-factors{[String]}} — multivalued primary (array of type).
  • + *
  • {@code /properties/risk{risk: Float, factor: String}} — complex object with schema.
  • + *
  • {@code /properties/risk{[risk: Float, factor: String]}} — multivalued complex object array.
  • + *
+ * + *

This class provides methods to strip annotations from paths and coerce incoming values + * based on the stored annotations.

+ */ +public final class PathTypeAnnotationUtil { + + /** + * Regex pattern to match a trailing curly brace annotation at the end of a path. + * Captures the content inside the braces (Group 1). + * Examples: {@code {String}}, {@code {[String]}}, {@code {risk: Float, factor: String}}. + */ + static final Pattern ANNOTATION_PATTERN = Pattern.compile("\\{([^}]*)}$"); + + /** Claim URI prefix for the WSO2 local claim dialect. */ + static final String LOCAL_CLAIM_DIALECT_PREFIX = "http://wso2.org/claims/"; + + /** Claim URI prefix for WSO2 identity claims (subset of local claims, not user-modifiable). */ + static final String IDENTITY_CLAIM_URI_PREFIX = "http://wso2.org/claims/identity/"; + + /** Reusable ObjectMapper for parsing JSON-string values received for complex-typed paths. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private PathTypeAnnotationUtil() { + + } + + /** + * Strip a trailing path type annotation from a raw path. + * + * @param rawPath The raw path potentially containing a trailing {@code {annotation}}. + * @return A two-element array: {@code [cleanPath, annotation]}. + * If no annotation is found, annotation element is {@code null}. + */ + public static String[] stripAnnotation(String rawPath) { + + if (rawPath == null) { + return new String[]{null, null}; + } + + Matcher matcher = ANNOTATION_PATTERN.matcher(rawPath); + if (matcher.find()) { + String cleanPath = rawPath.substring(0, matcher.start()); + String annotation = matcher.group(1); + return new String[]{cleanPath, annotation}; + } + return new String[]{rawPath, null}; + } + + /** + * Coerce a value based on path type annotations. + * + *

Annotation interpretation:

+ *
    + *
  • {@code null} (no annotation): value is coerced to String via {@code String.valueOf()}.
  • + *
  • Starts with {@code [} and contains {@code :} (e.g., {@code [risk: Float]}): + * complex object array — value is passed through as-is.
  • + *
  • Starts with {@code [} without {@code :} (e.g., {@code [String]}): + * multivalued primary type — value is expected to be a List; each element coerced to String. + * A single value is wrapped into a list.
  • + *
  • Contains {@code :} (e.g., {@code risk: Float, factor: String}): + * complex object — value is passed through as-is.
  • + *
  • Any other annotation (e.g., {@code String}, {@code Integer}): + * primary type — value is coerced to String via {@code String.valueOf()}.
  • + *
+ * + * @param path The operation path (used as lookup key in annotations map). + * @param value The raw value from the operation. + * @param pathTypeAnnotations Map from clean path to annotation content (may be empty). + * @return The coerced value. + */ + @SuppressWarnings("unchecked") + public static Object coerceValue(String path, Object value, + Map pathTypeAnnotations) { + + if (value == null) { + return null; + } + + String annotation = pathTypeAnnotations.get(path); + + if (annotation == null) { + // No annotation: coerce to String. + return String.valueOf(value); + } + + // Check for multivalued annotation: starts with [ + if (annotation.startsWith("[")) { + String inner = annotation.substring(1, annotation.length() - 1); + if (inner.contains(":")) { + // Complex object array (e.g., [risk: Float, factor: String]): + // parse JSON string if needed, then pass through. + return tryParseJsonString(value); + } + // Multivalued primary type (e.g., [String], [Integer]): coerce to List. + // Parse JSON string first in case the value arrived as "[\"a\",\"b\"]". + Object resolvedList = tryParseJsonString(value); + if (resolvedList instanceof List) { + List rawList = (List) resolvedList; + List stringList = new ArrayList<>(); + for (Object item : rawList) { + stringList.add(item == null ? null : String.valueOf(item)); + } + return stringList; + } + // Single value — wrap in a list. + List singleList = new ArrayList<>(); + singleList.add(String.valueOf(value)); + return singleList; + } + + // Check for complex object annotation: contains ":" + if (annotation.contains(":")) { + // Complex object (e.g., risk: Float, factor: String): + // parse JSON string if needed, then pass through. + return tryParseJsonString(value); + } + + // Primary type annotation (e.g., String, Integer, Boolean): coerce to String. + return String.valueOf(value); + } + + /** Maximum number of attributes allowed in a complex object annotation. */ + static final int MAX_ATTRIBUTES_PER_OBJECT = 10; + + /** Maximum number of items allowed in an array (primary or complex object array). */ + static final int MAX_ARRAY_ITEMS = 10; + + /** + * Validate that a complex object annotation does not exceed the maximum attribute count. + * Should be called on the raw annotation content (inside braces) before stripping. + * + * @param annotation The annotation content (e.g., {@code "risk: Float, factor: String"} + * or {@code "[risk: Float, factor: String]"}). May be {@code null}. + * @return {@code true} if the annotation is valid (within limits or not a complex annotation). + */ + public static boolean validateAnnotationLimits(String annotation) { + + if (annotation == null || annotation.isEmpty()) { + return true; + } + + String inner = annotation; + // Unwrap array brackets if present. + if (inner.startsWith("[") && inner.endsWith("]")) { + inner = inner.substring(1, inner.length() - 1); + } + + // Only validate complex annotations (those with attribute definitions containing ':'). + if (!inner.contains(":")) { + return true; + } + + return parseAnnotationAttributes(inner).size() <= MAX_ATTRIBUTES_PER_OBJECT; + } + + /** + * Validate a complex object value against its path type annotation schema. + * + *

Validates:

+ *
    + *
  • Value is a Map with attribute names matching the annotation schema.
  • + *
  • Only one nesting level: attributes must be primary types or primary arrays.
  • + *
  • Attribute count does not exceed {@link #MAX_ATTRIBUTES_PER_OBJECT}.
  • + *
  • Array attributes do not exceed {@link #MAX_ARRAY_ITEMS} items.
  • + *
+ * + *

For non-complex annotations (primary types, primary arrays), this method returns + * {@code true} without further validation since those are handled by coercion.

+ * + * @param path The operation path. + * @param value The value to validate. + * @param pathTypeAnnotations Map from clean path to annotation content. + * @return {@code true} if the value is valid against the annotation, {@code false} otherwise. + */ + @SuppressWarnings("unchecked") + public static boolean validateValueAgainstAnnotation(String path, Object value, + Map pathTypeAnnotations) { + + String annotation = pathTypeAnnotations.get(path); + if (annotation == null) { + return true; + } + + boolean isArray = annotation.startsWith("["); + String inner = isArray ? annotation.substring(1, annotation.length() - 1) : annotation; + + // Only validate complex annotations (those with attribute definitions). + if (!inner.contains(":")) { + return true; + } + + Map schema = parseAnnotationAttributes(inner); + + // Parse JSON string if the value arrived as serialized JSON + // (e.g., after JWE decryption or when the external service stringifies before encrypting). + Object resolvedValue = tryParseJsonString(value); + + if (isArray) { + // Complex object array: validate each item. + if (!(resolvedValue instanceof List)) { + return false; + } + List items = (List) resolvedValue; + if (items.size() > MAX_ARRAY_ITEMS) { + return false; + } + for (Object item : items) { + if (!validateSingleComplexObject(item, schema)) { + return false; + } + } + return true; + } + + // Single complex object. + return validateSingleComplexObject(resolvedValue, schema); + } + + /** + * Enforce array item limits on a value. Applies to both primary arrays and complex object arrays. + * + * @param path The operation path. + * @param value The value to check. + * @param pathTypeAnnotations Map from clean path to annotation content. + * @return {@code true} if the value is within array item limits, {@code false} otherwise. + */ + @SuppressWarnings("unchecked") + public static boolean enforceArrayItemLimit(String path, Object value, + Map pathTypeAnnotations) { + + String annotation = pathTypeAnnotations.get(path); + if (annotation == null || !annotation.startsWith("[")) { + return true; + } + + if (!(value instanceof List)) { + return true; + } + + return ((List) value).size() <= MAX_ARRAY_ITEMS; + } + + /** + * Attempt to parse a value as JSON if it is a String that starts with {@code [} or {@code {}. + * This handles values that arrive as serialized JSON strings — for example, after JWE + * decryption, or when an external service serialises a complex object/array before encrypting it. + * + *

If the value is not a String, does not start with {@code [} or {@code {}, or cannot be + * parsed as valid JSON, the original value is returned unchanged.

+ * + * @param value The value to inspect. + * @return The parsed JSON structure (List or Map), or the original value if not applicable. + */ + private static Object tryParseJsonString(Object value) { + + if (!(value instanceof String)) { + return value; + } + String str = ((String) value).trim(); + if (!str.startsWith("[") && !str.startsWith("{")) { + return value; + } + try { + return OBJECT_MAPPER.readValue(str, Object.class); + } catch (IOException ignored) { + return value; + } + } + + /** + * Parse complex annotation attributes into a map of attribute name to type. + * Handles array types indicated by trailing {@code []}. + * + * @param inner The inner annotation content (e.g., {@code "risk: Float, factor: String"}). + * @return Map from attribute name to type string (e.g., {@code "Float"} or {@code "String[]"}). + */ + private static Map parseAnnotationAttributes(String inner) { + + Map attributes = new HashMap<>(); + String[] parts = inner.split(","); + for (String part : parts) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + int colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + String name = trimmed.substring(0, colonIndex).trim(); + String type = trimmed.substring(colonIndex + 1).trim(); + attributes.put(name, type); + } + } + return attributes; + } + + /** + * Validate a single complex object value against a schema. + * Ensures value is a Map, keys match schema names, attribute count within limits, + * and nested values are only primary types or primary arrays (single nesting level). + * + * @param value The value to validate (expected to be a Map). + * @param schema The annotation schema (attribute name to type). + * @return {@code true} if valid. + */ + @SuppressWarnings("unchecked") + private static boolean validateSingleComplexObject(Object value, Map schema) { + + if (!isMapAndWithinAttributeLimit(value)) { + return false; + } + + Map map = (Map) value; + + if (!containsOnlySchemaKeys(map, schema)) { + return false; + } + + for (Map.Entry entry : map.entrySet()) { + if (!validateEntryValueAgainstType(entry, schema)) { + return false; + } + } + + return true; + } + + private static boolean isMapAndWithinAttributeLimit(Object value) { + + return value instanceof Map && ((Map) value).size() <= MAX_ATTRIBUTES_PER_OBJECT; + } + + private static boolean containsOnlySchemaKeys(Map valueMap, Map schema) { + + for (String key : valueMap.keySet()) { + if (!schema.containsKey(key)) { + return false; + } + } + return true; + } + + private static boolean validateEntryValueAgainstType(Map.Entry entry, Map schema) { + + String type = schema.get(entry.getKey()); + Object attrValue = entry.getValue(); + + if (type != null && type.endsWith("[]")) { + return validatePrimaryArrayAttribute(attrValue); + } + return !isNestedStructure(attrValue); + } + + @SuppressWarnings("unchecked") + private static boolean validatePrimaryArrayAttribute(Object value) { + + if (!(value instanceof List)) { + return false; + } + + List list = (List) value; + if (list.size() > MAX_ARRAY_ITEMS) { + return false; + } + + for (Object item : list) { + if (isNestedStructure(item)) { + return false; + } + } + return true; + } + + private static boolean isNestedStructure(Object value) { + + return value instanceof Map || value instanceof List; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java new file mode 100644 index 000000000000..9c23f6ce71c8 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.internal; + +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; + +/** + * Data holder for the In-Flow Extension bundle. + * + *

This singleton is used by DS bind/unbind methods to expose dynamic OSGi references + * to classes outside the component lifecycle methods.

+ */ +public final class InFlowExtensionDataHolder { + + private static final InFlowExtensionDataHolder instance = new InFlowExtensionDataHolder(); + + private ActionExecutorService actionExecutorService; + private ActionManagementService actionManagementService; + private CertificateManagementService certificateManagementService; + private ClaimMetadataManagementService claimMetadataManagementService; + + private InFlowExtensionDataHolder() { + + } + + public static InFlowExtensionDataHolder getInstance() { + + return instance; + } + + public ActionExecutorService getActionExecutorService() { + + return actionExecutorService; + } + + public void setActionExecutorService(ActionExecutorService actionExecutorService) { + + this.actionExecutorService = actionExecutorService; + } + + public ActionManagementService getActionManagementService() { + + return actionManagementService; + } + + public void setActionManagementService(ActionManagementService actionManagementService) { + + this.actionManagementService = actionManagementService; + } + + public CertificateManagementService getCertificateManagementService() { + + return certificateManagementService; + } + + public void setCertificateManagementService(CertificateManagementService certificateManagementService) { + + this.certificateManagementService = certificateManagementService; + } + + public ClaimMetadataManagementService getClaimMetadataManagementService() { + + return claimMetadataManagementService; + } + + public void setClaimMetadataManagementService(ClaimMetadataManagementService claimMetadataManagementService) { + + this.claimMetadataManagementService = claimMetadataManagementService; + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java new file mode 100644 index 000000000000..65cefb682f07 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.internal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.action.management.api.service.ActionConverter; +import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; +import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; +import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionExecutor; +import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionRequestBuilder; +import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionResponseProcessor; +import org.wso2.carbon.identity.flow.inflow.extensions.management.InFlowExtensionActionConverter; +import org.wso2.carbon.identity.flow.inflow.extensions.management.InFlowExtensionActionDTOModelResolver; + +/** + * OSGi declarative services component which registers the In-Flow Extension services. + */ +@Component( + name = "flow.inflow.extensions.component", + immediate = true) +public class InFlowExtensionServiceComponent { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionServiceComponent.class); + + @Activate + protected void activate(ComponentContext context) { + + try { + BundleContext bundleContext = context.getBundleContext(); + + bundleContext.registerService(Executor.class.getName(), new InFlowExtensionExecutor(), null); + bundleContext.registerService(ActionExecutionRequestBuilder.class.getName(), + new InFlowExtensionRequestBuilder(), null); + bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(), + new InFlowExtensionResponseProcessor(), null); + + bundleContext.registerService(ActionConverter.class.getName(), + new InFlowExtensionActionConverter(), null); + bundleContext.registerService(ActionDTOModelResolver.class.getName(), + new InFlowExtensionActionDTOModelResolver( + InFlowExtensionDataHolder.getInstance().getCertificateManagementService()), + null); + + LOG.debug("In-Flow Extension service successfully activated."); + } catch (Throwable e) { + LOG.error("Error while initiating In-Flow Extension service", e); + } + } + + @Deactivate + protected void deactivate(ComponentContext context) { + + LOG.debug("In-Flow Extension service successfully deactivated."); + } + + @Reference( + name = "ActionManagementService", + service = ActionManagementService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetActionManagementService" + ) + protected void setActionManagementService(ActionManagementService actionManagementService) { + + LOG.debug("Setting the ActionManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance().setActionManagementService(actionManagementService); + } + + protected void unsetActionManagementService(ActionManagementService actionManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionManagementService in the In-Flow Extension component. Service: " + + actionManagementService); + } + InFlowExtensionDataHolder.getInstance().setActionManagementService(null); + } + + @Reference( + name = "ActionExecutorService", + service = ActionExecutorService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetActionExecutorService" + ) + protected void setActionExecutorService(ActionExecutorService actionExecutorService) { + + LOG.debug("Setting the ActionExecutorService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance().setActionExecutorService(actionExecutorService); + } + + protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionExecutorService in the In-Flow Extension component. Service: " + + actionExecutorService); + } + InFlowExtensionDataHolder.getInstance().setActionExecutorService(null); + } + + @Reference( + name = "CertificateManagementService", + service = CertificateManagementService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetCertificateManagementService" + ) + protected void setCertificateManagementService(CertificateManagementService certificateManagementService) { + + LOG.debug("Setting the CertificateManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance() + .setCertificateManagementService(certificateManagementService); + } + + protected void unsetCertificateManagementService( + CertificateManagementService certificateManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the CertificateManagementService in the In-Flow Extension component. Service: " + + certificateManagementService); + } + InFlowExtensionDataHolder.getInstance().setCertificateManagementService(null); + } + + @Reference( + name = "ClaimMetadataManagementService", + service = ClaimMetadataManagementService.class, + cardinality = ReferenceCardinality.OPTIONAL, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetClaimMetadataManagementService" + ) + protected void setClaimMetadataManagementService( + ClaimMetadataManagementService claimMetadataManagementService) { + + LOG.debug("Setting the ClaimMetadataManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance() + .setClaimMetadataManagementService(claimMetadataManagementService); + } + + protected void unsetClaimMetadataManagementService( + ClaimMetadataManagementService claimMetadataManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ClaimMetadataManagementService in the In-Flow Extension component. Service: " + + claimMetadataManagementService); + } + InFlowExtensionDataHolder.getInstance().setClaimMetadataManagementService(null); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java new file mode 100644 index 000000000000..ab23dd441f2f --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.management; + +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; +import org.wso2.carbon.identity.action.management.api.model.ActionProperty; +import org.wso2.carbon.identity.action.management.api.service.ActionConverter; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.flow.inflow.extensions.model.AccessConfig; +import org.wso2.carbon.identity.flow.inflow.extensions.model.Encryption; +import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.inflow.extensions.model.InFlowExtensionAction; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; + +/** + * ActionConverter implementation for In-Flow Extension actions. + *

+ * Handles the conversion between {@link InFlowExtensionAction} (domain model) and + * {@link ActionDTO} (data transfer object) by mapping the {@link AccessConfig} fields + * to/from action properties. + *

+ */ +public class InFlowExtensionActionConverter implements ActionConverter { + + @Override + public Action.ActionTypes getSupportedActionType() { + + return Action.ActionTypes.IN_FLOW_EXTENSION; + } + + /** + * Converts an {@link InFlowExtensionAction} to an {@link ActionDTO} for persistence. + * Maps the access config fields (expose, modify) and flow type overrides + * into the DTO's properties map using prefixed keys. + * + * @param action The InFlowExtensionAction to convert. + * @return ActionDTO with access config properties. + */ + @Override + public ActionDTO buildActionDTO(Action action) { + + if (!(action instanceof InFlowExtensionAction inFlowExtensionAction)) { + return new ActionDTO.Builder(action).build(); + } + + Map properties = new HashMap<>(); + putDefaultAccessConfigProperties(properties, inFlowExtensionAction.getAccessConfig()); + putEncryptionProperty(properties, inFlowExtensionAction.getEncryption()); + if (inFlowExtensionAction.getIconUrl() != null) { + properties.put(ICON_URL, + new ActionProperty.BuilderForService(inFlowExtensionAction.getIconUrl()).build()); + } + putFlowTypeOverrideProperties(properties, inFlowExtensionAction.getFlowTypeOverrides()); + + return new ActionDTO.Builder(inFlowExtensionAction) + .properties(properties) + .build(); + } + + private void putDefaultAccessConfigProperties(Map properties, AccessConfig accessConfig) { + + if (accessConfig == null) { + return; + } + if (accessConfig.getExpose() != null) { + properties.put(ACCESS_CONFIG_EXPOSE, + new ActionProperty.BuilderForService(accessConfig.getExpose()).build()); + } + if (accessConfig.getModify() != null) { + properties.put(ACCESS_CONFIG_MODIFY, + new ActionProperty.BuilderForService(accessConfig.getModify()).build()); + } + } + + private void putEncryptionProperty(Map properties, Encryption encryption) { + + if (encryption == null) { + return; + } + if (encryption.getCertificate() != null) { + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService(encryption.getCertificate()).build()); + } else { + // Encryption object present but no certificate — signals explicit removal. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService("").build()); + } + } + + private void putFlowTypeOverrideProperties(Map properties, + Map overrides) { + + if (overrides == null) { + return; + } + for (Map.Entry entry : overrides.entrySet()) { + String flowType = entry.getKey(); + AccessConfig overrideConfig = entry.getValue(); + if (overrideConfig.getExpose() != null) { + properties.put(ACCESS_CONFIG_EXPOSE_PREFIX + flowType, + new ActionProperty.BuilderForService(overrideConfig.getExpose()).build()); + } + if (overrideConfig.getModify() != null) { + properties.put(ACCESS_CONFIG_MODIFY_PREFIX + flowType, + new ActionProperty.BuilderForService(overrideConfig.getModify()).build()); + } + } + } + + /** + * Converts an {@link ActionDTO} back to an {@link InFlowExtensionAction}. + * Reconstructs the default {@link AccessConfig} and per-flow-type overrides from the DTO's properties map. + * + * @param actionDTO The ActionDTO to convert. + * @return InFlowExtensionAction with access config and overrides populated. + */ + @SuppressWarnings("unchecked") + @Override + public Action buildAction(ActionDTO actionDTO) { + + // Default access config. + List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + List modify = + (List) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + + AccessConfig accessConfig = null; + if (expose != null || modify != null) { + accessConfig = new AccessConfig(expose, modify); + } + + // Encryption certificate (separate from access config). + Encryption encryption = null; + Object certValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certValue instanceof Certificate certificate) { + encryption = new Encryption(certificate); + } + + // Icon URL. + String iconUrl = null; + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue instanceof String iconUrlStr) { + iconUrl = iconUrlStr; + } + + // Reconstruct per-flow-type overrides from prefixed keys. + Map flowTypeOverrides = new HashMap<>(); + if (actionDTO.getProperties() != null) { + for (String propertyKey : actionDTO.getProperties().keySet()) { + if (propertyKey.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + String flowType = propertyKey.substring(ACCESS_CONFIG_EXPOSE_PREFIX.length()); + AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, + new AccessConfig(null, null)); + flowTypeOverrides.put(flowType, new AccessConfig( + (List) actionDTO.getPropertyValue(propertyKey), + existing.getModify())); + } else if (propertyKey.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + String flowType = propertyKey.substring(ACCESS_CONFIG_MODIFY_PREFIX.length()); + AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, + new AccessConfig(null, null)); + flowTypeOverrides.put(flowType, new AccessConfig( + existing.getExpose(), + (List) actionDTO.getPropertyValue(propertyKey))); + } + } + } + + return new InFlowExtensionAction.ResponseBuilder() + .id(actionDTO.getId()) + .type(actionDTO.getType()) + .name(actionDTO.getName()) + .description(actionDTO.getDescription()) + .status(actionDTO.getStatus()) + .actionVersion(actionDTO.getActionVersion()) + .createdAt(actionDTO.getCreatedAt()) + .updatedAt(actionDTO.getUpdatedAt()) + .endpoint(actionDTO.getEndpoint()) + .accessConfig(accessConfig) + .encryption(encryption) + .iconUrl(iconUrl) + .flowTypeOverrides(flowTypeOverrides) + .rule(actionDTO.getActionRule()) + .build(); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java new file mode 100644 index 000000000000..dd8e6a7609f8 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.management; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverClientException; +import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverException; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; +import org.wso2.carbon.identity.action.management.api.model.ActionProperty; +import org.wso2.carbon.identity.action.management.api.model.BinaryObject; +import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; +import org.wso2.carbon.identity.certificate.management.exception.CertificateMgtException; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptyList; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; +import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; + +/** + * ActionDTOModelResolver implementation for In-Flow Extension actions. + *

+ * Responsible for validating and transforming access config properties (expose paths and + * modify paths) between the service layer representation and the DAO layer BLOB format. + *

+ * + *
    + *
  • Add operation: Validates expose paths and modify paths, then serializes + * them to JSON {@link BinaryObject}s for BLOB storage in IDN_ACTION_PROPERTIES.
  • + *
  • Get operation: Deserializes BLOBs back to service-layer list objects.
  • + *
  • Update operation: Validates updated values or preserves existing ones (PUT semantics).
  • + *
  • Delete operation: No-op (properties are cascade-deleted with the action).
  • + *
+ */ +public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelResolver { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> CONTEXT_PATH_LIST_TYPE_REF = + new TypeReference>() { }; + + private final CertificateManagementService certificateManagementService; + + public InFlowExtensionActionDTOModelResolver(CertificateManagementService certificateManagementService) { + + this.certificateManagementService = certificateManagementService; + } + + @Override + public Action.ActionTypes getSupportedActionType() { + + return Action.ActionTypes.IN_FLOW_EXTENSION; + } + + @Override + public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + // Expose is an optional field. + if (exposeValue != null) { + List validatedExpose = validateExpose(exposeValue); + properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose)); + } + + Object modifyValue = actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + // Modify is an optional field. + if (modifyValue != null) { + List validatedModify = validateExpose(modifyValue); + properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(validatedModify)); + } + + // Handle certificate: store via CertificateManagementService and replace with ID. + handleCertificateAdd(actionDTO, properties, tenantDomain); + + // Handle icon URL: pass through as a PRIMITIVE string. + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue instanceof String iconUrlStr && !iconUrlStr.isEmpty()) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO(iconUrlStr).build()); + } + + // Handle per-flow-type override properties (prefixed keys). + resolveAddOverrideProperties(actionDTO, properties); + + return new ActionDTO.Builder(actionDTO) + .properties(properties) + .build(); + } + + private void resolveAddOverrideProperties(ActionDTO actionDTO, Map properties) + throws ActionDTOModelResolverException { + + if (actionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : actionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + Object overrideExpose = actionDTO.getPropertyValue(key); + if (overrideExpose != null) { + properties.put(key, createBlobProperty(validateExpose(overrideExpose))); + } + } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + Object overrideModify = actionDTO.getPropertyValue(key); + if (overrideModify != null) { + properties.put(key, createBlobProperty(validateExpose(overrideModify))); + } + } + } + } + + @Override + public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + // Default access config properties. + if (actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + properties.put(ACCESS_CONFIG_EXPOSE, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)).getJSONString())); + } + + if (actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + properties.put(ACCESS_CONFIG_MODIFY, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)).getJSONString())); + } + + // Retrieve certificate by stored ID. + handleCertificateGet(actionDTO, properties, tenantDomain); + + // Icon URL: pass through as-is (already a PRIMITIVE string). + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue != null) { + properties.put(ICON_URL, new ActionProperty.BuilderForService(iconUrlValue.toString()).build()); + } + + // Deserialize per-flow-type override properties (prefixed keys). + if (actionDTO.getProperties() != null) { + for (Map.Entry entry : actionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if ((key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) + && actionDTO.getPropertyValue(key) != null) { + properties.put(key, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString())); + } + } + } + + return new ActionDTO.Builder(actionDTO) + .properties(properties) + .build(); + } + + @Override + public List resolveForGetOperation(List actionDTOList, String tenantDomain) + throws ActionDTOModelResolverException { + + List resolvedList = new ArrayList<>(); + for (ActionDTO actionDTO : actionDTOList) { + resolvedList.add(resolveForGetOperation(actionDTO, tenantDomain)); + } + return resolvedList; + } + + /** + * Resolves the actionDTO for the update operation. + * When properties are updated, the existing properties are replaced with the new properties. + * When properties are not updated, the existing properties should be sent to the upstream component. + * + * @param updatingActionDTO ActionDTO that needs to be updated. + * @param existingActionDTO Existing ActionDTO. + * @param tenantDomain Tenant domain. + * @return Resolved ActionDTO. + * @throws ActionDTOModelResolverException ActionDTOModelResolverException. + */ + @Override + public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + String tenantDomain) throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + // Action Properties updating operation is treated as a PUT in DAO layer. Therefore if no properties are + // updated the existing properties should be sent to the DAO layer. + + // Handle default access config properties. + List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO); + if (!expose.isEmpty()) { + properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose)); + } + + List modify = getResolvedUpdatingModify(updatingActionDTO, existingActionDTO); + if (!modify.isEmpty()) { + properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(modify)); + } + + // Handle certificate update. + handleCertificateUpdate(updatingActionDTO, existingActionDTO, properties, tenantDomain); + resolveUpdateIconUrl(updatingActionDTO, existingActionDTO, properties); + carryForwardExistingOverrides(existingActionDTO, properties); + overlayUpdatedOverrides(updatingActionDTO, properties); + + return new ActionDTO.Builder(updatingActionDTO) + .properties(properties) + .build(); + } + + private void resolveUpdateIconUrl(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + Map properties) + throws ActionDTOModelResolverException { + + Object updatingIconUrl = updatingActionDTO.getPropertyValue(ICON_URL); + if (updatingIconUrl instanceof String updatingIconUrlStr && !updatingIconUrlStr.isEmpty()) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO(updatingIconUrlStr).build()); + } else if (existingActionDTO.getPropertyValue(ICON_URL) != null) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO( + existingActionDTO.getPropertyValue(ICON_URL).toString()).build()); + } + } + + private void carryForwardExistingOverrides(ActionDTO existingActionDTO, + Map properties) + throws ActionDTOModelResolverException { + + if (existingActionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : existingActionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + properties.put(key, createBlobProperty(existingActionDTO.getPropertyValue(key))); + } + } + } + + private void overlayUpdatedOverrides(ActionDTO updatingActionDTO, Map properties) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : updatingActionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + Object overrideExpose = updatingActionDTO.getPropertyValue(key); + if (overrideExpose != null) { + properties.put(key, createBlobProperty(validateExpose(overrideExpose))); + } + } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + Object overrideModify = updatingActionDTO.getPropertyValue(key); + if (overrideModify != null) { + properties.put(key, createBlobProperty(validateExpose(overrideModify))); + } + } + } + } + + @Override + public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + // Delete the certificate if one was stored for this action. + handleCertificateDelete(deletingActionDTO, tenantDomain); + } + + // ---- Update helpers ---- + + @SuppressWarnings("unchecked") + private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)); + } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + } + return emptyList(); + } + + @SuppressWarnings("unchecked") + private List getResolvedUpdatingModify(ActionDTO updatingActionDTO, + ActionDTO existingActionDTO) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)); + } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + } + return emptyList(); + } + + // ---- Validation ---- + + @SuppressWarnings("unchecked") + private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException { + + if (!(exposeValue instanceof List)) { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Expose should be provided as a list."); + } + + List exposeList = (List) exposeValue; + List result = new ArrayList<>(); + + for (Object item : exposeList) { + if (item instanceof Map) { + Map map = (Map) item; + if (!map.containsKey("path") || !(map.get("path") instanceof String)) { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Each expose entry must be an object with a 'path' field."); + } + String path = (String) map.get("path"); + boolean encrypted = map.containsKey("encrypted") && toBooleanSafe(map.get("encrypted")); + result.add(new ContextPath(path, encrypted)); + } else if (item instanceof ContextPath contextPath) { + result.add(contextPath); + } else { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Each expose entry must be an object with 'path' and optional 'encrypted' fields."); + } + } + + validateExposeCount(result); + validateContextPathFormat(result); + return result; + } + + private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException { + + if (expose.size() > MAX_EXPOSE_PATHS) { + throw new ActionDTOModelResolverClientException("Maximum expose paths limit exceeded.", + String.format("The number of configured expose paths: %d exceeds the maximum allowed limit: %d.", + expose.size(), MAX_EXPOSE_PATHS)); + } + } + + private void validateContextPathFormat(List expose) throws ActionDTOModelResolverClientException { + + Set seen = new HashSet<>(); + for (ContextPath exposePath : expose) { + String path = exposePath.getPath(); + if (path == null || path.trim().isEmpty()) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + "Expose paths must not be null or empty."); + } + if (!path.startsWith("/")) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + String.format("Expose path '%s' must start with '/'.", path)); + } + if (path.endsWith("/")) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + String.format("Expose path '%s' must not end with a trailing '/'.", path)); + } + if (!seen.add(path)) { + throw new ActionDTOModelResolverClientException("Duplicate expose path.", + String.format("The expose path '%s' is duplicated.", path)); + } + } + } + + // ---- Certificate lifecycle helpers ---- + + /** + * Stores the external service's certificate via CertificateManagementService during action creation. + * The certificate PEM is replaced with the stored certificate's ID as a PRIMITIVE property. + */ + private void handleCertificateAdd(ActionDTO actionDTO, Map properties, + String tenantDomain) throws ActionDTOModelResolverException { + + Object certValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certValue == null) { + return; + } + + String certificatePEM = extractCertificatePEM(certValue); + String certName = CERTIFICATE_NAME_PREFIX + actionDTO.getId(); + + try { + String certificateId = certificateManagementService.addCertificate( + new Certificate.Builder() + .name(certName) + .certificateContent(certificatePEM) + .build(), + tenantDomain); + + // Store the certificate ID as a primitive property so DAO persists just the ID. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(certificateId).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error storing certificate for action: " + + actionDTO.getId(), e); + } + } + + /** + * Retrieves the certificate by its stored ID during action get operations. + * Replaces the stored ID with the full Certificate object as a service-layer property. + */ + private void handleCertificateGet(ActionDTO actionDTO, Map properties, + String tenantDomain) throws ActionDTOModelResolverException { + + Object certIdValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certIdValue == null) { + return; + } + + try { + String certIdStr = certIdValue.toString(); + Certificate certificate = certificateManagementService.getCertificate( + certIdStr, tenantDomain); + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService(certificate).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error retrieving certificate for action: " + + actionDTO.getId(), e); + } + } + + /** + * Handles certificate lifecycle during action update: add new, update existing, delete, or carry forward. + */ + private void handleCertificateUpdate(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + Map properties, String tenantDomain) + throws ActionDTOModelResolverException { + + Object newCertValue = updatingActionDTO.getPropertyValue(CERTIFICATE); + Object existingCertValue = existingActionDTO.getPropertyValue(CERTIFICATE); + + // Empty string signals explicit certificate removal. + boolean isExplicitRemoval = newCertValue instanceof String s && s.isEmpty(); + + if (isExplicitRemoval && existingCertValue != null) { + // Explicitly clearing the certificate — delete the existing one. + try { + String existingCertId = extractCertificateId(existingCertValue); + certificateManagementService.deleteCertificate(existingCertId, tenantDomain); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error deleting certificate for action: " + + updatingActionDTO.getId(), e); + } + } else if (newCertValue != null && !isExplicitRemoval && existingCertValue != null) { + // Update existing certificate. + String certificatePEM = extractCertificatePEM(newCertValue); + try { + String existingCertId = extractCertificateId(existingCertValue); + certificateManagementService.updateCertificateContent( + existingCertId, certificatePEM, tenantDomain); + // Carry forward the existing certificate ID. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(existingCertId).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error updating certificate for action: " + + updatingActionDTO.getId(), e); + } + } else if (newCertValue != null && !isExplicitRemoval) { + // Add new certificate (previously had none). + handleCertificateAdd(updatingActionDTO, properties, tenantDomain); + } else if (existingCertValue != null) { + // No new certificate provided — carry forward the existing one (PUT semantics). + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(extractCertificateId(existingCertValue)).build()); + } + // else: both null — no certificate to handle. + } + + /** + * Deletes the certificate from IDN_CERTIFICATE during action deletion. + */ + private void handleCertificateDelete(ActionDTO deletingActionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Object certIdValue = deletingActionDTO.getPropertyValue(CERTIFICATE); + if (certIdValue == null) { + return; + } + + try { + String certId = extractCertificateId(certIdValue); + certificateManagementService.deleteCertificate(certId, tenantDomain); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error deleting certificate for action: " + + deletingActionDTO.getId(), e); + } + } + + /** + * Extracts the certificate UUID from a certificate property value. + *

+ * The existing action DTO may come from the GET resolver, which replaces the stored UUID + * with the full {@link Certificate} object. This method handles both cases: + * - {@link Certificate} object: extracts the ID via {@code getId()}. + * - String: assumes it is already the UUID. + *

+ */ + private String extractCertificateId(Object certValue) { + + if (certValue instanceof Certificate certificate) { + return certificate.getId(); + } + return certValue.toString(); + } + + /** + * Extracts the PEM string from a certificate value, which may be a Certificate object, + * a Map, or a plain string. + */ + private String extractCertificatePEM(Object certValue) throws ActionDTOModelResolverClientException { + + if (certValue instanceof Certificate certificate) { + return certificate.getCertificateContent(); + } else if (certValue instanceof Map) { + Map certMap = (Map) certValue; + Object content = certMap.get("certificateContent"); + if (content instanceof String pem) { + return pem; + } + throw new ActionDTOModelResolverClientException("Invalid certificate format.", + "Certificate object must contain a 'certificateContent' field."); + } else if (certValue instanceof String pem) { + return pem; + } + throw new ActionDTOModelResolverClientException("Invalid certificate format.", + "Certificate must be a PEM string, a Certificate object, or a map with 'certificateContent'."); + } + + // ---- Serialization helpers ---- + + private ActionProperty createBlobProperty(Object value) throws ActionDTOModelResolverException { + + try { + BinaryObject binaryObject = BinaryObject.fromJsonString(OBJECT_MAPPER.writeValueAsString(value)); + return new ActionProperty.BuilderForDAO(binaryObject).build(); + } catch (JsonProcessingException e) { + throw new ActionDTOModelResolverException("Failed to serialize access config property to JSON.", e); + } + } + + private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException { + + try { + List expose = OBJECT_MAPPER.readValue(jsonString, CONTEXT_PATH_LIST_TYPE_REF); + return new ActionProperty.BuilderForService(expose).build(); + } catch (IOException e) { + throw new ActionDTOModelResolverException("Error reading expose values from storage.", e); + } + } + + /** + * Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types. + * Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}. + * + * @param value The value to convert. + * @return {@code true} if the value is Boolean TRUE or the string "true" (case-insensitive). + */ + private static boolean toBooleanSafe(Object value) { + + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return Boolean.parseBoolean(s); + } + return false; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java new file mode 100644 index 000000000000..f65e85adeb13 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Builds the controlled In-Flow Extension context tree returned by the metadata endpoint. + * Consumes the engine-level {@link FlowContextHandoverConfig} flat allow-lists and projects + * them back into the user/flow/properties tree shape the Console UI expects, so the frontend + * requires no change. Nodes are pruned when the corresponding attribute is absent from the + * allow-list. + */ +public class InFlowExtensionContextTreeBuilder { + + // Flow type identifiers — must match the values produced by FlowTypes.getType(). + private static final String FLOW_REGISTRATION = "REGISTRATION"; + private static final String FLOW_INVITED_USER_REGISTRATION = "INVITED_USER_REGISTRATION"; + private static final String FLOW_PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; + + // Node-type sentinels matching the tree component's NodeType enum on the Console side. + private static final String NODE_OBJECT = "OBJECT"; + private static final String NODE_LEAF = "LEAF"; + private static final String NODE_MAP = "MAP"; + private static final String NODE_COMPLEX_MAP = "COMPLEX_MAP"; + + private static final String OP_EXPOSE = "EXPOSE"; + private static final String OP_MODIFY = "MODIFY"; + private static final String DATA_TYPE_STRING = "String"; + + // Flow context attributes that appear under the "flow" branch of the tree. + private static final List FLOW_BRANCH_ATTRS = + Arrays.asList("tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl"); + + private final FlowContextHandoverConfig handoverConfig; + + public InFlowExtensionContextTreeBuilder(FlowContextHandoverConfig handoverConfig) { + + this.handoverConfig = handoverConfig; + } + + /** + * Build the metadata response for the given flow type. + * + * @param flowType the flow type (null → default tree). + * @return a fully populated metadata DTO. + */ + public InFlowExtensionContextTreeMetadata build(String flowType) { + + Set attrs = handoverConfig.getIncludedAttributes(); + Set userAttrs = handoverConfig.isFullUserPassthrough() + ? null // null → all user children shown + : handoverConfig.getIncludedUserAttributes(); + + List tree = new ArrayList<>(); + + // User and properties nodes are always non-null (always construct and return a node). + tree.add(buildUserNode(userAttrs)); + tree.add(buildPropertiesNode(attrs)); + + // Flow node is conditionally non-null (returns null if no flow attributes are configured). + InFlowExtensionContextTreeNode flowNode = buildFlowNode(attrs); + if (flowNode != null) { + tree.add(flowNode); + } + + return new InFlowExtensionContextTreeMetadata( + flowType, + tree, + true, // redirection is unconditionally enabled + resolveAllowReadOnlyClaimsModification(flowType)); + } + + /** + * Whether the Console UI may permit MODIFY on read-only claims for this flow type. + * Hardcoded enumerative mapping so that any future flow type defaults to false until + * explicitly added here. + * + *

The default tree (null flowType) returns true — matches current behaviour for the + * connection-level access-config editor which doesn't yet know which flow the action + * will be wired into.

+ */ + static boolean resolveAllowReadOnlyClaimsModification(String flowType) { + + if (flowType == null) { + return true; + } + switch (flowType) { + case FLOW_REGISTRATION, FLOW_INVITED_USER_REGISTRATION: + return true; + case FLOW_PASSWORD_RECOVERY: + default: + return false; + } + } + + /** + * Build the "user" subtree. + * + *

Strategy: + *

    + *
  • Read-only fields (userId, username, userStoreDomain): only emitted when the + * attribute is in the allow-list (or full-passthrough is active). They support EXPOSE + * only, so excluding them when restricted is the correct behaviour.
  • + *
  • Modifiable fields (claims, credentials): always emitted because modifications + * bypass the context handover — they travel through the executor response and are applied + * by the task execution node. EXPOSE is added only when the attribute is configured.
  • + *
+ * + *

{@code userAttrs == null} signals full-passthrough (show and expose everything). + */ + private InFlowExtensionContextTreeNode buildUserNode(Set userAttrs) { + + // userAttrs == null → full passthrough set by the caller (build()). + boolean fullPassthrough = (userAttrs == null); + + List children = new ArrayList<>(); + + // ── Read-only fields: emit only when exposed ──────────────────────────────────────── + if (fullPassthrough || userAttrs.contains("userId")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("userId") + .title("User ID") + .path("/user/userId") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + if (fullPassthrough || userAttrs.contains("username")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("username") + .title("Username") + .path("/user/username") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + if (fullPassthrough || userAttrs.contains("userStoreDomain")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("userStoreDomain") + .title("User Store Domain") + .path("/user/userStoreDomain") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + + // ── Modifiable fields: always present, restrict EXPOSE when not configured ────────── + List claimsOps = (fullPassthrough || userAttrs.contains("claims")) + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + children.add(InFlowExtensionContextTreeNode.builder() + .key("claims") + .title("Claims") + .path("/user/claims/") + .dataType("Map") + .nodeType(NODE_MAP) + .allowedOperations(claimsOps) + .dynamicEntryAllowed(true) + .dynamicEntryType("String") + .children(Collections.emptyList()) + .build()); + + List credOps = (fullPassthrough || userAttrs.contains("userCredentials")) + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + children.add(InFlowExtensionContextTreeNode.builder() + .key("credentials") + .title("Credentials") + .path("/user/credentials/") + .dataType("Map") + .nodeType(NODE_MAP) + .allowedOperations(credOps) + .dynamicEntryAllowed(true) + .dynamicEntryType("char[]") + .children(Collections.emptyList()) + .build()); + + // User node is always returned (claims + credentials are always present). + return InFlowExtensionContextTreeNode.builder() + .key("user") + .title("User") + .path("/user/") + .dataType("") + .nodeType(NODE_OBJECT) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .children(children) + .build(); + } + + /** + * Build the "flow" subtree from top-level context attributes that map to the flow branch. + */ + private InFlowExtensionContextTreeNode buildFlowNode(Set attrs) { + + List children = new ArrayList<>(); + for (String attr : FLOW_BRANCH_ATTRS) { + if (attrs.contains(attr)) { + String title = attrTitle(attr); + children.add(flowLeaf(attr, title, "/flow/" + attr)); + } + } + if (children.isEmpty()) { + return null; + } + return InFlowExtensionContextTreeNode.builder() + .key("flow") + .title("Flow") + .path("/flow/") + .dataType("") + .nodeType(NODE_OBJECT) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .readOnly(true) + .children(children) + .build(); + } + + private InFlowExtensionContextTreeNode flowLeaf(String key, String title, String path) { + + return InFlowExtensionContextTreeNode.builder() + .key(key) + .title(title) + .path(path) + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .readOnly(true) + .build(); + } + + /** + * Build the "properties" node. + * + *

Properties is always emitted because modifications bypass the context handover (they + * travel through the executor response and are applied by the task execution node). + * EXPOSE is included only when {@code "properties"} is in the allow-list. + */ + private InFlowExtensionContextTreeNode buildPropertiesNode(Set attrs) { + + List ops = attrs.contains("properties") + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + return InFlowExtensionContextTreeNode.builder() + .key("properties") + .title("Properties") + .path("/properties/") + .dataType("Map") + .nodeType(NODE_COMPLEX_MAP) + .allowedOperations(ops) + .dynamicEntryAllowed(true) + .dynamicEntryType("Object") + .children(Collections.emptyList()) + .build(); + } + + private static String attrTitle(String attr) { + + switch (attr) { + case "tenantDomain": return "Tenant Domain"; + case "applicationId": return "Application ID"; + case "flowType": return "Flow Type"; + case "callbackUrl": return "Callback URL"; + case "portalUrl": return "Portal URL"; + default: return attr; + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java new file mode 100644 index 000000000000..d26d2ca2780b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import java.util.Collections; +import java.util.List; + +/** + * Metadata response served by the {@code GET /flow/in-flow-extension/context-tree} endpoint. + * Carries the controlled context tree (filtered by {@code FlowContextHandoverPolicy}) plus + * per-flow-type policy flags that the Console UI uses to gate the access-config editor. + */ +public class InFlowExtensionContextTreeMetadata { + + private final String flowType; + private final List contextTree; + private final boolean redirectionEnabled; + private final boolean allowReadOnlyClaimsModification; + + public InFlowExtensionContextTreeMetadata(String flowType, + List contextTree, + boolean redirectionEnabled, + boolean allowReadOnlyClaimsModification) { + + this.flowType = flowType; + this.contextTree = contextTree != null ? Collections.unmodifiableList(contextTree) + : Collections.emptyList(); + this.redirectionEnabled = redirectionEnabled; + this.allowReadOnlyClaimsModification = allowReadOnlyClaimsModification; + } + + /** + * @return The flow type this metadata applies to. {@code null} indicates the default tree. + */ + public String getFlowType() { + + return flowType; + } + + public List getContextTree() { + + return contextTree; + } + + public boolean isRedirectionEnabled() { + + return redirectionEnabled; + } + + public boolean isAllowReadOnlyClaimsModification() { + + return allowReadOnlyClaimsModification; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java new file mode 100644 index 000000000000..e00a7fef97e7 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import java.util.Collections; +import java.util.List; + +/** + * Single node in the In-Flow Extension context tree returned by the metadata service. + * Mirrors the shape of {@code default-flow-context-tree.json} on the Console side so the + * existing tree component can render this without translation. + */ +public class InFlowExtensionContextTreeNode { + + private final String key; + private final String title; + private final String path; + private final String dataType; + private final String nodeType; + private final List allowedOperations; + private final boolean readOnly; + private final boolean replaceable; + private final boolean dynamicEntryAllowed; + private final String dynamicEntryType; + private final List children; + + private InFlowExtensionContextTreeNode(Builder b) { + + this.key = b.key; + this.title = b.title; + this.path = b.path; + this.dataType = b.dataType; + this.nodeType = b.nodeType; + this.allowedOperations = b.allowedOperations != null + ? Collections.unmodifiableList(b.allowedOperations) : Collections.emptyList(); + this.readOnly = b.readOnly; + this.replaceable = b.replaceable; + this.dynamicEntryAllowed = b.dynamicEntryAllowed; + this.dynamicEntryType = b.dynamicEntryType; + this.children = b.children != null + ? Collections.unmodifiableList(b.children) : Collections.emptyList(); + } + + public String getKey() { + + return key; + } + + public String getTitle() { + + return title; + } + + public String getPath() { + + return path; + } + + public String getDataType() { + + return dataType; + } + + public String getNodeType() { + + return nodeType; + } + + public List getAllowedOperations() { + + return allowedOperations; + } + + public boolean isReadOnly() { + + return readOnly; + } + + public boolean isReplaceable() { + + return replaceable; + } + + public boolean isDynamicEntryAllowed() { + + return dynamicEntryAllowed; + } + + public String getDynamicEntryType() { + + return dynamicEntryType; + } + + public List getChildren() { + + return children; + } + + public static Builder builder() { + + return new Builder(); + } + + public static final class Builder { + + private String key; + private String title; + private String path; + private String dataType = ""; + private String nodeType; + private List allowedOperations; + private boolean readOnly; + private boolean replaceable; + private boolean dynamicEntryAllowed; + private String dynamicEntryType; + private List children; + + public Builder key(String v) { + + this.key = v; + return this; + } + + public Builder title(String v) { + + this.title = v; + return this; + } + + public Builder path(String v) { + + this.path = v; + return this; + } + + public Builder dataType(String v) { + + this.dataType = v; + return this; + } + + public Builder nodeType(String v) { + + this.nodeType = v; + return this; + } + + public Builder allowedOperations(List v) { + + this.allowedOperations = v; + return this; + } + + public Builder readOnly(boolean v) { + + this.readOnly = v; + return this; + } + + public Builder replaceable(boolean v) { + + this.replaceable = v; + return this; + } + + public Builder dynamicEntryAllowed(boolean v) { + + this.dynamicEntryAllowed = v; + return this; + } + + public Builder dynamicEntryType(String v) { + + this.dynamicEntryType = v; + return this; + } + + public Builder children(List v) { + + this.children = v; + return this; + } + + public InFlowExtensionContextTreeNode build() { + + return new InFlowExtensionContextTreeNode(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java new file mode 100644 index 000000000000..8765e89079d8 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; + +/** + * Public-API entry point for retrieving the controlled In-Flow Extension context tree. + * Lives in the {@code metadata} package (which is exported by the engine OSGi bundle), so + * external bundles such as the flow-management API server can call it without depending on + * the engine's {@code internal} package. + * + *

Delegates config lookup to {@link FlowExecutionEngineDataHolder}, which owns the + * engine-level {@link FlowContextHandoverConfig} singleton.

+ */ +public final class InFlowExtensionContextTreeService { + + private static final InFlowExtensionContextTreeService INSTANCE = new InFlowExtensionContextTreeService(); + + private InFlowExtensionContextTreeService() { + + } + + public static InFlowExtensionContextTreeService getInstance() { + + return INSTANCE; + } + + /** + * Build the controlled context tree for the given flow type. + * + * @param flowType the flow type, or null for the default tree. + * @return the metadata DTO carrying the pruned tree + per-flow-type policy flags. + */ + public InFlowExtensionContextTreeMetadata buildContextTree(String flowType) { + + return new InFlowExtensionContextTreeBuilder( + FlowContextHandoverConfig.defaultPolicy()).build(flowType); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java new file mode 100644 index 000000000000..d970a3245add --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.flow.inflow.extensions.executor.PathTypeAnnotationUtil; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Access Configuration for In-Flow Extension actions. + *

+ * Defines which parts of the flow context are exposed to the external service + * and which paths the service is allowed to modify. + *

+ * + *
    + *
  • {@code expose} – structured list of {@link ContextPath} entries, each with a hierarchical + * path prefix and an {@code encrypted} flag controlling outbound JWE encryption.
  • + *
  • {@code modify} – structured list of {@link ContextPath} entries defining which paths the + * external service can change. All modifications map to REPLACE operations internally. + * The {@code encrypted} flag on modify paths controls inbound JWE encryption (the external + * service encrypts values, IS decrypts with its private key).
  • + *
+ * + *

Expose and modify are independent: expose controls what data is sent to the external service, + * while modify controls what data the external service is allowed to change. A path can appear + * in both lists with independent encryption flags.

+ * + *

Note: The external service's certificate for outbound encryption is held in the + * separate {@link Encryption} model, not in AccessConfig.

+ */ +public class AccessConfig { + + private final List expose; + private final List modify; + + /** + * Constructs an AccessConfig with the given expose and modify paths. + * + * @param expose List of expose path entries. May be {@code null}. + * @param modify List of modify path entries. May be {@code null}. + */ + public AccessConfig(List expose, List modify) { + + this.expose = expose != null ? Collections.unmodifiableList(expose) : null; + this.modify = modify != null ? Collections.unmodifiableList(modify) : null; + } + + /** + * Returns the list of expose path entries. + * + * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. + */ + public List getExpose() { + + return expose; + } + + /** + * Returns the flat list of expose path strings (without encryption metadata). + * Convenience method for components that only need path prefixes. + * + * @return List of path strings, or an empty list if expose is not configured. + */ + public List getExposePaths() { + + if (expose == null) { + return Collections.emptyList(); + } + return expose.stream().map(ContextPath::getPath).collect(Collectors.toList()); + } + + /** + * Returns the list of modify path entries. + * + * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. + */ + public List getModify() { + + return modify; + } + + /** + * Returns the flat list of modify path strings (without encryption metadata). + * Convenience method for components that only need path strings. + * + * @return List of path strings, or an empty list if modify is not configured. + */ + public List getModifyPaths() { + + if (modify == null) { + return Collections.emptyList(); + } + return modify.stream().map(ContextPath::getPath).collect(Collectors.toList()); + } + + /** + * Check if a given expose path has outbound encryption enabled. + * With leaf-only expose paths, this performs an exact match lookup. + * + * @param path The expose path to check. + * @return {@code true} if the matching expose entry has {@code encrypted = true}. + */ + public boolean isExposePathEncrypted(String path) { + + if (expose == null) { + return false; + } + return expose.stream() + .filter(ep -> ep.getPath().equals(path)) + .findFirst() + .map(ContextPath::isEncrypted) + .orElse(false); + } + + /** + * Check if a given modify path has inbound encryption enabled. + * Matches the modify entry whose path (after stripping type annotations) exactly equals the given path. + * + * @param path The terminal path to check (clean, without annotations). + * @return {@code true} if the matching modify entry has {@code encrypted = true}. + */ + public boolean isModifyPathEncrypted(String path) { + + if (modify == null) { + return false; + } + return modify.stream() + .filter(mp -> path.equals(PathTypeAnnotationUtil.stripAnnotation(mp.getPath())[0])) + .findFirst() + .map(ContextPath::isEncrypted) + .orElse(false); + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java new file mode 100644 index 000000000000..603ea3c159ec --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Domain model for a context path with an optional encryption flag. + * Used for both expose and modify path entries in {@link AccessConfig}. + *

+ * When {@code encrypted} is {@code true}, the value at this path prefix is JWE-encrypted: + * for expose paths, outbound values are encrypted with the external service's certificate; + * for modify paths, inbound values from the external service are encrypted with IS's key. + *

+ */ +public class ContextPath { + + private final String path; + private final boolean encrypted; + + @JsonCreator + public ContextPath(@JsonProperty("path") String path, + @JsonProperty("encrypted") boolean encrypted) { + + this.path = path; + this.encrypted = encrypted; + } + + /** + * Returns the hierarchical path prefix (e.g. {@code "/user/credentials/"}). + * + * @return The path string. + */ + public String getPath() { + + return path; + } + + /** + * Returns whether values at this path should be JWE-encrypted before sending + * to the external service. + * + * @return {@code true} if encryption is enabled for this path. + */ + public boolean isEncrypted() { + + return encrypted; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java new file mode 100644 index 000000000000..a9296bcfb4e3 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.certificate.management.model.Certificate; + +/** + * Encryption configuration for In-Flow Extension actions. + *

+ * Holds the external service's X.509 public certificate used for outbound JWE encryption. + * This model is separate from {@link AccessConfig} — following the same pattern as + * {@code PasswordSharing} in the PreUpdatePassword action type. + *

+ *

+ * The IS uses this certificate to encrypt expose path values marked {@code encrypted: true} + * before sending them to the external service. For inbound encryption (Extension → IS), + * the external service must obtain the IS's public key out-of-band. + *

+ */ +public class Encryption { + + private final Certificate certificate; + + /** + * Constructs an Encryption configuration with the given certificate. + * + * @param certificate The external service's X.509 public certificate. + * May be {@code null} if encryption is not configured. + */ + public Encryption(Certificate certificate) { + + this.certificate = certificate; + } + + /** + * Returns the external service's X.509 public certificate. + * + * @return The certificate, or {@code null} if not configured. + */ + public Certificate getCertificate() { + + return certificate; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java new file mode 100644 index 000000000000..e1ddc736a0ba --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; + +import java.util.Collections; +import java.util.Set; + +/** + * Immutable snapshot of which {@code FlowExecutionContext} attributes are allowed to be + * handed over to the action framework during an in-flow extension execution. + * + *

The default policy is sourced from compile-time constants in + * {@link InFlowExtensionConstants.HandoverPolicy}. When the dynamic toml-based + * configuration PR is merged, these constants will serve as the documented defaults.

+ */ +public final class FlowContextHandoverConfig { + + private final Set includedAttributes; + private final Set includedUserAttributes; + private final boolean fullUserPassthrough; + + private FlowContextHandoverConfig(Set includedAttributes, + Set includedUserAttributes, + boolean fullUserPassthrough) { + + this.includedAttributes = Collections.unmodifiableSet(includedAttributes); + this.includedUserAttributes = Collections.unmodifiableSet(includedUserAttributes); + this.fullUserPassthrough = fullUserPassthrough; + } + + /** + * Construct a config from explicit attribute sets. + * {@code fullUserPassthrough} is true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} + * is present in {@code attrs}, meaning the entire FlowUser passes through without per-field filtering. + * + * @param attrs top-level context attributes to include; null is treated as empty. + * @param userAttrs FlowUser attributes to include; null is treated as empty. + * @return a new immutable {@link FlowContextHandoverConfig}. + */ + public static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { + + Set resolvedAttrs = (attrs != null) ? attrs : Collections.emptySet(); + Set resolvedUserAttrs = (userAttrs != null) ? userAttrs : Collections.emptySet(); + boolean fullPassthrough = resolvedAttrs.contains( + InFlowExtensionConstants.HandoverPolicy.ATTR_FLOW_USER); + return new FlowContextHandoverConfig(resolvedAttrs, resolvedUserAttrs, fullPassthrough); + } + + /** + * Returns the default handover policy built from compile-time constants defined in + * {@link InFlowExtensionConstants.HandoverPolicy}. + * + *

This is the factory method called at runtime by the executor and the context tree + * service. To change the effective policy, update the constants in + * {@link InFlowExtensionConstants.HandoverPolicy}.

+ * + * @return a new {@link FlowContextHandoverConfig} reflecting the default policy. + */ + public static FlowContextHandoverConfig defaultPolicy() { + + return of( + InFlowExtensionConstants.HandoverPolicy.INCLUDED_ATTRIBUTES, + InFlowExtensionConstants.HandoverPolicy.INCLUDED_USER_ATTRIBUTES + ); + } + + /** + * Returns the set of top-level {@code FlowExecutionContext} attribute names that may be + * handed over to the action framework. + * + * @return unmodifiable set of allowed attribute names. + */ + public Set getIncludedAttributes() { + + return includedAttributes; + } + + /** + * Returns the set of {@code FlowUser} attribute names that may be handed over when + * {@link #isFullUserPassthrough()} is false. + * + * @return unmodifiable set of allowed user attribute names. + */ + public Set getIncludedUserAttributes() { + + return includedUserAttributes; + } + + /** + * Returns true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} is + * present in {@link #getIncludedAttributes()}, meaning the entire {@code FlowUser} object + * is passed through without per-field inspection. + * + * @return true if the full FlowUser should pass through; false if per-field filtering applies. + */ + public boolean isFullUserPassthrough() { + + return fullUserPassthrough; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java new file mode 100644 index 000000000000..30a297000df3 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.EndpointConfig; + +import java.sql.Timestamp; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * In-Flow Extension Action. + *

+ * Extends the base {@link Action} with an {@link AccessConfig} that defines what flow context data + * is exposed to the external service and which operations are allowed. + *

+ */ +public class InFlowExtensionAction extends Action { + + private final AccessConfig accessConfig; + private final Encryption encryption; + private final Map flowTypeOverrides; + private final String iconUrl; + + public InFlowExtensionAction(ResponseBuilder responseBuilder) { + + super(responseBuilder); + this.accessConfig = responseBuilder.accessConfig; + this.encryption = responseBuilder.encryption; + this.flowTypeOverrides = responseBuilder.flowTypeOverrides != null + ? Collections.unmodifiableMap(new HashMap<>(responseBuilder.flowTypeOverrides)) + : Collections.emptyMap(); + this.iconUrl = responseBuilder.iconUrl; + } + + public InFlowExtensionAction(RequestBuilder requestBuilder) { + + super(requestBuilder); + this.accessConfig = requestBuilder.accessConfig; + this.encryption = requestBuilder.encryption; + this.flowTypeOverrides = requestBuilder.flowTypeOverrides != null + ? Collections.unmodifiableMap(new HashMap<>(requestBuilder.flowTypeOverrides)) + : Collections.emptyMap(); + this.iconUrl = requestBuilder.iconUrl; + } + + /** + * Returns the default access configuration for this In-Flow Extension action. + * + * @return The access config, or {@code null} if not configured. + */ + public AccessConfig getAccessConfig() { + + return accessConfig; + } + + /** + * Returns the encryption configuration holding the external service's certificate. + * + * @return The encryption config, or {@code null} if not configured. + */ + public Encryption getEncryption() { + + return encryption; + } + + /** + * Returns the icon URL for this In-Flow Extension action. + * + * @return The icon URL, or {@code null} if not configured. + */ + public String getIconUrl() { + + return iconUrl; + } + + /** + * Returns the per-flow-type access config overrides. + * Keys are flow type strings (e.g., "REGISTRATION", "LOGIN"). + * + * @return Unmodifiable map of flow type to AccessConfig overrides. + */ + public Map getFlowTypeOverrides() { + + return flowTypeOverrides; + } + + /** + * Resolves the effective access config for the given flow type. + * Returns the flow-type-specific override if present, otherwise falls back to the default access config. + * + * @param flowType The flow type (e.g., "REGISTRATION"). + * @return The resolved AccessConfig, or {@code null} if neither override nor default is configured. + */ + public AccessConfig resolveAccessConfig(String flowType) { + + if (flowType != null && flowTypeOverrides.containsKey(flowType)) { + return flowTypeOverrides.get(flowType); + } + return accessConfig; + } + + /** + * Response Builder for InFlowExtensionAction. + * Used when building from persisted data (DAO → service layer). + */ + public static class ResponseBuilder extends ActionResponseBuilder { + + private AccessConfig accessConfig; + private Encryption encryption; + private Map flowTypeOverrides; + private String iconUrl; + + @Override + public ResponseBuilder id(String id) { + + super.id(id); + return this; + } + + @Override + public ResponseBuilder type(ActionTypes type) { + + super.type(type); + return this; + } + + @Override + public ResponseBuilder name(String name) { + + super.name(name); + return this; + } + + @Override + public ResponseBuilder description(String description) { + + super.description(description); + return this; + } + + @Override + public ResponseBuilder status(Status status) { + + super.status(status); + return this; + } + + @Override + public ResponseBuilder actionVersion(String actionVersion) { + + super.actionVersion(actionVersion); + return this; + } + + @Override + public ResponseBuilder createdAt(Timestamp createdAt) { + + super.createdAt(createdAt); + return this; + } + + @Override + public ResponseBuilder updatedAt(Timestamp updatedAt) { + + super.updatedAt(updatedAt); + return this; + } + + @Override + public ResponseBuilder endpoint(EndpointConfig endpoint) { + + super.endpoint(endpoint); + return this; + } + + @Override + public ResponseBuilder rule(org.wso2.carbon.identity.action.management.api.model.ActionRule rule) { + + super.rule(rule); + return this; + } + + public ResponseBuilder accessConfig(AccessConfig accessConfig) { + + this.accessConfig = accessConfig; + return this; + } + + public ResponseBuilder encryption(Encryption encryption) { + + this.encryption = encryption; + return this; + } + + public ResponseBuilder flowTypeOverrides(Map flowTypeOverrides) { + + this.flowTypeOverrides = flowTypeOverrides; + return this; + } + + public ResponseBuilder iconUrl(String iconUrl) { + + this.iconUrl = iconUrl; + return this; + } + + @Override + public InFlowExtensionAction build() { + + return new InFlowExtensionAction(this); + } + } + + /** + * Request Builder for InFlowExtensionAction. + * Used when building from REST API request (API → service layer). + */ + public static class RequestBuilder extends ActionRequestBuilder { + + private AccessConfig accessConfig; + private Encryption encryption; + private Map flowTypeOverrides; + private String iconUrl; + + public RequestBuilder(Action action) { + + name(action.getName()); + description(action.getDescription()); + actionVersion(action.getActionVersion()); + endpoint(action.getEndpoint()); + rule(action.getActionRule()); + } + + @Override + public RequestBuilder name(String name) { + + super.name(name); + return this; + } + + @Override + public RequestBuilder description(String description) { + + super.description(description); + return this; + } + + @Override + public RequestBuilder endpoint(EndpointConfig endpoint) { + + super.endpoint(endpoint); + return this; + } + + public RequestBuilder accessConfig(AccessConfig accessConfig) { + + this.accessConfig = accessConfig; + return this; + } + + public RequestBuilder encryption(Encryption encryption) { + + this.encryption = encryption; + return this; + } + + public RequestBuilder flowTypeOverrides(Map flowTypeOverrides) { + + this.flowTypeOverrides = flowTypeOverrides; + return this; + } + + public RequestBuilder iconUrl(String iconUrl) { + + this.iconUrl = iconUrl; + return this; + } + + @Override + public InFlowExtensionAction build() { + + return new InFlowExtensionAction(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java new file mode 100644 index 000000000000..c1f3a1ccabee --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.action.execution.api.model.Application; +import org.wso2.carbon.identity.action.execution.api.model.Event; +import org.wso2.carbon.identity.action.execution.api.model.Organization; +import org.wso2.carbon.identity.action.execution.api.model.Request; +import org.wso2.carbon.identity.action.execution.api.model.Tenant; +import org.wso2.carbon.identity.action.execution.api.model.User; +import org.wso2.carbon.identity.action.execution.api.model.UserStore; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * This class models the In-Flow Extension Event. + * It represents the event sent to the In-Flow Extension action over the Action Execution Request. + */ +public class InFlowExtensionEvent extends Event { + + private final String flowType; + private final String flowId; + private final String callbackUrl; + private final String portalUrl; + private final Map flowProperties; + + private InFlowExtensionEvent(Builder builder) { + + this.request = builder.request; + this.tenant = builder.tenant; + this.organization = builder.organization; + this.user = builder.user; + this.userStore = builder.userStore; + this.application = builder.application; + this.flowType = builder.flowType; + this.flowId = builder.flowId; + this.callbackUrl = builder.callbackUrl; + this.portalUrl = builder.portalUrl; + this.flowProperties = builder.flowProperties != null ? + Collections.unmodifiableMap(new HashMap<>(builder.flowProperties)) : Collections.emptyMap(); + } + + /** + * Get the flow type. + * + * @return The flow type (e.g., "REGISTRATION", "PASSWORD_RESET"). + */ + public String getFlowType() { + + return flowType; + } + + /** + * Get the flow identifier (context identifier of the executing flow). + * + * @return The flow identifier. + */ + public String getFlowId() { + + return flowId; + } + + /** + * Get the callback URL for the flow, if exposed. + * + * @return The callback URL, or null if not exposed. + */ + public String getCallbackUrl() { + + return callbackUrl; + } + + /** + * Get the portal URL for the flow, if exposed. + * + * @return The portal URL, or null if not exposed. + */ + public String getPortalUrl() { + + return portalUrl; + } + + /** + * Get the flow properties/context data. + * + * @return Unmodifiable map of flow properties. + */ + public Map getFlowProperties() { + + return flowProperties; + } + + /** + * Builder for the InFlowExtensionEvent. + */ + public static class Builder { + + private Request request; + private Tenant tenant; + private Organization organization; + private User user; + private UserStore userStore; + private Application application; + private String flowType; + private String flowId; + private String callbackUrl; + private String portalUrl; + private Map flowProperties; + + public Builder request(Request request) { + + this.request = request; + return this; + } + + public Builder tenant(Tenant tenant) { + + this.tenant = tenant; + return this; + } + + public Builder organization(Organization organization) { + + this.organization = organization; + return this; + } + + public Builder user(User user) { + + this.user = user; + return this; + } + + public Builder userStore(UserStore userStore) { + + this.userStore = userStore; + return this; + } + + public Builder application(Application application) { + + this.application = application; + return this; + } + + public Builder flowType(String flowType) { + + this.flowType = flowType; + return this; + } + + public Builder flowId(String flowId) { + + this.flowId = flowId; + return this; + } + + public Builder callbackUrl(String callbackUrl) { + + this.callbackUrl = callbackUrl; + return this; + } + + public Builder portalUrl(String portalUrl) { + + this.portalUrl = portalUrl; + return this; + } + + public Builder flowProperties(Map flowProperties) { + + this.flowProperties = flowProperties != null ? new HashMap<>(flowProperties) : null; + return this; + } + + public InFlowExtensionEvent build() { + + return new InFlowExtensionEvent(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java new file mode 100644 index 000000000000..0f36f68c0a69 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.action.execution.api.model.Request; + +/** + * Request payload carried inside an {@link InFlowExtensionEvent}. Holds the inbound HTTP + * request's additional headers and parameters (filtered downstream by the action framework + * against the action's allowed-headers / allowed-parameters configuration). + */ +public class InFlowExtensionRequest extends Request { + + public InFlowExtensionRequest() { + + super(); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java new file mode 100644 index 000000000000..73eeb00ac073 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; + +/** + * This class represents the result of the execution of an operation. + * It contains the operation that was executed, the status of the execution and a message. + * This is used to summarize the operations performed based on action response. + */ +public class OperationExecutionResult { + + private final PerformableOperation operation; + private final Status status; + private final String message; + + public OperationExecutionResult(PerformableOperation operation, Status status, String message) { + + this.operation = operation; + this.status = status; + this.message = message; + } + + public PerformableOperation getOperation() { + + return operation; + } + + public Status getStatus() { + + return status; + } + + public String getMessage() { + + return message; + } + + /** + * Enum to represent the status of the operation execution. + */ + public enum Status { + SUCCESS, FAILURE + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java new file mode 100644 index 000000000000..1a307c79989b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.util; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.HandoverPolicy; +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; + +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Builds a filtered defensive copy of a {@link FlowExecutionContext} containing only the + * attributes permitted by the supplied {@link FlowContextHandoverConfig}. + * + *

This class mirrors the engine's {@code FlowExecutionContextFilter} but lives in + * the inflow module to avoid a cross-bundle dependency on {@code engine.config.*}. When the + * toml-based dynamic configuration PR is merged, this class can be removed and the engine's + * filter used directly.

+ * + *

Implementation reflects over the JavaBean property descriptors of + * {@link FlowExecutionContext} and {@link FlowUser}. Descriptors are cached on class load. + * The original context is never mutated; non-permitted attributes are left null/empty on + * the copy.

+ * + *

Map fields receive a defensive shallow {@link HashMap} copy. The + * {@code userCredentials} field receives a per-entry {@code char[]} clone so that the + * request builder's post-extraction wipe zeroes the copy, not the source.

+ */ +public final class InFlowExtensionContextFilterUtil { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionContextFilterUtil.class); + + private static final Map CONTEXT_PROPERTIES; + private static final Map USER_PROPERTIES; + + static { + CONTEXT_PROPERTIES = Collections.unmodifiableMap(introspect(FlowExecutionContext.class)); + USER_PROPERTIES = Collections.unmodifiableMap(introspect(FlowUser.class)); + } + + private InFlowExtensionContextFilterUtil() { + + } + + /** + * Build a filtered copy of {@code original} according to {@code config}. + * + * @param original the source context (untouched). + * @param config the handover policy. + * @return a new {@link FlowExecutionContext} carrying only whitelisted attributes, + * or {@code null} if {@code original} is {@code null}. + */ + public static FlowExecutionContext filter(FlowExecutionContext original, + FlowContextHandoverConfig config) { + + if (original == null) { + return null; + } + + FlowExecutionContext copy = new FlowExecutionContext(); + + // contextIdentifier is engine-internal and always propagated regardless of config. + copy.setContextIdentifier(original.getContextIdentifier()); + + // Top-level attributes (flowUser and contextIdentifier are handled separately). + for (String name : config.getIncludedAttributes()) { + if (HandoverPolicy.ATTR_FLOW_USER.equals(name) + || HandoverPolicy.ATTR_CONTEXT_IDENTIFIER.equals(name)) { + continue; + } + copyProperty(CONTEXT_PROPERTIES, name, original, copy); + } + + // User attributes — a fresh non-null FlowUser is always set on the copy so that + // request builders / response processors don't need to null-guard the user object. + FlowUser dstUser = new FlowUser(); + copy.setFlowUser(dstUser); + FlowUser srcUser = original.getFlowUser(); + if (srcUser != null) { + Set userAttrs = config.isFullUserPassthrough() + ? USER_PROPERTIES.keySet() + : config.getIncludedUserAttributes(); + for (String name : userAttrs) { + copyProperty(USER_PROPERTIES, name, srcUser, dstUser); + } + } + + return copy; + } + + private static void copyProperty(Map descriptors, String name, + T source, T destination) { + + PropertyDescriptor pd = descriptors.get(name); + if (pd == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Skipping unknown handover attribute: " + name); + } + return; + } + Method reader = pd.getReadMethod(); + Method writer = pd.getWriteMethod(); + if (reader == null || writer == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Skipping handover attribute without readable+writable accessors: " + name); + } + return; + } + try { + Object value = reader.invoke(source); + value = defensivelyCopy(name, value); + writer.invoke(destination, value); + } catch (IllegalAccessException | InvocationTargetException e) { + LOG.warn("Failed to copy handover attribute '" + name + "': " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private static Object defensivelyCopy(String name, Object value) { + + if (!(value instanceof Map)) { + return value; + } + if (HandoverPolicy.ATTR_USER_CREDENTIALS.equals(name)) { + Map src = (Map) value; + Map out = new LinkedHashMap<>(); + for (Map.Entry entry : src.entrySet()) { + char[] v = entry.getValue(); + out.put(entry.getKey(), v == null ? null : v.clone()); + } + return out; + } + return new HashMap<>((Map) value); + } + + private static Map introspect(Class beanClass) { + + Map result = new HashMap<>(); + try { + for (PropertyDescriptor pd : + Introspector.getBeanInfo(beanClass, Object.class).getPropertyDescriptors()) { + if (pd.getReadMethod() != null && pd.getWriteMethod() != null) { + result.put(pd.getName(), pd); + } + } + } catch (IntrospectionException e) { + LOG.error("Failed to introspect " + beanClass.getName() + " for handover filtering.", e); + } + return result; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java new file mode 100644 index 000000000000..df63f77f8cb3 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions; + +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Test-only helper that builds {@link FlowContextHandoverConfig} instances using the + * inflow module's own factory method — no reflection, no engine class dependencies. + */ +public final class InFlowExtensionTestUtils { + + /** + * Commonly used attribute sets that cover all fields exposed by the filter. + */ + public static final Set ALL_CONTEXT_ATTRS = new HashSet<>(Arrays.asList( + "tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl", + "properties", "contextIdentifier")); + + public static final Set ALL_USER_ATTRS = new HashSet<>(Arrays.asList( + "username", "userId", "userStoreDomain", "claims", "userCredentials")); + + private InFlowExtensionTestUtils() { + + } + + /** + * Construct a permissive {@link FlowContextHandoverConfig} covering all known fields. + */ + public static FlowContextHandoverConfig permissiveConfig() { + + return configOf(ALL_CONTEXT_ATTRS, ALL_USER_ATTRS); + } + + /** + * Construct a {@link FlowContextHandoverConfig} with explicit allow-lists. + */ + public static FlowContextHandoverConfig configOf(Set attrs, Set userAttrs) { + + return FlowContextHandoverConfig.of(attrs, userAttrs); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java new file mode 100644 index 000000000000..ea938ea70f5c --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link HierarchicalPrefixMatcher}. + */ +public class HierarchicalPrefixMatcherTest { + + // ========================= isReadOnly ========================= + + @DataProvider(name = "readOnlyPaths") + public Object[][] readOnlyPaths() { + + return new Object[][] { + { "/flow/tenantDomain", true }, + { "/flow/applicationId", true }, + { "/flow/", true }, + { "/properties/riskScore", false }, + { "/user/claims/email", false }, + { null, false }, + }; + } + + @Test(dataProvider = "readOnlyPaths") + public void testIsReadOnly(String path, boolean expected) { + + assertEquals(HierarchicalPrefixMatcher.isReadOnly(path), expected); + } + + // ========================= anyExposedUnder ========================= + + @Test + public void testAnyExposedUnderMatchesLeafUnderPrefix() { + + List leafPaths = Arrays.asList( + "/user/claims/http://wso2.org/claims/email", + "/properties/riskScore"); + assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); + assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/flow/applicationId"); + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNullPrefix() { + + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder(null, + Arrays.asList("/user/claims/email"))); + } + + @Test + public void testAnyExposedUnderNullList() { + + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", null)); + } + + @Test + public void testAnyExposedUnderEmptyList() { + + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", + Collections.emptyList())); + } + + @Test + public void testAnyExposedUnderDoesNotMatchShortPath() { + + // A leaf path of "/user/userId" should NOT be matched by area prefix "/user/claims/" + List leafPaths = Collections.singletonList("/user/userId"); + assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); + } + + @Test + public void testAnyExposedUnderMultipleLeafsOneMatches() { + + List leafPaths = Arrays.asList( + "/flow/tenantDomain", + "/user/credentials/password"); + assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/user/credentials/", leafPaths)); + } + + // ========================= isExposedPath ========================= + + @Test + public void testIsExposedPathExactMatch() { + + List leafPaths = Arrays.asList( + "/user/claims/http://wso2.org/claims/email", + "/flow/tenantDomain", + "/user/userId"); + assertTrue(HierarchicalPrefixMatcher.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + assertTrue(HierarchicalPrefixMatcher.isExposedPath("/flow/tenantDomain", leafPaths)); + assertTrue(HierarchicalPrefixMatcher.isExposedPath("/user/userId", leafPaths)); + } + + @Test + public void testIsExposedPathNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/user/userId"); + assertFalse(HierarchicalPrefixMatcher.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + } + + @Test + public void testIsExposedPathNullPath() { + + assertFalse(HierarchicalPrefixMatcher.isExposedPath(null, + Arrays.asList("/user/userId"))); + } + + @Test + public void testIsExposedPathNullList() { + + assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/userId", null)); + } + + @Test + public void testIsExposedPathEmptyList() { + + assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/userId", + Collections.emptyList())); + } + + @Test + public void testIsExposedPathPrefixNotSufficient() { + + // An area prefix "/user/claims/" must NOT match when the list only has a leaf under it. + List leafPaths = Collections.singletonList("/user/claims/http://wso2.org/claims/email"); + assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/claims/", leafPaths)); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java new file mode 100644 index 000000000000..1264361abf1b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java @@ -0,0 +1,616 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; +import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionExecutor}. + */ +public class InFlowExtensionExecutorTest { + + private InFlowExtensionExecutor executor; + + @Mock + private ActionExecutorService actionExecutorService; + + private AutoCloseable mocks; + private MockedStatic holderMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + mocks = MockitoAnnotations.openMocks(this); + executor = new InFlowExtensionExecutor(); + + // Stub InFlowExtensionDataHolder for action executor service. + InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(actionExecutorService); + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + } + + @AfterMethod + public void tearDown() throws Exception { + + loggerUtilsMock.close(); + holderMock.close(); + mocks.close(); + } + + // ========================= getName ========================= + + @Test + public void testGetName() { + + assertEquals(executor.getName(), "InFlowExtensionExecutor"); + } + + // ========================= getInitiationData ========================= + + @Test + public void testGetInitiationData() { + + assertNotNull(executor.getInitiationData()); + assertTrue(executor.getInitiationData().isEmpty()); + } + + // ========================= rollback ========================= + + @Test + public void testRollback() { + + assertNull(executor.rollback(new FlowExecutionContext())); + } + + // ========================= execute — no actionId ========================= + + @Test + public void testExecuteNoActionId() throws Exception { + + FlowExecutionContext context = createContextWithMetadata(new HashMap<>()); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + verify(actionExecutorService, never()) + .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); + } + + @Test + public void testExecuteEmptyActionId() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", ""); + FlowExecutionContext context = createContextWithMetadata(metadata); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= execute — execution disabled ========================= + + @Test + public void testExecuteDisabledExecution() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(false); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + verify(actionExecutorService, never()) + .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); + } + + // ========================= execute — SUCCESS ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteSuccess() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus successStatus = mock(ActionExecutionStatus.class); + when(successStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.SUCCESS); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(successStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_COMPLETE); + } + + // ========================= execute — FAILED ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailed() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure("risk_detected", "Risk score exceeds threshold"); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + assertEquals(response.getErrorMessage(), "Risk score exceeds threshold"); + // Verify failureType metadata is set for RETRY. + assertNotNull(response.getAdditionalInfo()); + assertEquals(response.getAdditionalInfo().get("failureType"), "IN_FLOW_EXTENSION_FAILURE"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailedNoDescription() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure("risk_detected", null); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + // Falls back to reason when description is null. + assertEquals(response.getErrorMessage(), "risk_detected"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailedBothNull() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure(null, null); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + assertEquals(response.getErrorMessage(), + "The operation could not be completed due to an external service failure."); + } + + // ========================= execute — ERROR ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteError() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error("internal_error", "DB connection failed"); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + // errorMessage carries the Error reason/code field; errorDescription carries the human-readable text. + assertEquals(response.getErrorMessage(), "internal_error"); + assertEquals(response.getErrorDescription(), "DB connection failed"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteErrorNoDescription() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error("internal_error", null); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + assertEquals(response.getErrorMessage(), "internal_error"); + assertNull(response.getErrorDescription()); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteErrorBothNull() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error(null, null); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + // Both fields null → errorMessage and errorDescription remain null; errorCode alone triggers FE-65033 routing. + assertNull(response.getErrorMessage()); + assertNull(response.getErrorDescription()); + } + + // ========================= execute — INCOMPLETE ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteWithoutRedirectUrlReturnsError() throws Exception { + + // INCOMPLETE without a stashed redirect URL is a contract violation — + // the response processor should normally have thrown, but the executor + // defends against it as well by returning STATUS_ERROR with a clear message. + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(incompleteStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "Extension returned INCOMPLETE without a redirect URL."); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteWithRedirectUrlReturnsExternalRedirection() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + // Set a context identifier so the OTFI collision-guard has something to compare against. + context.setContextIdentifier("original-flow-id"); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + // Simulate the response processor stashing the redirect URL into the FlowContext + // during the actionExecutorService.execute call. + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, + "https://example.com/step-up"); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + + // OTFI must be set on contextProperties so FlowExecutionService can swap caches on resume. + Map ctxProps = response.getContextProperties(); + assertNotNull(ctxProps); + Object otfi = ctxProps.get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); + assertNotNull(otfi); + assertTrue(otfi instanceof String); + assertFalse(((String) otfi).isEmpty()); + // OTFI must not collide with the original context identifier. + assertFalse("original-flow-id".equals(otfi)); + + // Redirect URL must carry the OTFI as a flowId query parameter. + Map additionalInfo = response.getAdditionalInfo(); + assertNotNull(additionalInfo); + String redirectUrl = additionalInfo.get( + org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); + assertNotNull(redirectUrl); + assertEquals(redirectUrl, "https://example.com/step-up?flowId=" + otfi); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteRedirectAppendsFlowIdWithAmpersandWhenUrlHasQuery() + throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + context.setContextIdentifier("original-flow-id"); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + // URL already has a query string — the executor must use & not ?. + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, + "https://example.com/step-up?ref=abc"); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + String otfi = (String) response.getContextProperties() + .get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); + String redirectUrl = response.getAdditionalInfo() + .get(org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); + assertEquals(redirectUrl, "https://example.com/step-up?ref=abc&flowId=" + otfi); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteRedirectEmptyUrlReturnsError() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, ""); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + // Empty URL is treated the same as missing — defensive STATUS_ERROR. + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "Extension returned INCOMPLETE without a redirect URL."); + } + + // ========================= execute — null status ========================= + + @Test + public void testExecuteNullStatus() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(null); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= execute — exception ========================= + + @Test + public void testExecuteActionException() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.execute( + eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenThrow(new ActionExecutionException("Connection timeout")); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "An error occurred while processing the extension. Please try again."); + } + + // ========================= execute — service unavailable ========================= + + @Test(expectedExceptions = org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException.class) + public void testExecuteServiceUnavailable() throws Exception { + + // Override holder mock to return null service. + holderMock.close(); + + InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(null); + + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + // Should throw FlowEngineException since service is unavailable. + executor.execute(context); + } + + // ========================= execute — no node config ========================= + + @Test + public void testExecuteNoNodeConfig() throws Exception { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + // No current node set → getMetadataValue returns null. + + ExecutorResponse response = executor.execute(context); + + // actionId is null → missing configuration → ERROR. + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + @Test + public void testExecuteNoExecutorDTO() throws Exception { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + + NodeConfig nodeConfig = new NodeConfig.Builder().build(); + context.setCurrentNode(nodeConfig); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= Helper methods ========================= + + private FlowExecutionContext createContextWithMetadata(Map metadata) { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + + ExecutorDTO executorDTO = new ExecutorDTO("InFlowExtensionExecutor", metadata); + NodeConfig nodeConfig = new NodeConfig.Builder() + .executorConfig(executorDTO) + .build(); + context.setCurrentNode(nodeConfig); + + return context; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java new file mode 100644 index 000000000000..489687ed4783 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java @@ -0,0 +1,811 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.flow.inflow.extensions.model.*; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +/** + * Unit tests for {@link InFlowExtensionRequestBuilder}. + */ +public class InFlowExtensionRequestBuilderTest { + + private InFlowExtensionRequestBuilder requestBuilder; + private MockedStatic identityTenantUtilMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + requestBuilder = new InFlowExtensionRequestBuilder(); + identityTenantUtilMock = mockStatic(IdentityTenantUtil.class); + identityTenantUtilMock.when(() -> IdentityTenantUtil.getTenantId(anyString())).thenReturn(1); + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + } + + @AfterMethod + public void tearDown() { + + loggerUtilsMock.close(); + identityTenantUtilMock.close(); + } + + // ========================= getSupportedActionType ========================= + + @Test + public void testGetSupportedActionType() { + + assertEquals(requestBuilder.getSupportedActionType(), ActionType.IN_FLOW_EXTENSION); + } + + // ========================= buildActionExecutionRequest — basics ========================= + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) + public void testBuildRequestThrowsWhenFlowExecutionContextMissing() + throws ActionExecutionRequestBuilderException { + + FlowContext flowContext = FlowContext.create(); + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + } + + @Test + public void testBuildRequestWithMinimalContext() throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + assertNotNull(request); + assertEquals(request.getActionType(), ActionType.IN_FLOW_EXTENSION); + assertNotNull(request.getEvent()); + } + + @Test + public void testBuildRequestUsesEmptyExposeWhenExposeIsNull() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + // With empty expose, no context areas should be included in the event. + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event); + assertNull(event.getFlowType()); + // flowProperties defaults to emptyMap() in the builder — verify it is empty. + assertTrue(event.getFlowProperties().isEmpty()); + } + + // ========================= buildAllowedOperations (from modify) ========================= + + @Test + public void testBuildRequestWithValidModifyPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + // REPLACE + REDIRECT (REDIRECT is always advertised). + assertEquals(ops.size(), 2); + AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); + assertNotNull(replaceOp, "REPLACE should be present when modify paths are configured"); + assertTrue(replaceOp.getPaths().contains("/properties/riskScore")); + assertNotNull(findOperation(ops, Operation.REDIRECT), + "REDIRECT should always be present"); + } + + @Test + public void testBuildRequestWithNoAccessConfig() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + // No action → no access config → no modify paths → only REDIRECT (always present). + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + assertEquals(ops.size(), 1); + assertEquals(ops.get(0).getOp(), Operation.REDIRECT); + } + + @Test + public void testBuildRequestWithNonInFlowActionFallsBackToMinimalPayload() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + when(reqCtx.getAction()).thenReturn(mock(Action.class)); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + assertNotNull(request); + assertEquals(request.getActionType(), ActionType.IN_FLOW_EXTENSION); + assertEquals(request.getAllowedOperations().size(), 1); + assertEquals(request.getAllowedOperations().get(0).getOp(), Operation.REDIRECT); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event); + assertEquals(event.getFlowId(), execCtx.getContextIdentifier()); + assertNull(event.getUser()); + assertNull(event.getFlowType()); + } + + @Test + public void testBuildRequestWithEmptyModifyPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + // No modify paths → only REDIRECT. + assertEquals(ops.size(), 1); + assertEquals(ops.get(0).getOp(), Operation.REDIRECT); + } + + // ========================= REDIRECT always advertised ========================= + + @Test + public void testRedirectIsAdvertisedAlongsideReplace() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); + assertNotNull(redirectOp); + // REDIRECT does not target a path — paths must be null or empty. + assertTrue(redirectOp.getPaths() == null || redirectOp.getPaths().isEmpty(), + "REDIRECT must not carry any paths"); + } + + @Test + public void testRedirectIsAdvertisedWhenNoAccessConfig() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + // REDIRECT must be available even without any modify config so extensions can always + // signal mid-flow redirects. + AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); + assertNotNull(redirectOp); + } + + // ========================= Path annotation stripping ========================= + + @Test + @SuppressWarnings("unchecked") + public void testPathAnnotationStrippingSimpleArray() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskFactors{[String]}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // AllowedOperation should have clean path (without {[String]}). + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertTrue(op.getPaths().contains("/properties/riskFactors")); + assertFalse(op.getPaths().contains("/properties/riskFactors{[String]}")); + + // Annotations should be stored in FlowContext. + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertNotNull(annotations); + assertEquals(annotations.get("/properties/riskFactors"), "[String]"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPathAnnotationStrippingSchemaAnnotation() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/items{name: String, count: Integer}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertTrue(op.getPaths().contains("/properties/items")); + assertFalse(op.getPaths().contains("/properties/items{name: String, count: Integer}")); + + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPathWithoutAnnotationNotStoredInAnnotationsMap() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, null)); + + // No annotations should be stored when paths have no annotations. + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertNull(annotations); + } + + @Test + @SuppressWarnings("unchecked") + public void testMultipleAnnotatedPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/properties/riskFactors{[String]}", false), + new ContextPath("/properties/items{name: String, count: Integer}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertEquals(op.getPaths().size(), 3); + assertTrue(op.getPaths().contains("/properties/riskScore")); + assertTrue(op.getPaths().contains("/properties/riskFactors")); + assertTrue(op.getPaths().contains("/properties/items")); + + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertEquals(annotations.size(), 2); + assertEquals(annotations.get("/properties/riskFactors"), "[String]"); + assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); + } + + // ========================= Expose and modify independence ========================= + + @Test + public void testModifyPathsDoNotAffectExpose() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose only /input/ and /flow/ — /properties/ is NOT exposed. + // Modify path targets /properties/riskScore — but this should NOT auto-expose it. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/input/", false), + new ContextPath("/flow/", false)), + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + execCtx.setProperty("riskScore", "50"); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // Modify paths should produce a REPLACE allowed op alongside the always-present REDIRECT. + List ops = request.getAllowedOperations(); + assertEquals(ops.size(), 2); + assertNotNull(findOperation(ops, Operation.REPLACE)); + assertNotNull(findOperation(ops, Operation.REDIRECT)); + + // Properties should NOT be in event — expose and modify are independent. + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertTrue(event.getFlowProperties() == null || event.getFlowProperties().isEmpty()); + } + + @Test + public void testMultipleModifyPathsProduceSingleReplaceOperation() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/properties/riskLevel", false), + new ContextPath("/user/claims/http://wso2.org/claims/email", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // All modify paths grouped into a single REPLACE op (REDIRECT is always added too). + List ops = request.getAllowedOperations(); + assertEquals(ops.size(), 2); + AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); + assertNotNull(replaceOp); + assertEquals(replaceOp.getPaths().size(), 3); + assertNotNull(findOperation(ops, Operation.REDIRECT)); + } + + // ========================= Expose filtering ========================= + + @Test + public void testExposeFilteringOnlyExposedAreaIncluded() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Only expose /flow/ — no user, no properties, no input. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/tenantDomain", false), + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + // User should NOT be in the event since /user/ is not exposed. + assertNull(event.getUser()); + // Flow type should be present. + assertNotNull(event.getFlowType()); + // Tenant should be present. + assertNotNull(event.getTenant()); + } + + @Test + public void testFlowPortalUrlExposed() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/portalUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getPortalUrl(), "https://localhost:9443/accounts/recovery"); + } + + @Test + public void testFlowPortalUrlNotExposedYieldsNull() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); + + // /flow/portalUrl NOT in expose list — must be omitted from the event even when + // the context has a value, mirroring the rest of the expose-gated paths. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNull(event.getPortalUrl()); + } + + @Test + public void testFlowCallbackUrlExposed() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setCallbackUrl("https://example.com/callback"); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/callbackUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getCallbackUrl(), "https://example.com/callback"); + } + + @Test + public void testExposeFilteringSpecificClaim() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Only expose a specific claim. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", false), + new ContextPath("/user/userId", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getUser()); + // Only the email claim should be present, not the country claim. + List claims = event.getUser().getClaims(); + assertEquals(claims.size(), 1); + } + + // ========================= Outbound encryption of properties and inputs ========================= + + @Test + public void testPropertiesEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + // riskScore is expose-encrypted; existingProp is exposed plaintext. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/properties/riskScore", true), + new ContextPath("/properties/existingProp", false)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + // riskScore should be encrypted. + Object riskScoreValue = event.getFlowProperties().get("riskScore"); + assertNotNull(riskScoreValue); + assertTrue(riskScoreValue.toString().startsWith("encrypted."), + "Property value should be encrypted when expose path is marked encrypted"); + // existingProp is NOT marked encrypted — should remain plaintext. + assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); + } + } + + @Test + public void testClaimEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + + // email claim is expose-encrypted; country claim is exposed plaintext. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", true), + new ContextPath("/user/claims/http://wso2.org/claims/country", false)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getUser()); + List claims = event.getUser().getClaims(); + assertEquals(claims.size(), 2); + + org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = null; + org.wso2.carbon.identity.action.execution.api.model.UserClaim countryClaim = null; + for (Object c : claims) { + org.wso2.carbon.identity.action.execution.api.model.UserClaim uc = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) c; + if ("http://wso2.org/claims/email".equals(uc.getUri())) { + emailClaim = uc; + } else if ("http://wso2.org/claims/country".equals(uc.getUri())) { + countryClaim = uc; + } + } + + assertNotNull(emailClaim, "email claim should be present"); + assertTrue(emailClaim.getValue().toString().startsWith("encrypted."), + "email claim should be encrypted when expose path is marked encrypted"); + + assertNotNull(countryClaim, "country claim should be present"); + assertEquals(countryClaim.getValue().toString(), "US", + "country claim should remain plaintext when expose path is not marked encrypted"); + } + } + + @Test + public void testCredentialEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + Map creds = new java.util.HashMap<>(); + creds.put("password", "secret123".toCharArray()); + execCtx.getFlowUser().setUserCredentials(creds); + + // password credential is expose-encrypted. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList(new ContextPath("/user/credentials/password", true)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getUser()); + Map eventCreds = event.getUser().getUserCredentials(); + assertNotNull(eventCreds); + assertTrue(eventCreds.containsKey("password")); + String credValue = new String(eventCreds.get("password")); + assertTrue(credValue.startsWith("encrypted."), + "credential should be encrypted when expose path is marked encrypted"); + } + } + + @Test + public void testEncryptedExposePathIsOmittedWhenCertificateIsMissing() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/properties/riskScore", true), + new ContextPath("/properties/existingProp", false)), + null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + assertFalse(event.getFlowProperties().containsKey("riskScore"), + "Encrypted expose path should be omitted when outbound certificate is missing."); + assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); + } + + // ========================= Outbound encryption failure ========================= + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) + public void testEncryptionFailureThrowsException() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenThrow(new RuntimeException("Encryption failed")); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + AccessConfig accessConfig = new AccessConfig( + Arrays.asList(new ContextPath("/properties/riskScore", true)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, encryption)); + } + } + + // ========================= Helper methods ========================= + + /** + * Locate the first AllowedOperation in {@code ops} matching {@code op}, or null if absent. + */ + private AllowedOperation findOperation(List ops, Operation op) { + + if (ops == null) { + return null; + } + for (AllowedOperation candidate : ops) { + if (candidate.getOp() == op) { + return candidate; + } + } + return null; + } + + /** + * Create a mock ActionExecutionRequestContext whose getAction() returns an InFlowExtensionAction + * configured with the given access config and encryption. + */ + private ActionExecutionRequestContext mockReqCtx(AccessConfig accessConfig, Encryption encryption) { + + InFlowExtensionAction action = mock(InFlowExtensionAction.class); + when(action.resolveAccessConfig(any())).thenReturn(accessConfig); + when(action.getEncryption()).thenReturn(encryption); + when(action.getName()).thenReturn("Test Action"); + ActionExecutionRequestContext ctx = mock(ActionExecutionRequestContext.class); + when(ctx.getAction()).thenReturn(action); + return ctx; + } + + private FlowExecutionContext createMinimalFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-correlation-id"); + + NodeConfig node = new NodeConfig.Builder() + .id("node1") + .type("EXECUTION") + .build(); + context.setCurrentNode(node); + + return context; + } + + private FlowExecutionContext createFullFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-correlation-id"); + context.setApplicationId("app-123"); + context.setFlowType("REGISTRATION"); + + NodeConfig node = new NodeConfig.Builder() + .id("execution_node_1") + .type("EXECUTION") + .build(); + context.setCurrentNode(node); + + FlowUser flowUser = new FlowUser(); + flowUser.setUserId("user-456"); + flowUser.setUsername("testuser"); + flowUser.setUserStoreDomain("PRIMARY"); + flowUser.addClaim("http://wso2.org/claims/email", "test@example.com"); + flowUser.addClaim("http://wso2.org/claims/country", "US"); + context.setFlowUser(flowUser); + + context.addUserInputData("username", "testuser"); + context.addUserInputData("consent", "true"); + + context.setProperty("existingProp", "existingValue"); + + return context; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java new file mode 100644 index 000000000000..8a698ac866f5 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java @@ -0,0 +1,999 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Incomplete; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionResponseProcessor}. + */ +public class InFlowExtensionResponseProcessorTest { + + private InFlowExtensionResponseProcessor responseProcessor; + private MockedStatic loggerUtilsMock; + private MockedStatic holderMock; + private InFlowExtensionDataHolder holderInstance; + private FlowContext capturedFlowContext; + + @BeforeMethod + public void setUp() throws Exception { + + responseProcessor = new InFlowExtensionResponseProcessor(); + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + + holderInstance = mock(InFlowExtensionDataHolder.class); + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + } + + @AfterMethod + public void tearDown() { + + holderMock.close(); + loggerUtilsMock.close(); + capturedFlowContext = null; + } + + // ========================= getSupportedActionType ========================= + + @Test + public void testGetSupportedActionType() { + + assertEquals(responseProcessor.getSupportedActionType(), ActionType.IN_FLOW_EXTENSION); + } + + // ========================= processSuccessResponse — Property REPLACE ========================= + + @Test + public void testPropertyReplaceFlatExists() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("riskScore", "50"); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "80"); + } + + @Test + public void testPropertyReplaceCreatesIfMissing() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + // riskScore not set — REPLACE should auto-create it. + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "80"); + } + + @Test + public void testPropertyReplaceCoercesToStringByDefault() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Integer value with no annotation → coerced to String. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", 75); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "75"); + } + + @Test + public void testPropertyReplaceWithMultivaluedAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[String]"); + + List factors = Arrays.asList("ip_mismatch", "new_device"); + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskFactors", factors); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + Object stored = pendingProps.get("riskFactors"); + assertTrue(stored instanceof List); + assertEquals(((List) stored).size(), 2); + } + + @Test + public void testPropertyReplaceMultivaluedSingleValueWrapped() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + // Single value with [String] annotation → wrapped in a list. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/tags", "singleTag"); + executeSuccessResponse(execCtx, replaceOp, annotations); + + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + Object stored = pendingProps.get("tags"); + assertTrue(stored instanceof List); + assertEquals(((List) stored).size(), 1); + assertEquals(((List) stored).get(0), "singleTag"); + } + + @Test + public void testPropertyReplaceWithComplexAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/item", "name: String, count: Integer"); + + Map item = new HashMap<>(); + item.put("name", "item1"); + item.put("count", 5); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/item", item); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + // Complex annotation → value passed through as-is. + Object stored = pendingProps.get("item"); + assertTrue(stored instanceof Map); + } + + @Test + public void testPropertyReplaceWithPrimaryTypeAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + // Integer annotation → coerced to String. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", 95); + executeSuccessResponse(execCtx, replaceOp, annotations); + + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("score"), "95"); + } + + @Test + public void testPropertyReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("score", "50"); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, replaceOp, Collections.emptyMap()); + + // Null value should fail for REPLACE. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Original value should remain. + assertEquals(execCtx.getProperties().get("score"), "50"); + } + + @Test + public void testPropertyReplaceEmptyPropertyName() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/", "value"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Empty property name should fail. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testPropertyReplaceComplexObjectInvalidSchema() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "risk: Float, factor: String"); + + // Value has an unknown attribute not in the schema. + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("unknown", "bad"); + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", value); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall (logged as failure for this operation) but property not set. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("data")); + } + + @Test + public void testPropertyReplaceArrayExceedsItemLimit() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + // Create a list with 11 items (exceeds max 10). + List bigList = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/tags", bigList); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall but property not set due to array limit. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("tags")); + } + + @Test + public void testPropertyReplaceComplexArrayExceedsItemLimit() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float]"); + + List> items = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + Map item = new HashMap<>(); + item.put("risk", (float) i); + items.add(item); + } + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/risks", items); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall but property not set due to item limit on complex array. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("risks")); + } + + // ========================= processSuccessResponse — User claim REPLACE ========================= + + @Test + public void testUserClaimReplace() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.getFlowUser().addClaim("http://wso2.org/claims/email", "old@example.com"); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", "new@example.com"); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/email"), "new@example.com"); + } + + @Test + public void testUserClaimReplaceCreatesNewClaim() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/country", "US"); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "US"); + } + + @Test + public void testUserClaimReplaceStringifiesValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Numeric value should be stringified. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/country", 42); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "42"); + } + + @Test + public void testUserClaimReplaceIdentityClaimRejected() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Identity claim should be rejected by the identity-claim prefix guard. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/identity/accountLocked", "true"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNonExistentClaimRejected() + throws ActionExecutionResponseProcessorException, ClaimMetadataException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Mock ClaimMetadataManagementService to return null for an unknown claim URI. + ClaimMetadataManagementService claimService = mock(ClaimMetadataManagementService.class); + when(holderInstance.getClaimMetadataManagementService()).thenReturn(claimService); + when(claimService.getLocalClaim("http://wso2.org/claims/nonexistent", "carbon.super")) + .thenReturn(java.util.Optional.empty()); + + // Claim not registered in the system should be rejected. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, + "/user/claims/http://wso2.org/claims/nonexistent", "value"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNonLocalDialectRejected() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Non-local dialect claim should be rejected by the local-dialect prefix guard. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, + "/user/claims/urn:ietf:params:scim:schemas:core:2.0:User:name.givenName", "John"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Operation should fail — null value. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getFlowUser().getClaims().get("http://wso2.org/claims/email")); + } + + @Test + public void testUserClaimReplaceEmptyClaimUri() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation(Operation.REPLACE, "/user/claims/", "value"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Should fail — empty claim URI. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testUserClaimReplaceNoFlowUser() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setFlowUser(null); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", "test@email.com"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Should fail — no FlowUser. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processSuccessResponse — User input REPLACE ========================= + + @Test + public void testUserInputReplace() throws ActionExecutionResponseProcessorException { + + // /input/ paths are no longer a modify target; operations on them are silently ignored. + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.addUserInputData("consent", "false"); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); + + // Operation succeeds overall but /input/ is not a modify target — input data unchanged. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertEquals(execCtx.getUserInputData().get("consent"), "false"); + } + + @Test + public void testUserInputReplaceCreatesNew() throws ActionExecutionResponseProcessorException { + + // /input/ paths are no longer a modify target; operations on them are silently ignored. + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); + + // Operation succeeds overall but /input/ is not a modify target — value is not created. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getUserInputData().get("consent")); + } + + @Test + public void testUserInputReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.addUserInputData("consent", "true"); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, inputOp, Collections.emptyMap()); + + // Null value should fail. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Original value should remain. + assertEquals(execCtx.getUserInputData().get("consent"), "true"); + } + + // ========================= processSuccessResponse — Read-only paths ========================= + + @Test + public void testReadOnlyFlowPath() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/flow/tenantDomain", "newValue"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Operation should fail but overall status is SUCCESS. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testReadOnlyGraphPath() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/graph/currentNode/id", "newId"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processSuccessResponse — Unknown path ========================= + + @Test + public void testUnknownPathPrefix() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/unknown/path", "value"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Unknown path → operation fails, but overall status is SUCCESS. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testNullPathOperationFails() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Operation with null path must fail gracefully without NPE. + PerformableOperation op = new PerformableOperation(); + op.setOp(Operation.REPLACE); + // path is intentionally not set (null). + + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Nothing should be staged in any pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); + } + + @Test + public void testUnsupportedOperationTypeOnValidPathFails() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // ADD (non-REPLACE) on a valid properties path must be rejected before routing. + PerformableOperation op = new PerformableOperation(); + op.setOp(Operation.ADD); + op.setPath("/properties/riskScore"); + op.setValue("90"); + + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Property must NOT be staged. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + } + + // ========================= processSuccessResponse — Multiple operations ========================= + + @Test + public void testMultipleOperationsMixedResults() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("existingProp", "old"); + + List operations = new ArrayList<>(); + operations.add(createOperation(Operation.REPLACE, "/properties/newProp", "newValue")); + operations.add(createOperation(Operation.REPLACE, "/properties/existingProp", "updated")); + operations.add(createOperation(Operation.REPLACE, "/flow/readonly", "fail")); // This should fail. + + ActionExecutionStatus status = executeSuccessResponse(execCtx, operations, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("newProp"), "newValue"); + assertEquals(pendingProps.get("existingProp"), "updated"); + } + + @Test + public void testEmptyOperations() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + ActionExecutionStatus status = executeSuccessResponse( + execCtx, Collections.emptyList(), Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processFailureResponse ========================= + + @Test + public void testProcessFailureResponse() throws ActionExecutionResponseProcessorException { + + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk_detected"); + when(failureResponse.getFailureDescription()).thenReturn("Risk score exceeds threshold"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.FAILED); + assertNotNull(status.getResponse()); + assertEquals(status.getResponse().getFailureReason(), "high_risk_detected"); + assertEquals(status.getResponse().getFailureDescription(), "Risk score exceeds threshold"); + } + + // ========================= processFailureResponse — full-key pass-through ========================= + + @Test + public void testProcessFailureResponseWithFullKeyPassesThrough() + throws ActionExecutionResponseProcessorException { + + // Extension now sends the full i18n key directly — processor must pass it through unchanged. + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk"); + when(failureResponse.getFailureDescription()) + .thenReturn("inflow.extension.risk.assessment.extension.user.access.denied"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + // Full key passes through unchanged — no wrapping or prefixing. + assertEquals(status.getResponse().getFailureDescription(), + "inflow.extension.risk.assessment.extension.user.access.denied"); + assertEquals(status.getResponse().getFailureReason(), "high_risk"); + } + + @Test + public void testProcessFailureResponseWithShortKeyPassesThrough() + throws ActionExecutionResponseProcessorException { + + // Short dot-separated key also passes through as-is — IS no longer adds a prefix. + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk"); + when(failureResponse.getFailureDescription()).thenReturn("user.access.denied"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + assertEquals(status.getResponse().getFailureDescription(), "user.access.denied"); + assertEquals(status.getResponse().getFailureReason(), "high_risk"); + } + + // ========================= processErrorResponse ========================= + + @Test + public void testProcessErrorResponse() throws ActionExecutionResponseProcessorException { + + ActionInvocationErrorResponse errorResponse = mock(ActionInvocationErrorResponse.class); + when(errorResponse.getErrorMessage()).thenReturn("internal_error"); + when(errorResponse.getErrorDescription()).thenReturn("Database connection failed"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(errorResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processErrorResponse( + flowContext, responseContext); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.ERROR); + assertNotNull(status.getResponse()); + assertEquals(status.getResponse().getErrorMessage(), "internal_error"); + assertEquals(status.getResponse().getErrorDescription(), "Database connection failed"); + } + + // ========================= processIncompleteResponse ========================= + + @Test + public void testProcessIncompleteResponseWithRedirectStashesUrlAndReturnsIncomplete() + throws ActionExecutionResponseProcessorException { + + PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); + + FlowContext flowContext = FlowContext.create(); + ActionExecutionStatus status = executeIncompleteResponse( + flowContext, Collections.singletonList(redirect)); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); + // URL must be stashed under the key the executor reads. + assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), + "https://example.com/step-up"); + } + + @Test + public void testProcessIncompleteResponseIgnoresNonRedirectOperations() + throws ActionExecutionResponseProcessorException { + + // Contract: REPLACE ops sent alongside REDIRECT on an INCOMPLETE response are dropped + // — the extension is expected to resend them on the resume call. + PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); + PerformableOperation replace = createOperation( + Operation.REPLACE, "/properties/riskScore", "42"); + + FlowContext flowContext = FlowContext.create(); + ActionExecutionStatus status = executeIncompleteResponse( + flowContext, Arrays.asList(redirect, replace)); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); + // Redirect URL is captured, but the REPLACE must NOT have produced any pending props. + assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), + "https://example.com/step-up"); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithoutRedirectThrows() + throws ActionExecutionResponseProcessorException { + + // INCOMPLETE without a REDIRECT op is a contract violation. + PerformableOperation replace = createOperation( + Operation.REPLACE, "/properties/riskScore", "42"); + + executeIncompleteResponse(FlowContext.create(), Collections.singletonList(replace)); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithEmptyOperationsThrows() + throws ActionExecutionResponseProcessorException { + + executeIncompleteResponse(FlowContext.create(), Collections.emptyList()); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithNullOperationsThrows() + throws ActionExecutionResponseProcessorException { + + executeIncompleteResponse(FlowContext.create(), null); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithEmptyRedirectUrlThrows() + throws ActionExecutionResponseProcessorException { + + PerformableOperation emptyRedirect = createRedirectOperation(""); + + executeIncompleteResponse(FlowContext.create(), Collections.singletonList(emptyRedirect)); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeIncompleteResponse( + FlowContext flowContext, List operations) + throws ActionExecutionResponseProcessorException { + + ActionInvocationIncompleteResponse incompleteResponse = + mock(ActionInvocationIncompleteResponse.class); + when(incompleteResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(incompleteResponse); + + return responseProcessor.processIncompleteResponse(flowContext, responseContext); + } + + // ========================= Helper methods ========================= + + private FlowExecutionContext createFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-id"); + + FlowUser flowUser = new FlowUser(); + flowUser.setUserId("user-1"); + flowUser.setUsername("testuser"); + context.setFlowUser(flowUser); + + return context; + } + + private PerformableOperation createOperation(Operation op, String path, Object value) { + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(op); + operation.setPath(path); + if (value != null) { + operation.setValue(value); + } + return operation; + } + + private PerformableOperation createRedirectOperation(String url) { + + // PerformableOperation rejects setPath/setValue for REDIRECT and rejects setUrl for + // every other op — REDIRECT carries its target in the dedicated `url` field. + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REDIRECT); + operation.setUrl(url); + return operation; + } + + private ActionExecutionStatus executeSuccessResponse( + FlowExecutionContext execCtx, PerformableOperation operation, + Map pathTypeAnnotations) + throws ActionExecutionResponseProcessorException { + + return executeSuccessResponse(execCtx, Collections.singletonList(operation), pathTypeAnnotations); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeSuccessResponse( + FlowExecutionContext execCtx, List operations, + Map pathTypeAnnotations) + throws ActionExecutionResponseProcessorException { + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + + capturedFlowContext = flowContext; + + ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); + when(successResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); + + return responseProcessor.processSuccessResponse(flowContext, responseContext); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeSuccessResponseWithModifyPaths( + FlowExecutionContext execCtx, List operations, + Map pathTypeAnnotations, List modifyPaths) + throws ActionExecutionResponseProcessorException { + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + if (modifyPaths != null) { + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); + } + + capturedFlowContext = flowContext; + + ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); + when(successResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); + + return responseProcessor.processSuccessResponse(flowContext, responseContext); + } + + // ========================= Inbound decryption failure ========================= + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testDecryptionFailureThrowsException() + throws ActionExecutionResponseProcessorException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(true); + jweUtilMock.when(() -> JWEEncryptionUtil.decrypt(anyString(), anyString())) + .thenThrow(new RuntimeException("Decryption failed")); + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); + + // Value looks like JWE (5 dot-separated parts). + PerformableOperation op = createOperation( + Operation.REPLACE, "/properties/secret", "a.b.c.d.e"); + + executeSuccessResponseWithModifyPaths(execCtx, Collections.singletonList(op), + Collections.emptyMap(), modifyPaths); + } + } + + @Test + public void testNonStringValueForEncryptedPathThrowsException() { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/data", true)); + + // Non-string value (Integer) for an encrypted path must now be rejected with an exception + // because accepting non-JWE values would make the encryption flag advisory. + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", 42); + + try { + executeSuccessResponseWithModifyPaths( + execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); + throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); + } catch (ActionExecutionResponseProcessorException e) { + assertTrue(e.getMessage().contains("/properties/data"), + "Exception message should reference the offending path."); + } + } + + @Test + public void testPlaintextStringForEncryptedPathThrowsException() { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); + + // Plain-text string (not JWE-formatted) for an encrypted modify path must be rejected. + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/secret", "plaintext-value"); + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(false); + try { + executeSuccessResponseWithModifyPaths( + execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); + throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); + } catch (ActionExecutionResponseProcessorException e) { + assertTrue(e.getMessage().contains("/properties/secret"), + "Exception message should reference the offending path."); + } + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java new file mode 100644 index 000000000000..409dc05acbfd --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; + +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link PathTypeAnnotationUtil}. + */ +public class PathTypeAnnotationUtilTest { + + // ========================= stripAnnotation ========================= + + @Test + public void testStripAnnotationPrimaryType() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactor{String}"); + assertEquals(result[0], "/properties/riskFactor"); + assertEquals(result[1], "String"); + } + + @Test + public void testStripAnnotationMultivaluedPrimary() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactors{[String]}"); + assertEquals(result[0], "/properties/riskFactors"); + assertEquals(result[1], "[String]"); + } + + @Test + public void testStripAnnotationComplexObject() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation( + "/properties/risk{risk: Float, factor: String}"); + assertEquals(result[0], "/properties/risk"); + assertEquals(result[1], "risk: Float, factor: String"); + } + + @Test + public void testStripAnnotationMultivaluedComplex() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation( + "/properties/risks{[risk: Float, factor: String]}"); + assertEquals(result[0], "/properties/risks"); + assertEquals(result[1], "[risk: Float, factor: String]"); + } + + @Test + public void testStripAnnotationNoAnnotation() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskScore"); + assertEquals(result[0], "/properties/riskScore"); + assertNull(result[1]); + } + + @Test + public void testStripAnnotationNullInput() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation(null); + assertNull(result[0]); + assertNull(result[1]); + } + + @Test + public void testStripAnnotationEmptyBraces() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/field{}"); + assertEquals(result[0], "/properties/field"); + assertEquals(result[1], ""); + } + + @Test + public void testStripAnnotationIntegerType() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/count{Integer}"); + assertEquals(result[0], "/properties/count"); + assertEquals(result[1], "Integer"); + } + + // ========================= coerceValue ========================= + + @Test + public void testCoerceValueNoAnnotation() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", 42, Collections.emptyMap()); + assertEquals(result, "42"); + } + + @Test + public void testCoerceValuePrimaryTypeAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", 95, annotations); + assertEquals(result, "95"); + } + + @Test + public void testCoerceValueMultivaluedPrimaryList() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List input = Arrays.asList("tag1", "tag2", "tag3"); + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + } + + @Test + public void testCoerceValueMultivaluedPrimarySingleValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", "singleTag", annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 1); + assertEquals(list.get(0), "singleTag"); + } + + @Test + public void testCoerceValueComplexObjectAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map complexValue = new HashMap<>(); + complexValue.put("risk", 0.85); + complexValue.put("factor", "ip_mismatch"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/risk", complexValue, annotations); + // Complex annotation — passed through as-is. + assertTrue(result instanceof Map); + assertEquals(result, complexValue); + } + + @Test + public void testCoerceValueComplexArrayAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + List> items = Arrays.asList( + new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, + new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} + ); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/risks", items, annotations); + // Complex array annotation — passed through as-is. + assertTrue(result instanceof List); + assertEquals(result, items); + } + + @Test + public void testCoerceValueBooleanPrimaryType() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/active", "Boolean"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/active", true, annotations); + assertEquals(result, "true"); + } + + @Test + public void testCoerceValueStringValue() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/name", "test", Collections.emptyMap()); + assertEquals(result, "test"); + } + + @Test + public void testCoerceValueNoAnnotationNullValue() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", null, Collections.emptyMap()); + assertNull(result); + } + + @Test + public void testCoerceValuePrimaryTypeAnnotationNullValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", null, annotations); + assertNull(result); + } + + @Test + public void testCoerceValueMultivaluedPrimaryPreservesNullElements() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List input = Arrays.asList("tag1", null, "tag3"); + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + assertNull(list.get(1)); + assertEquals(list.get(2), "tag3"); + } + + // ========================= validateAnnotationLimits ========================= + + @Test + public void testValidateAnnotationLimitsNull() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(null)); + } + + @Test + public void testValidateAnnotationLimitsEmpty() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("")); + } + + @Test + public void testValidateAnnotationLimitsPrimaryType() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("String")); + } + + @Test + public void testValidateAnnotationLimitsPrimaryArray() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[String]")); + } + + @Test + public void testValidateAnnotationLimitsComplexWithinLimit() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("risk: Float, factor: String")); + } + + @Test + public void testValidateAnnotationLimitsComplexArrayWithinLimit() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[risk: Float, factor: String]")); + } + + @Test + public void testValidateAnnotationLimitsExceedsMax() { + + // Build an annotation with 11 attributes (exceeds MAX_ATTRIBUTES_PER_OBJECT = 10). + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 11; i++) { + if (i > 0) sb.append(", "); + sb.append("attr").append(i).append(": String"); + } + assertFalse(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); + } + + @Test + public void testValidateAnnotationLimitsExactlyAtMax() { + + // Build an annotation with exactly 10 attributes. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10; i++) { + if (i > 0) sb.append(", "); + sb.append("attr").append(i).append(": String"); + } + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); + } + + // ========================= validateValueAgainstAnnotation ========================= + + @Test + public void testValidateValueNoAnnotation() { + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/score", 42, Collections.emptyMap())); + } + + @Test + public void testValidateValuePrimaryAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/score", 42, annotations)); + } + + @Test + public void testValidateValueComplexObjectValid() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("factor", "ip_mismatch"); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectUnknownAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("unknown", "bad"); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectNotAMap() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", "not a map", annotations)); + } + + @Test + public void testValidateValueComplexObjectNestedMap() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map nested = new HashMap<>(); + nested.put("deep", "value"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("factor", nested); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexArrayValid() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + List> items = Arrays.asList( + new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, + new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} + ); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", items, annotations)); + } + + @Test + public void testValidateValueComplexArrayExceedsItemLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float]"); + + List> items = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + Map item = new HashMap<>(); + item.put("risk", (float) i); + items.add(item); + } + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", items, annotations)); + } + + @Test + public void testValidateValueComplexObjectWithArrayAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "tags: String[], score: Float"); + + Map value = new HashMap<>(); + value.put("tags", Arrays.asList("a", "b", "c")); + value.put("score", 0.9); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/data", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectArrayAttrExceedsLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "tags: String[]"); + + List bigList = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + Map value = new HashMap<>(); + value.put("tags", bigList); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/data", value, annotations)); + } + + // ========================= enforceArrayItemLimit ========================= + + @Test + public void testEnforceArrayItemLimitNoAnnotation() { + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/score", Arrays.asList(1, 2, 3), Collections.emptyMap())); + } + + @Test + public void testEnforceArrayItemLimitWithinLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", Arrays.asList("a", "b", "c"), annotations)); + } + + @Test + public void testEnforceArrayItemLimitExceedsLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List bigList = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + assertFalse(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", bigList, annotations)); + } + + @Test + public void testEnforceArrayItemLimitNonArrayAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/score", 42, annotations)); + } + + @Test + public void testEnforceArrayItemLimitNonListValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", "singleValue", annotations)); + } + + // ========================= JSON string parsing ========================= + + @Test + public void testValidateValueComplexArrayFromJsonString() + throws Exception { + + // This is the exact failing case: after JWE decryption the value is a JSON string. + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); + + String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/riskFactors", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexArrayFromJsonStringMultipleItems() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + String jsonString = "[{\"risk\":0.5,\"factor\":\"ip_mismatch\"}" + + ",{\"risk\":0.8,\"factor\":\"high_risk_email\"}]"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexObjectFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexArrayFromJsonStringUnknownAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + // JSON string with an attribute not in the schema. + String jsonString = "[{\"risk\":0.5,\"unknown\":\"bad\"}]"; + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", jsonString, annotations)); + } + + @Test + public void testCoerceValueComplexArrayFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); + + String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/riskFactors", jsonString, annotations); + + // Should be parsed into a List, not returned as a plain string. + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 1); + assertTrue(list.get(0) instanceof Map); + } + + @Test + public void testCoerceValueMultivaluedPrimaryFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + String jsonString = "[\"tag1\",\"tag2\",\"tag3\"]"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/tags", jsonString, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + } + + @Test + public void testCoerceValueComplexObjectFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/risk", jsonString, annotations); + + // Should be parsed into a Map, not returned as a plain string. + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map map = (Map) result; + assertEquals(map.get("factor"), "ip_mismatch"); + } + + @Test + public void testCoerceValueNonJsonStringIsNotParsed() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + // A plain string with no JSON structure should be coerced to String normally. + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", "42", annotations); + assertEquals(result, "42"); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java new file mode 100644 index 000000000000..422bf15a7ef6 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionTestUtils; + +import java.util.Set; + +/** + * Test-only helper that delegates to {@link InFlowExtensionTestUtils#configOf} to + * instantiate {@link FlowContextHandoverConfig} with explicit allow-lists. + */ +final class FlowContextHandoverConfigTestHelper { + + private FlowContextHandoverConfigTestHelper() { + + } + + static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { + + return InFlowExtensionTestUtils.configOf(attrs, userAttrs); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java new file mode 100644 index 000000000000..74c52d9003fd --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.metadata; + +import org.testng.annotations.Test; +import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionContextTreeBuilder}. + * + *

Uses {@link FlowContextHandoverConfigTestHelper} to construct configs with explicit + * allow-lists without touching IdentityConfigParser.

+ */ +public class InFlowExtensionContextTreeBuilderTest { + + // ========================= redirection always enabled ========================= + + @Test + public void testRedirectionAlwaysEnabled() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), + new HashSet<>(), null); + + assertTrue(meta.isRedirectionEnabled(), + "Redirection must always be true regardless of config"); + } + + // ========================= allowReadOnlyClaimsModification ========================= + + @Test + public void testAllowReadOnlyClaimsModificationForRegistration() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationForInvitedUserRegistration() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "INVITED_USER_REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForPasswordRecovery() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "PASSWORD_RECOVERY"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForUnknownFlowType() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "SOME_FUTURE_FLOW"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationTrueForNullFlowType() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + // ========================= flow branch ========================= + + /** + * With an empty allow-list the modifiable fields (claims, credentials, properties) are still + * present but carry only MODIFY — no EXPOSE. Read-only fields (flow branch, user read-only + * scalar) are absent because they have no modify path. + */ + @Test + public void testEmptyAllowListRestrictsExposeOnly() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + + // Modifiable nodes always present. + assertNotNull(findNode(meta, "user"), "user node must be present (has modifiable children)"); + assertNotNull(findNode(meta, "properties"), "properties node must be present (modifiable)"); + + // Read-only-only node absent when nothing configured. + assertNull(findNode(meta, "flow"), "flow node must be absent when no flow attrs configured"); + + // Properties has only MODIFY. + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must not have EXPOSE when not in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must have MODIFY regardless of allow-list"); + + // Claims and credentials carry only MODIFY. + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode, "claims always present"); + assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE"), + "claims must not have EXPOSE when not in allow-list"); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode, "credentials always present"); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), + "credentials must not have EXPOSE when not in allow-list"); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testFlowNodeAppearsWhenFlowAttrsPresent() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain", "flowType")), + new HashSet<>(), null); + + InFlowExtensionContextTreeNode flowNode = findNode(meta, "flow"); + assertNotNull(flowNode, "flow node should be present"); + + // Only the allowed attrs should appear as children. + List children = flowNode.getChildren(); + assertTrue(hasChildKey(children, "tenantDomain"), "tenantDomain expected"); + assertTrue(hasChildKey(children, "flowType"), "flowType expected"); + assertFalse(hasChildKey(children, "applicationId"), "applicationId not in allow-list"); + assertFalse(hasChildKey(children, "callbackUrl"), "callbackUrl not in allow-list"); + assertFalse(hasChildKey(children, "portalUrl"), "portalUrl not in allow-list"); + } + + @Test + public void testFlowNodeAbsentWhenNoFlowAttrsIncluded() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("username")), // only user attr + new HashSet<>(Arrays.asList("username")), null); + + assertNull(findNode(meta, "flow"), "flow node must not appear if no flow attrs present"); + } + + // ========================= user branch ========================= + + @Test + public void testReadOnlyUserFieldsAbsentWhenNoUserAttrsIncluded() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), + new HashSet<>(), null); + + // User node is always present (claims + credentials always included). + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node must be present"); + + List children = userNode.getChildren(); + + // Read-only user fields absent when not configured. + assertFalse(hasChildKey(children, "userId"), "userId must not appear"); + assertFalse(hasChildKey(children, "username"), "username must not appear"); + assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain must not appear"); + + // Modifiable fields still present. + assertTrue(hasChildKey(children, "claims"), "claims must always be present"); + assertTrue(hasChildKey(children, "credentials"), "credentials must always be present"); + } + + @Test + public void testUserNodeAppearsWithSelectedUserAttrs() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // no flowUser + new HashSet<>(Arrays.asList("username", "claims")), null); + + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node should be present"); + + List children = userNode.getChildren(); + + // Configured read-only field is exposed. + assertTrue(hasChildKey(children, "username"), "username expected"); + + // Claims configured → EXPOSE+MODIFY. + InFlowExtensionContextTreeNode claimsNode = findChildNode(children, "claims"); + assertNotNull(claimsNode, "claims must be present"); + assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE"), "claims must have EXPOSE"); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY"), "claims must have MODIFY"); + + // Read-only fields not configured → absent. + assertFalse(hasChildKey(children, "userId"), "userId not in allow-list"); + assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain not in allow-list"); + + // credentials not configured but always present → MODIFY only, no EXPOSE. + InFlowExtensionContextTreeNode credNode = findChildNode(children, "credentials"); + assertNotNull(credNode, "credentials always present"); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), + "credentials must not have EXPOSE when not in allow-list"); + assertTrue(credNode.getAllowedOperations().contains("MODIFY"), + "credentials must have MODIFY regardless"); + } + + @Test + public void testFullUserPassthroughShowsAllUserChildren() { + + // "flowUser" in context attrs → fullUserPassthrough → all user children present. + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("flowUser")), + new HashSet<>(), null); // includedUserAttributes empty — ignored in passthrough + + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node should be present with full passthrough"); + + List children = userNode.getChildren(); + assertTrue(hasChildKey(children, "userId"), "userId must appear in passthrough"); + assertTrue(hasChildKey(children, "username"), "username must appear in passthrough"); + assertTrue(hasChildKey(children, "userStoreDomain"),"userStoreDomain must appear in passthrough"); + assertTrue(hasChildKey(children, "claims"), "claims must appear in passthrough"); + assertTrue(hasChildKey(children, "credentials"), "credentials must appear in passthrough"); + } + + // ========================= properties branch ========================= + + @Test + public void testPropertiesHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // properties not in allow-list + new HashSet<>(), null); + + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertNotNull(propsNode, "properties node must always be present"); + assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must not expose when not in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must always allow MODIFY"); + } + + @Test + public void testPropertiesHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("properties")), + new HashSet<>(), null); + + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertNotNull(propsNode, "properties node should be present"); + assertTrue(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must have EXPOSE when in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must always have MODIFY"); + } + + @Test + public void testClaimsHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "claims" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testClaimsHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("claims")), null); + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "userCredentials" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("userCredentials")), null); + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode); + assertTrue(credNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testFullPassthroughGivesExposeOnClaimsAndCredentials() { + + // flowUser in attrs → full passthrough → all user fields exposed. + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("flowUser")), + new HashSet<>(), null); + + List userChildren = findNode(meta, "user").getChildren(); + assertTrue(findChildNode(userChildren, "claims").getAllowedOperations().contains("EXPOSE")); + assertTrue(findChildNode(userChildren, "credentials").getAllowedOperations().contains("EXPOSE")); + } + + // ========================= flowType metadata field ========================= + + @Test + public void testFlowTypeFieldPreserved() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + + assertEquals(meta.getFlowType(), "REGISTRATION"); + } + + @Test + public void testFlowTypeNullPreserved() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + + assertNull(meta.getFlowType()); + } + + // ========================= resolveAllowReadOnlyClaimsModification (static) ========================= + + @Test + public void testResolveAllowReadOnlyClaimsModificationDirectly() { + + assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification(null)); + assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification("REGISTRATION")); + assertTrue(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("INVITED_USER_REGISTRATION")); + assertFalse(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("PASSWORD_RECOVERY")); + assertFalse(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("UNKNOWN_TYPE")); + } + + // ========================= helpers ========================= + + private InFlowExtensionContextTreeMetadata buildWith(Set attrs, + Set userAttrs, + String flowType) { + + FlowContextHandoverConfig cfg = FlowContextHandoverConfigTestHelper.of(attrs, userAttrs); + return new InFlowExtensionContextTreeBuilder(cfg).build(flowType); + } + + private InFlowExtensionContextTreeNode findNode(InFlowExtensionContextTreeMetadata meta, + String key) { + + if (meta.getContextTree() == null) { + return null; + } + for (InFlowExtensionContextTreeNode node : meta.getContextTree()) { + if (key.equals(node.getKey())) { + return node; + } + } + return null; + } + + private boolean hasChildKey(List children, String key) { + + if (children == null) { + return false; + } + for (InFlowExtensionContextTreeNode child : children) { + if (key.equals(child.getKey())) { + return true; + } + } + return false; + } + + private InFlowExtensionContextTreeNode findChildNode(List children, + String key) { + + if (children == null) { + return null; + } + for (InFlowExtensionContextTreeNode child : children) { + if (key.equals(child.getKey())) { + return child; + } + } + return null; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java new file mode 100644 index 000000000000..47d30a127cf1 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link AccessConfig} and {@link ContextPath}. + */ +public class AccessConfigTest { + + @Test + public void testAccessConfigWithNullLists() { + + AccessConfig config = new AccessConfig(null, null); + assertNull(config.getExpose()); + assertNull(config.getModify()); + assertNotNull(config.getExposePaths()); + assertTrue(config.getExposePaths().isEmpty()); + assertNotNull(config.getModifyPaths()); + assertTrue(config.getModifyPaths().isEmpty()); + } + + @Test + public void testAccessConfigWithEmptyLists() { + + AccessConfig config = new AccessConfig(Collections.emptyList(), Collections.emptyList()); + assertNotNull(config.getExpose()); + assertTrue(config.getExpose().isEmpty()); + assertNotNull(config.getModify()); + assertTrue(config.getModify().isEmpty()); + assertNotNull(config.getExposePaths()); + assertTrue(config.getExposePaths().isEmpty()); + assertNotNull(config.getModifyPaths()); + assertTrue(config.getModifyPaths().isEmpty()); + } + + @Test + public void testGetExposePaths() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/", true), + new ContextPath("/user/credentials/", false) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + List paths = config.getExposePaths(); + assertEquals(paths.size(), 2); + assertEquals(paths.get(0), "/user/claims/"); + assertEquals(paths.get(1), "/user/credentials/"); + } + + @Test + public void testGetModifyPaths() { + + List modifyPaths = Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + List paths = config.getModifyPaths(); + assertEquals(paths.size(), 2); + assertEquals(paths.get(0), "/properties/riskScore"); + assertEquals(paths.get(1), "/user/claims/"); + } + + @Test + public void testIsExposePathEncryptedMatchesLongestPrefix() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/email", true), + new ContextPath("/user/username", false) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + assertTrue(config.isExposePathEncrypted("/user/claims/email")); + assertFalse(config.isExposePathEncrypted("/user/username")); + } + + @Test + public void testIsExposePathEncryptedNoMatch() { + + List exposePaths = Collections.singletonList( + new ContextPath("/user/claims/email", true) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + assertFalse(config.isExposePathEncrypted("/properties/riskScore")); + } + + @Test + public void testIsExposePathEncryptedWithNullExpose() { + + AccessConfig config = new AccessConfig(null, null); + assertFalse(config.isExposePathEncrypted("/user/claims/email")); + } + + @Test + public void testIsModifyPathEncryptedMatchesExactPath() { + + List modifyPaths = Arrays.asList( + new ContextPath("/user/username", false), + new ContextPath("/user/credentials/password", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + assertTrue(config.isModifyPathEncrypted("/user/credentials/password")); + assertFalse(config.isModifyPathEncrypted("/user/username")); + assertFalse(config.isModifyPathEncrypted("/user/credentials/other")); + } + + @Test + public void testIsModifyPathEncryptedWithNullModify() { + + AccessConfig config = new AccessConfig(null, null); + assertFalse(config.isModifyPathEncrypted("/user/credentials/password")); + } + + @Test + public void testIsModifyPathEncryptedWithAnnotatedPaths() { + + List modifyPaths = Arrays.asList( + new ContextPath("/properties/risk{risk: Float, factor: String}", true), + new ContextPath("/properties/tags{[String]}", false) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + // Clean operation path should match annotated modify path after stripping. + assertTrue(config.isModifyPathEncrypted("/properties/risk")); + assertFalse(config.isModifyPathEncrypted("/properties/tags")); + assertFalse(config.isModifyPathEncrypted("/properties/unknown")); + } + + @Test + public void testContextPathGetters() { + + ContextPath path = new ContextPath("/user/claims/email", true); + assertEquals(path.getPath(), "/user/claims/email"); + assertTrue(path.isEncrypted()); + + ContextPath unencrypted = new ContextPath("/properties/", false); + assertEquals(unencrypted.getPath(), "/properties/"); + assertFalse(unencrypted.isEncrypted()); + } + + @Test + public void testExposeListIsUnmodifiable() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + try { + config.getExpose().add(new ContextPath("/hack/", false)); + // If no exception thrown, fail the test + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void testModifyListIsUnmodifiable() { + + List modifyPaths = Arrays.asList( + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + try { + config.getModify().add(new ContextPath("/hack/", false)); + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java new file mode 100644 index 000000000000..97ad067bb1f3 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.testng.annotations.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionEvent}. + */ +public class InFlowExtensionEventTest { + + @Test + public void testBuilderWithAllFields() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("riskScore", 85); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowType("REGISTRATION") + .flowId("flow-id-123") + .callbackUrl("https://example.com/callback") + .portalUrl("https://example.com/portal") + .flowProperties(flowProperties) + .build(); + + assertEquals(event.getFlowType(), "REGISTRATION"); + assertEquals(event.getFlowId(), "flow-id-123"); + assertEquals(event.getCallbackUrl(), "https://example.com/callback"); + assertEquals(event.getPortalUrl(), "https://example.com/portal"); + assertEquals(event.getFlowProperties().get("riskScore"), 85); + } + + @Test + public void testOptionalFieldsDefaultToNull() { + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowType("LOGIN") + .flowId("flow-id-456") + .flowProperties(null) + .build(); + + assertEquals(event.getFlowType(), "LOGIN"); + assertEquals(event.getFlowId(), "flow-id-456"); + assertNull(event.getCallbackUrl()); + assertNull(event.getPortalUrl()); + assertNotNull(event.getFlowProperties()); + assertTrue(event.getFlowProperties().isEmpty()); + } + + @Test + public void testFlowPropertiesAreUnmodifiable() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("key", "value"); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowProperties(flowProperties) + .build(); + + try { + event.getFlowProperties().put("hack", "value"); + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected — map is unmodifiable + } + } + + @Test + public void testBuilderDoesNotShareMapReferences() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("score", "original"); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowProperties(flowProperties) + .build(); + + // Mutating the original map should not affect the event + flowProperties.put("score", "modified"); + assertEquals(event.getFlowProperties().get("score"), "original"); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java new file mode 100644 index 000000000000..8e1b3f1fa8e2 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.inflow.extensions.model; + +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +/** + * Unit tests for {@link OperationExecutionResult}. + */ +public class OperationExecutionResultTest { + + private PerformableOperation createOperation(Operation op, String path, Object value) { + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(op); + operation.setPath(path); + operation.setValue(value); + return operation; + } + + @Test + public void testSuccessResult() { + + PerformableOperation operation = createOperation( + Operation.REPLACE, "/user/claims/email", "test@example.com"); + OperationExecutionResult result = new OperationExecutionResult( + operation, OperationExecutionResult.Status.SUCCESS, "Claim updated"); + + assertEquals(result.getOperation(), operation); + assertEquals(result.getStatus(), OperationExecutionResult.Status.SUCCESS); + assertEquals(result.getMessage(), "Claim updated"); + } + + @Test + public void testFailureResult() { + + PerformableOperation operation = createOperation( + Operation.REPLACE, "/user/credentials/password", "newPass"); + OperationExecutionResult result = new OperationExecutionResult( + operation, OperationExecutionResult.Status.FAILURE, "Path not allowed"); + + assertEquals(result.getStatus(), OperationExecutionResult.Status.FAILURE); + assertEquals(result.getMessage(), "Path not allowed"); + assertNotNull(result.getOperation()); + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml new file mode 100755 index 000000000000..17a93f2ff2ad --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml @@ -0,0 +1,687 @@ + + + + + + + + WSO2 Identity Server + + + IS + + + 5.3.0 + + + localhost + + + localhost + + + local:/${carbon.context}/services/ + + + + + + + IdentityServer + + + + + + + org.wso2.carbon + + + / + + + + + + + + + 15 + + + + + + + + + 0 + + + + + 9999 + + 11111 + + + + + + 10389 + + 8000 + + + + + + 10500 + + + + + + + + + org.wso2.carbon.tomcat.jndi.CarbonJavaURLContextFactory + + + + + + + + + + java + + + + + + + + + + false + + + false + + + 600 + + + + false + + + + + + + + 30 + + + + + + + + + 15 + + + + + + ${carbon.home}/repository/deployment/server/ + + + 15 + + + ${carbon.home}/repository/conf/axis2/axis2.xml + + + 30000 + + + ${carbon.home}/repository/deployment/client/ + + ${carbon.home}/repository/conf/axis2/axis2_client.xml + + true + + + + + + + + + + admin + Default Administrator Role + + + user + Default User Role + + + + + + + + + + + + ${carbon.home}/repository/resources/security/wso2carbon.jks + + JKS + + wso2carbon + + wso2carbon + + wso2carbon + + + + + + ${carbon.home}/repository/resources/security/client-truststore.jks + + JKS + + wso2carbon + + + + + + + + + + + + + + + + + + + UserManager + + + false + + org.wso2.carbon.identity.provider.AttributeCallbackHandler + + + org.wso2.carbon.identity.sts.store.DBTokenStore + + + true + allow + + + + + + + claim_mgt_menu + identity_mgt_emailtemplate_menu + identity_security_questions_menu + + + + ${carbon.home}/tmp/work + + + + + + true + + + 10 + + + 30 + + + + + + 100 + + + + keystore + certificate + * + + org.wso2.carbon.ui.transports.fileupload.AnyFileUploadExecutor + + + + + jarZip + + org.wso2.carbon.ui.transports.fileupload.JarZipUploadExecutor + + + + dbs + + org.wso2.carbon.ui.transports.fileupload.DBSFileUploadExecutor + + + + tools + + org.wso2.carbon.ui.transports.fileupload.ToolsFileUploadExecutor + + + + toolsAny + + org.wso2.carbon.ui.transports.fileupload.ToolsAnyFileUploadExecutor + + + + + + + + + + info + org.wso2.carbon.core.transports.util.InfoProcessor + + + wsdl + org.wso2.carbon.core.transports.util.Wsdl11Processor + + + wsdl2 + org.wso2.carbon.core.transports.util.Wsdl20Processor + + + xsd + org.wso2.carbon.core.transports.util.XsdProcessor + + + + + + false + false + true + svn + http://svnrepo.example.com/repos/ + username + password + true + + + + + + + + + + + + + + + ${require.carbon.servlet} + + + + + true + + + + + + + default repository + http://product-dist.wso2.com/p2/carbon/releases/wilkes/ + + + + + + + + true + + + + + + true + + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml new file mode 100644 index 000000000000..4c8191946b3e --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/flow-orchestration-framework/pom.xml b/components/flow-orchestration-framework/pom.xml index 6a5ce82da046..a96984613bea 100644 --- a/components/flow-orchestration-framework/pom.xml +++ b/components/flow-orchestration-framework/pom.xml @@ -37,6 +37,7 @@ org.wso2.carbon.identity.flow.mgt org.wso2.carbon.identity.flow.execution.engine + org.wso2.carbon.identity.flow.inflow.extensions diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml new file mode 100644 index 000000000000..08505499f447 --- /dev/null +++ b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml @@ -0,0 +1,73 @@ + + + + + org.wso2.carbon.identity.framework + flow-orchestration-framework-feature + 7.11.70-SNAPSHOT + ../pom.xml + + + 4.0.0 + org.wso2.carbon.identity.flow.inflow.extensions.server.feature + pom + Flow InFlow Extensions Feature + https://wso2.com + This feature contains the bundles required for in-flow extension support in the flow engine. + + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.inflow.extensions + + + + + + + org.wso2.maven + carbon-p2-plugin + ${carbon.p2.plugin.version} + + + 4-p2-feature-generation + package + + p2-feature-gen + + + org.wso2.carbon.identity.flow.inflow.extensions.server + ../../etc/feature.properties + + + org.wso2.carbon.p2.category.type:server + + + + + org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.inflow.extensions + + + + + + + + + diff --git a/features/flow-orchestration-framework/pom.xml b/features/flow-orchestration-framework/pom.xml index f7f70d5960ba..38c83202767e 100644 --- a/features/flow-orchestration-framework/pom.xml +++ b/features/flow-orchestration-framework/pom.xml @@ -35,6 +35,7 @@ org.wso2.carbon.identity.flow.mgt.server.feature org.wso2.carbon.identity.flow.orchestration.framework.feature org.wso2.carbon.identity.flow.execution.engine.server.feature + org.wso2.carbon.identity.flow.inflow.extensions.server.feature diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 index 12806edc37dc..b7c868c33311 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 @@ -2518,6 +2518,12 @@ {{actions.types.authentication.default_userstore}} + + {{actions.types.in_flow_extension.enable}} + + {{actions.types.in_flow_extension.version.latest}} + + diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json index 8c359b7a6700..a54b1f536422 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json @@ -2272,6 +2272,8 @@ "actions.types.pre_update_profile.enable": true, "actions.types.pre_update_profile.version.latest": "v1", "actions.types.pre_update_password.version.latest": "v2", + "actions.types.in_flow_extension.enable": true, + "actions.types.in_flow_extension.version.latest": "v1", "external_api_client.http_client.connection_timeout": "2000", "external_api_client.http_client.read_timeout": "3000", diff --git a/pom.xml b/pom.xml index efbbd1ddcf5f..886cafe3421e 100644 --- a/pom.xml +++ b/pom.xml @@ -854,6 +854,12 @@ zip ${project.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.inflow.extensions.server.feature + zip + ${project.version} + com.google.api-client google-api-client @@ -1941,6 +1947,11 @@ org.wso2.carbon.identity.flow.execution.engine ${project.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.inflow.extensions + ${project.version} + org.wso2.carbon.identity.framework org.wso2.carbon.identity.servlet.mgt From c3fe3b0d2508e91ce27e764ec95f40b741b39000 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Wed, 20 May 2026 08:32:15 +0530 Subject: [PATCH 02/17] Address review comments --- .gitignore | 5 +- .../execution/api/model/ActionType.java | 2 +- .../action/execution/api/model/User.java | 33 ++ .../impl/ActionExecutorServiceImpl.java | 9 +- .../internal/util/ActionExecutorConfig.java | 10 +- .../action/management/api/model/Action.java | 4 +- .../api/service/ActionManagementService.java | 32 -- .../api/service/ActionValidator.java | 37 +++ .../service/impl/DefaultActionValidator.java | 43 +++ .../impl/ActionDTOModelResolverFactory.java | 4 +- .../impl/ActionManagementServiceImpl.java | 65 +--- .../CacheBackedActionManagementService.java | 7 - .../internal/util/ActionManagementConfig.java | 12 +- .../management/model/ActionTypesTest.java | 4 +- components/entitlement/pom.xml | 2 +- .../pom.xml | 3 +- .../flow/execution/engine/Constants.java | 7 +- .../engine/graph/TaskExecutionNode.java | 29 ++ .../FlowExecutionEngineDataHolder.java | 2 - .../engine/model/ExecutorResponse.java | 11 + .../flow/execution/engine/model/FlowUser.java | 9 +- .../pom.xml | 22 +- .../extensions/InFlowExtensionConstants.java | 22 +- .../executor/InFlowExtensionExecutor.java | 27 +- .../InFlowExtensionRequestBuilder.java | 283 ++++++++---------- .../InFlowExtensionResponseProcessor.java | 75 +++-- .../executor/JWEEncryptionUtil.java | 2 +- .../executor/PathTypeAnnotationUtil.java | 2 +- .../internal/InFlowExtensionDataHolder.java | 2 +- .../InFlowExtensionServiceComponent.java | 14 +- .../InFlowExtensionActionConverter.java | 24 +- ...InFlowExtensionActionDTOModelResolver.java | 22 +- .../InFlowExtensionContextTreeBuilder.java | 4 +- .../InFlowExtensionContextTreeMetadata.java | 2 +- .../InFlowExtensionContextTreeNode.java | 2 +- .../InFlowExtensionContextTreeService.java | 4 +- .../flow}/extensions/model/AccessConfig.java | 4 +- .../flow}/extensions/model/ContextPath.java | 2 +- .../flow}/extensions/model/Encryption.java | 2 +- .../model/FlowContextHandoverConfig.java | 4 +- .../model/InFlowExtensionAction.java | 2 +- .../model/InFlowExtensionEvent.java | 71 ++--- .../extensions/model/InFlowExtensionFlow.java | 90 ++++++ .../model/InFlowExtensionRequest.java | 2 +- .../flow/extensions/model/InFlowUser.java | 43 +++ .../model/OperationExecutionResult.java | 2 +- .../InFlowExtensionContextFilterUtil.java | 6 +- .../util/InFlowExtensionPathUtil.java | 72 +++++ .../extensions/InFlowExtensionTestUtils.java | 4 +- .../executor/InFlowExtensionExecutorTest.java | 60 ++-- .../InFlowExtensionRequestBuilderTest.java | 272 +++++++++++++++-- .../InFlowExtensionResponseProcessorTest.java | 28 +- .../executor/PathTypeAnnotationUtilTest.java | 2 +- .../FlowContextHandoverConfigTestHelper.java | 6 +- ...InFlowExtensionContextTreeBuilderTest.java | 4 +- .../extensions/model/AccessConfigTest.java | 2 +- .../model/InFlowExtensionEventTest.java | 24 +- .../model/OperationExecutionResultTest.java | 2 +- .../util/InFlowExtensionPathUtilTest.java} | 44 ++- .../test/resources/repository/conf/carbon.xml | 0 .../src/test/resources/testng.xml | 43 +++ .../executor/HierarchicalPrefixMatcher.java | 110 ------- .../src/test/resources/testng.xml | 43 --- .../flow-orchestration-framework/pom.xml | 2 +- .../pom.xml | 10 +- .../pom.xml | 8 + features/flow-orchestration-framework/pom.xml | 2 +- pom.xml | 4 +- 68 files changed, 1118 insertions(+), 689 deletions(-) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions => org.wso2.carbon.identity.flow.extensions}/pom.xml (94%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/InFlowExtensionConstants.java (82%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionExecutor.java (95%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionRequestBuilder.java (77%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionResponseProcessor.java (92%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/executor/JWEEncryptionUtil.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/executor/PathTypeAnnotationUtil.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/internal/InFlowExtensionDataHolder.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/internal/InFlowExtensionServiceComponent.java (92%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/management/InFlowExtensionActionConverter.java (87%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/management/InFlowExtensionActionDTOModelResolver.java (95%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/metadata/InFlowExtensionContextTreeBuilder.java (98%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/metadata/InFlowExtensionContextTreeMetadata.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/metadata/InFlowExtensionContextTreeNode.java (98%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/metadata/InFlowExtensionContextTreeService.java (92%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/AccessConfig.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/ContextPath.java (96%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/Encryption.java (96%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/FlowContextHandoverConfig.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/InFlowExtensionAction.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/InFlowExtensionEvent.java (72%) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/InFlowExtensionRequest.java (94%) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/model/OperationExecutionResult.java (96%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow}/extensions/util/InFlowExtensionContextFilterUtil.java (96%) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/InFlowExtensionTestUtils.java (93%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionExecutorTest.java (93%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionRequestBuilderTest.java (75%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/executor/InFlowExtensionResponseProcessorTest.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/executor/PathTypeAnnotationUtilTest.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/metadata/FlowContextHandoverConfigTestHelper.java (82%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/model/AccessConfigTest.java (99%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/model/InFlowExtensionEventTest.java (83%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow}/extensions/model/OperationExecutionResultTest.java (97%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java => org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java} (67%) rename components/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions => org.wso2.carbon.identity.flow.extensions}/src/test/resources/repository/conf/carbon.xml (100%) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml rename features/flow-orchestration-framework/{org.wso2.carbon.identity.flow.inflow.extensions.server.feature => org.wso2.carbon.identity.flow.extensions.server.feature}/pom.xml (88%) diff --git a/.gitignore b/.gitignore index 2f3ae4e27ced..fe5c06fa3508 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ hs_err_pid* # Ignore everything in this directory -target \ No newline at end of file +target + +# Maven versions plugin backup files +*.versionsBackup \ No newline at end of file diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java index 203edb9ae14d..4408c0a227b9 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java @@ -30,7 +30,7 @@ public enum ActionType { PRE_UPDATE_PROFILE, AUTHENTICATION, PRE_ISSUE_ID_TOKEN, - IN_FLOW_EXTENSION; + FLOW_EXTENSIONS; public String getDisplayName() { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java index fe37ca5d3513..9daaf08e73e4 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/User.java @@ -18,6 +18,13 @@ package org.wso2.carbon.identity.action.execution.api.model; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -36,6 +43,7 @@ public class User { private final List groups = new ArrayList<>(); private final List roles = new ArrayList<>(); private Organization organization; + private UserStore userStoreDomain; /** * Represents the user id when the user is shared across sub-organizations. * This field differs from the regular user id ({@link #id}) in scenarios where a user is accessed in the context @@ -76,8 +84,10 @@ public User(Builder builder) { this.userType = builder.userType; this.federatedIdP = builder.federatedIdP; this.accessingOrganization = builder.accessingOrganization; + this.userStoreDomain = builder.userStoreDomain; } + @JsonInclude(JsonInclude.Include.NON_NULL) public String getId() { return id; @@ -108,6 +118,13 @@ public Organization getOrganization() { return organization; } + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonSerialize(using = UserStoreNameSerializer.class) + public UserStore getUserStoreDomain() { + + return userStoreDomain; + } + public String getSharedUserId() { return sharedUserId; @@ -139,6 +156,7 @@ public static class Builder { private final List groups = new ArrayList<>(); private final List roles = new ArrayList<>(); private Organization organization; + private UserStore userStoreDomain; private String sharedUserId; private String userType; private String federatedIdP; @@ -173,6 +191,12 @@ public Builder organization(Organization organization) { return this; } + public Builder userStoreDomain(UserStore userStoreDomain) { + + this.userStoreDomain = userStoreDomain; + return this; + } + public Builder sharedUserId(String sharedUserId) { this.sharedUserId = sharedUserId; @@ -208,4 +232,13 @@ public User build() { return new User(this); } } + + private static class UserStoreNameSerializer extends JsonSerializer { + + @Override + public void serialize(UserStore value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + + gen.writeString(value.getName() != null ? value.getName() : ""); + } + } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java index defa1749ae76..1936f0d98e33 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java @@ -197,8 +197,13 @@ private Action getActionByActionId(ActionType actionType, String actionId, Strin throws ActionExecutionException { try { - return ActionExecutionServiceComponentHolder.getInstance().getActionManagementService().getActionByActionId( - Action.ActionTypes.valueOf(actionType.name()).getPathParam(), actionId, tenantDomain); + Action action = ActionExecutionServiceComponentHolder.getInstance().getActionManagementService() + .getActionByActionId(Action.ActionTypes.valueOf(actionType.name()).getPathParam(), actionId, + tenantDomain); + if (action == null) { + throw new ActionExecutionRuntimeException("No action found for action Id: " + actionId); + } + return action; } catch (ActionMgtException e) { throw new ActionExecutionException("Error occurred while retrieving action by action Id.", e); } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java index c93967db4de3..2e59c61d0afa 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java @@ -87,8 +87,8 @@ public boolean isExecutionForActionTypeEnabled(ActionType actionType) { return isActionTypeEnabled(ActionTypeConfig.PRE_UPDATE_PROFILE.getActionTypeEnableProperty()); case PRE_ISSUE_ID_TOKEN: return isActionTypeEnabled(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getActionTypeEnableProperty()); - case IN_FLOW_EXTENSION: - return isActionTypeEnabled(ActionTypeConfig.IN_FLOW_EXTENSION.getActionTypeEnableProperty()); + case FLOW_EXTENSIONS: + return isActionTypeEnabled(ActionTypeConfig.FLOW_EXTENSIONS.getActionTypeEnableProperty()); default: return false; } @@ -335,8 +335,8 @@ public String getRetiredUpToVersion(ActionType actionType) { return getVersion(ActionTypeConfig.PRE_UPDATE_PROFILE.getRetiredUpToVersionProperty()); case PRE_ISSUE_ID_TOKEN: return getVersion(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getRetiredUpToVersionProperty()); - case IN_FLOW_EXTENSION: - return getVersion(ActionTypeConfig.IN_FLOW_EXTENSION.getRetiredUpToVersionProperty()); + case FLOW_EXTENSIONS: + return getVersion(ActionTypeConfig.FLOW_EXTENSIONS.getRetiredUpToVersionProperty()); default: return null; } @@ -422,7 +422,7 @@ private enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.AllowedHeaders.Header", "Actions.Types.PreIssueIdToken.ActionRequest.AllowedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.RetiredUpTo"), - IN_FLOW_EXTENSION("Actions.Types.InFlowExtension.Enable", + FLOW_EXTENSIONS("Actions.Types.InFlowExtension.Enable", "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.InFlowExtension.ActionRequest.AllowedHeaders.Header", diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java index b35aea2318ab..c4b276dd529c 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java @@ -76,9 +76,9 @@ public enum ActionTypes { "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", Category.PRE_POST), - IN_FLOW_EXTENSION( + FLOW_EXTENSIONS( "inFlowExtension", - "IN_FLOW_EXTENSION", + "FLOW_EXTENSIONS", "In-Flow Extension", "Configure an extension point within any flow via a custom service.", Category.IN_FLOW_EXTENSION); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java index fa370a5ffe27..237a900d8277 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java @@ -129,36 +129,4 @@ Action updateAction(String actionType, String actionId, Action action, String te Action updateActionEndpointAuthentication(String actionType, String actionId, Authentication authentication, String tenantDomain) throws ActionMgtException; - /** - * Check whether the given action name is available (unique) within the specified action type. - * When {@code excludeActionId} is {@code null} the check covers all existing actions of that - * type (creation scenario). When non-null the action with that ID is excluded from the - * uniqueness check (update scenario). - * - * @param actionType Action Type path parameter. - * @param name Action name to check. - * @param excludeActionId Action ID to exclude from the uniqueness check, or {@code null} for - * creation scenarios where no action should be excluded. - * @param tenantDomain Tenant domain. - * @return {@code true} if the name is not already used by another action of the same type - * (i.e., the caller may safely use this name); {@code false} if the name is already taken. - * @throws ActionMgtException If an error occurs while checking name availability. - */ - boolean isActionNameAvailable(String actionType, String name, String excludeActionId, String tenantDomain) - throws ActionMgtException; - - /** - * Convenience overload for creation scenarios — delegates to - * {@link #isActionNameAvailable(String, String, String, String)} with {@code excludeActionId = null}. - * - * @param actionType Action Type path parameter. - * @param name Action name to check. - * @param tenantDomain Tenant domain. - * @return {@code true} if the name is available; {@code false} if already taken. - * @throws ActionMgtException If an error occurs while checking name availability. - */ - default boolean isActionNameAvailable(String actionType, String name, String tenantDomain) - throws ActionMgtException { - return isActionNameAvailable(actionType, name, null, tenantDomain); - } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java index 517830f1145b..9ae26e0c37ca 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java @@ -20,6 +20,9 @@ import org.wso2.carbon.identity.action.management.api.exception.ActionMgtException; import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; + +import java.util.List; /** * This interface to the validate action in the Action management service layer. @@ -42,6 +45,22 @@ public interface ActionValidator { void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action) throws ActionMgtException; + /** + * Perform pre validations on action model when creating an action, including tenant-scoped checks. + * Overload that receives the pre-fetched list of existing actions for validations such as name uniqueness. + * Default delegates to {@link #doPreAddActionValidations(Action.ActionTypes, String, Action)} for + * backward compatibility with existing implementations. + * + * @param action Action creation model. + * @param existingActionsOfType Existing actions of the same type in the tenant. + * @throws ActionMgtException if action model is invalid. + */ + default void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, + List existingActionsOfType) throws ActionMgtException { + + doPreAddActionValidations(actionType, actionVersion, action); + } + /** * Perform pre validations on action model when updating an existing action. * This is specifically used during HTTP PATCH operation and only validate non-null and non-empty fields. @@ -51,4 +70,22 @@ void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersi */ void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action) throws ActionMgtException; + + /** + * Perform pre validations on action model when updating an existing action, including tenant-scoped checks. + * Overload that receives the pre-fetched list of existing actions for validations such as name uniqueness. + * Default delegates to {@link #doPreUpdateActionValidations(Action.ActionTypes, String, Action)} for + * backward compatibility. + * + * @param action Action update model. + * @param excludeId Action ID to exclude from uniqueness check. + * @param existingActionsOfType Existing actions of the same type in the tenant. + * @throws ActionMgtException if action model is invalid. + */ + default void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, + String excludeId, List existingActionsOfType) + throws ActionMgtException { + + doPreUpdateActionValidations(actionType, actionVersion, action); + } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java index 49a323434621..7d753b92dc28 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java @@ -27,6 +27,7 @@ import org.wso2.carbon.identity.action.management.api.exception.ActionMgtException; import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.action.management.api.model.Action.ActionTypes; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; import org.wso2.carbon.identity.action.management.api.model.Authentication; import org.wso2.carbon.identity.action.management.api.service.ActionValidator; import org.wso2.carbon.identity.action.management.internal.component.ActionMgtServiceComponentHolder; @@ -93,6 +94,16 @@ public void doPreAddActionValidations(Action.ActionTypes actionType, String acti isRulesApplicableForActionVersion(actionVersion, action); } + @Override + public void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, + List existingActionsOfType) throws ActionMgtException { + + doPreAddActionValidations(actionType, actionVersion, action); + if (ActionTypes.FLOW_EXTENSIONS.equals(actionType)) { + validateActionNameUniqueness(action.getName(), null, existingActionsOfType); + } + } + /** * Perform pre validations on action model when updating an existing action. * This is specifically used during HTTP PATCH operation and only validate non-null and non-empty fields. @@ -122,6 +133,17 @@ public void doPreUpdateActionValidations(Action.ActionTypes actionType, String a isRulesApplicableForActionVersion(actionVersion, action); } + @Override + public void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, + String excludeId, List existingActionsOfType) + throws ActionMgtException { + + doPreUpdateActionValidations(actionType, actionVersion, action); + if (action.getName() != null && ActionTypes.FLOW_EXTENSIONS.equals(actionType)) { + validateActionNameUniqueness(action.getName(), excludeId, existingActionsOfType); + } + } + /** * Perform pre validations on endpoint authentication model. * @@ -249,6 +271,27 @@ private void validateAllowedParameters(List allowedParametersInAction) t } } + /** + * Validate that the action name is unique within the given list of existing actions. + * + * @param name Action name to validate. + * @param excludeId Action ID to exclude (for update). Null for creation. + * @param existing Existing actions of the same type in the tenant. + * @throws ActionMgtClientException If a duplicate name is found. + */ + public void validateActionNameUniqueness(String name, String excludeId, List existing) + throws ActionMgtClientException { + + boolean duplicateExists = existing.stream() + .filter(dto -> excludeId == null || !excludeId.equals(dto.getId())) + .anyMatch(dto -> name.equalsIgnoreCase(dto.getName())); + + if (duplicateExists) { + throw ActionManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name); + } + } + /** * Validate whether required fields exist. * diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java index 6fdc4186b4af..671bbde7af0f 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java @@ -46,8 +46,8 @@ public static ActionDTOModelResolver getActionDTOModelResolver(Action.ActionType return actionDTOModelResolvers.get(Action.ActionTypes.PRE_UPDATE_PASSWORD); case PRE_ISSUE_ACCESS_TOKEN: return actionDTOModelResolvers.get(Action.ActionTypes.PRE_ISSUE_ACCESS_TOKEN); - case IN_FLOW_EXTENSION: - return actionDTOModelResolvers.get(Action.ActionTypes.IN_FLOW_EXTENSION); + case FLOW_EXTENSIONS: + return actionDTOModelResolvers.get(Action.ActionTypes.FLOW_EXTENSIONS); default: return null; } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index c61ce1acd8dd..f5afa5b50c06 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -42,6 +42,7 @@ import org.wso2.carbon.identity.core.util.IdentityUtil; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; @@ -75,14 +76,14 @@ public Action addAction(String actionType, Action action, String tenantDomain) t int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); String resolvedActionType = getActionTypeFromPath(actionType); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); + List existingActions = ActionTypes.FLOW_EXTENSIONS.equals(castedActionType) + ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) + : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreAddActionValidations( - castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action); + castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action, + existingActions); // Check whether the maximum allowed actions per type is reached. validateMaxActionsPerType(resolvedActionType, tenantDomain); - // Check whether the action name is unique within the action type for in-flow extensions. - if (resolvedActionType.equals(ActionTypes.IN_FLOW_EXTENSION.getActionType())) { - validateActionNameUniqueness(resolvedActionType, action.getName(), null, tenantId); - } String generatedActionId = UUID.randomUUID().toString(); ActionDTO creatingActionDTO = buildActionDTOForCreation(resolvedActionType, generatedActionId, action); @@ -164,12 +165,12 @@ public Action updateAction(String actionType, String actionId, Action action, St String resolvedActionType = getActionTypeFromPath(actionType); ActionDTO existingActionDTO = checkIfActionExists(resolvedActionType, actionId, tenantDomain); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); + List existingActions = ActionTypes.FLOW_EXTENSIONS.equals(castedActionType) + ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) + : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreUpdateActionValidations( - castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action); - if (action.getName() != null && - Action.ActionTypes.IN_FLOW_EXTENSION.equals(castedActionType)) { - validateActionNameUniqueness(resolvedActionType, action.getName(), actionId, tenantId); - } + castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action, actionId, + existingActions); ActionDTO updatingActionDTO = buildActionDTOForUpdate(resolvedActionType, actionId, action); DAO_FACADE.updateAction(updatingActionDTO, existingActionDTO, tenantId); @@ -180,25 +181,6 @@ public Action updateAction(String actionType, String actionId, Action action, St return buildAction(resolvedActionType, updatedActionDTO); } - @Override - public boolean isActionNameAvailable(String actionType, String name, String excludeActionId, - String tenantDomain) throws ActionMgtException { - - if (name == null) { - throw ActionManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_INVALID_ACTION_REQUEST_FIELD, "Action name"); - } - // actionType is the URL path param (e.g., "inFlowExtension"); resolve to the internal enum name. - String resolvedActionType = getActionTypeFromPath(actionType); - int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); - List actionDTOS = DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId); - // noneMatch returns true when no existing action has this name → name is available. - // When excludeActionId is null (creation), no action is excluded from the check. - return actionDTOS.stream() - .noneMatch(dto -> name.equalsIgnoreCase(dto.getName()) && - (excludeActionId == null || !excludeActionId.equals(dto.getId()))); - } - private String resolveActionVersionAtUpdating(Action updatingAction, ActionDTO existingActionDTO) { String updatingActionVersion = updatingAction.getActionVersion(); @@ -381,29 +363,6 @@ private ActionDTO checkIfActionExists(String actionType, String actionId, String return actionDTO; } - /** - * Validate that the action name is unique within the given action type. - * - * @param actionType Action type. - * @param name Action name to validate. - * @param excludeId Action ID to exclude (for update). Null for creation. - * @param tenantId Tenant ID. - * @throws ActionMgtException If a duplicate name is found. - */ - private void validateActionNameUniqueness(String actionType, String name, String excludeId, int tenantId) - throws ActionMgtException { - - List existingActions = DAO_FACADE.getActionsByActionType(actionType, tenantId); - boolean duplicateExists = existingActions.stream() - .filter(dto -> excludeId == null || !excludeId.equals(dto.getId())) - .anyMatch(dto -> name.equalsIgnoreCase(dto.getName())); - - if (duplicateExists) { - throw ActionManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name); - } - } - /** * For action creation operation, builds an `ActionDTO` object based on the provided action type, action ID, and * action model. This method resolves the action type and status, applies necessary transformations, and constructs @@ -419,7 +378,7 @@ private ActionDTO buildActionDTOForCreation(String actionType, String actionId, Action.ActionTypes resolvedActionType = Action.ActionTypes.valueOf(actionType); // PRE_POST actions start INACTIVE (require explicit activation). - // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, IN_FLOW_EXTENSION) + // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSIONS) // start ACTIVE and can be used immediately. Action.Status resolvedStatus = resolvedActionType.getCategory() == Action.ActionTypes.Category.PRE_POST ? Action.Status.INACTIVE : Action.Status.ACTIVE; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java index 19180ca29400..5e2e0dd0f3b6 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java @@ -182,13 +182,6 @@ public Action updateActionEndpointAuthentication(String actionType, String actio return updatedAction; } - @Override - public boolean isActionNameAvailable(String actionType, String name, String excludeActionId, - String tenantDomain) throws ActionMgtException { - - return ACTION_MGT_SERVICE.isActionNameAvailable(actionType, name, excludeActionId, tenantDomain); - } - private void updateCache(Action action, ActionCacheEntry entry, ActionTypeCacheKey cacheKey, String tenantDomain) { if (LOG.isDebugEnabled()) { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java index fd4acd913c41..f77edb4759db 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java @@ -96,9 +96,9 @@ public String getLatestVersion(ActionTypes actionType) throws ActionMgtServerExc case PRE_ISSUE_ID_TOKEN: return getVersion( ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getLatestVersionProperty(), actionType); - case IN_FLOW_EXTENSION: + case FLOW_EXTENSIONS: return getVersion( - ActionTypeConfig.IN_FLOW_EXTENSION.getLatestVersionProperty(), actionType); + ActionTypeConfig.FLOW_EXTENSIONS.getLatestVersionProperty(), actionType); default: throw new ActionMgtServerException("Unsupported action type: " + actionType); } @@ -145,9 +145,8 @@ public enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.Latest" ), - IN_FLOW_EXTENSION( + FLOW_EXTENSIONS( "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", - "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.InFlowExtension.Version.Latest" ); @@ -162,6 +161,11 @@ public enum ActionTypeConfig { this.latestVersionProperty = latestVersionProperty; } + ActionTypeConfig(String excludedHeadersProperty, String latestVersionProperty) { + + this(excludedHeadersProperty, null, latestVersionProperty); + } + public String getExcludedHeadersProperty() { return excludedHeadersProperty; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java index a8053186cba7..b896ac8796ac 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java @@ -53,7 +53,7 @@ public Object[][] actionTypesProvider() { "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", Action.ActionTypes.Category.PRE_POST}, - {Action.ActionTypes.IN_FLOW_EXTENSION, "inFlowExtension", "IN_FLOW_EXTENSION", + {Action.ActionTypes.FLOW_EXTENSIONS, "inFlowExtension", "FLOW_EXTENSIONS", "In-Flow Extension", "Configure an extension point within any flow via a custom service.", Action.ActionTypes.Category.IN_FLOW_EXTENSION} @@ -82,7 +82,7 @@ public Object[][] filterByCategoryProvider() { Action.ActionTypes.PRE_REGISTRATION, Action.ActionTypes.PRE_ISSUE_ID_TOKEN}}, {Action.ActionTypes.Category.IN_FLOW, new Action.ActionTypes[]{Action.ActionTypes.AUTHENTICATION}}, {Action.ActionTypes.Category.IN_FLOW_EXTENSION, - new Action.ActionTypes[]{Action.ActionTypes.IN_FLOW_EXTENSION}} + new Action.ActionTypes[]{Action.ActionTypes.FLOW_EXTENSIONS}} }; } diff --git a/components/entitlement/pom.xml b/components/entitlement/pom.xml index c555f126527f..807c82c73870 100644 --- a/components/entitlement/pom.xml +++ b/components/entitlement/pom.xml @@ -21,7 +21,7 @@ org.wso2.carbon.identity.framework identity-framework - 7.7.0-SNAPSHOT + 7.11.88-SNAPSHOT ../../pom.xml diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml index c88ff2a0a700..055bccfae858 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/pom.xml @@ -20,7 +20,7 @@ org.wso2.carbon.identity.framework identity-framework - 7.11.88-SNAPSHOT + 7.11.94-SNAPSHOT ../../../pom.xml @@ -180,7 +180,6 @@ !org.wso2.carbon.identity.flow.execution.internal, org.wso2.carbon.identity.flow.execution.engine.util, org.wso2.carbon.identity.flow.execution.engine, - org.wso2.carbon.identity.flow.execution.engine.internal, org.wso2.carbon.identity.flow.execution.engine.core, org.wso2.carbon.identity.flow.execution.engine.cache, org.wso2.carbon.identity.flow.execution.engine.store, diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java index d21fd09ed825..3df959dff192 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java @@ -167,7 +167,7 @@ public enum ErrorMessages { "Error occurred during policy consent processing.", "Error occurred while processing policy consent for the %s request of flow id: %s."), ERROR_CODE_INFLOW_EXTENSION_ERROR("65034", - "%s", + "In-Flow Extension error.", "%s"), // Client errors. @@ -218,7 +218,10 @@ public enum ErrorMessages { "The provided username: %s does not meet the configured format requirements."), ERROR_CODE_PASSWORD_FORMAT_VALIDATION_FAILED("60016", "Password does not meet the required format.", - "The provided password does not meet the configured format requirements.") + "The provided password does not meet the configured format requirements."), + ERROR_CODE_INFLOW_EXTENSION_FAILURE("60017", + "%s", + "%s") ; private static final String ERROR_PREFIX = "FE"; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java index c57ff63bd380..e08b8e3accf2 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java @@ -19,8 +19,10 @@ package org.wso2.carbon.identity.flow.execution.engine.graph; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder; import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; @@ -28,6 +30,7 @@ import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; import org.wso2.carbon.identity.flow.execution.engine.model.NodeResponse; import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; +import org.wso2.carbon.user.core.common.AbstractUserStoreManager; import static org.wso2.carbon.identity.flow.execution.engine.Constants.ErrorMessages.ERROR_CODE_EXECUTOR_FAILURE; import static org.wso2.carbon.identity.flow.execution.engine.Constants.ErrorMessages.ERROR_CODE_EXECUTOR_NOT_FOUND; @@ -204,16 +207,42 @@ private NodeResponse handleCompleteStatus(FlowExecutionContext context, Executor } FlowUser user = context.getFlowUser(); + if (response.getUserId() != null) { + user.setUserId(response.getUserId()); + } if (response.getUpdatedUserClaims() != null) { response.getUpdatedUserClaims().forEach((key, value) -> user.addClaim(key, String.valueOf(value))); } if (response.getUserCredentials() != null) { user.getUserCredentials().putAll(response.getUserCredentials()); } + if (user.getUserId() == null) { + resolveUserIdFromUserStore(user, context.getTenantDomain()); + } if (CollectionUtils.isNotEmpty(configs.getEdges())) { configs.setNextNodeId(configs.getEdges().get(0).getTargetNodeId()); } return new NodeResponse.Builder().status(STATUS_COMPLETE).build(); } + + private void resolveUserIdFromUserStore(FlowUser user, String tenantDomain) { + + String username = user.getUsername(); + if (StringUtils.isBlank(username)) { + return; + } + try { + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) + FlowExecutionEngineDataHolder.getInstance().getRealmService() + .getTenantUserRealm(tenantId).getUserStoreManager(); + String userId = userStoreManager.getUserIDFromUserName(username); + if (StringUtils.isNotBlank(userId)) { + user.setUserId(userId); + } + } catch (Exception e) { + LOG.warn("Failed to resolve userId for user '" + username + "' from user store.", e); + } + } } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java index f1775598ea45..adf1b6225b53 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java @@ -209,7 +209,6 @@ public void setFederatedAssociationManager(FederatedAssociationManager federated this.federatedAssociationManager = federatedAssociationManager; } - public IdentityEventService getIdentityEventService() { return identityEventService; @@ -220,4 +219,3 @@ public void setIdentityEventService(IdentityEventService identityEventService) { this.identityEventService = identityEventService; } } - diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java index d70ef7b39224..5aa8f0591b81 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java @@ -27,6 +27,7 @@ public class ExecutorResponse { private String result; + private String userId; private List requiredData; private List optionalData; private Map updatedUserClaims; @@ -55,6 +56,16 @@ public void setResult(String result) { this.result = result; } + public String getUserId() { + + return userId; + } + + public void setUserId(String userId) { + + this.userId = userId; + } + public List getRequiredData() { return requiredData; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java index 187684ebac0c..12fdfac768eb 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java @@ -123,6 +123,7 @@ public void addClaims(Map claims) { this.claims.putAll(claims); } + public void setClaims(Map claims) { this.claims.clear(); @@ -182,14 +183,6 @@ public void addFederatedAssociation(String idpName, String idpSubject) { this.federatedAssociations.put(idpName, idpSubject); } - public void setFederatedAssociations(Map federatedAssociations) { - - this.federatedAssociations.clear(); - if (federatedAssociations != null) { - this.federatedAssociations.putAll(federatedAssociations); - } - } - /** * Check whether the user credentials are managed locally. * diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml similarity index 94% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml index 23d0a6c989a0..ddb55fc0508f 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/pom.xml +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml @@ -20,12 +20,12 @@ org.wso2.carbon.identity.framework identity-framework - 7.11.70-SNAPSHOT + 7.11.94-SNAPSHOT ../../../pom.xml 4.0.0 - org.wso2.carbon.identity.flow.inflow.extensions + org.wso2.carbon.identity.flow.extensions bundle WSO2 Carbon - Identity Flow In-Flow Extensions WSO2 flow engine in-flow extensions @@ -132,8 +132,8 @@ ${project.artifactId} ${project.artifactId} - org.wso2.carbon.identity.flow.inflow.extensions.internal, - org.wso2.carbon.identity.flow.inflow.extensions.util + org.wso2.carbon.identity.flow.extensions.internal, + org.wso2.carbon.identity.flow.extensions.util javax.xml.parsers; version="${javax.xml.parsers.import.pkg.version}", @@ -196,12 +196,12 @@ org.slf4j; version="${org.slf4j.imp.pkg.version.range}" - !org.wso2.carbon.identity.flow.inflow.extensions.internal, - org.wso2.carbon.identity.flow.inflow.extensions, - org.wso2.carbon.identity.flow.inflow.extensions.executor, - org.wso2.carbon.identity.flow.inflow.extensions.model, - org.wso2.carbon.identity.flow.inflow.extensions.management, - org.wso2.carbon.identity.flow.inflow.extensions.metadata; + !org.wso2.carbon.identity.flow.extensions.internal, + org.wso2.carbon.identity.flow.extensions, + org.wso2.carbon.identity.flow.extensions.executor, + org.wso2.carbon.identity.flow.extensions.model, + org.wso2.carbon.identity.flow.extensions.management, + org.wso2.carbon.identity.flow.extensions.metadata; version="${carbon.identity.package.export.version}" @@ -240,7 +240,7 @@ ${jacoco.version} - org/wso2/carbon/identity/flow/inflow/extensions/internal/** + org/wso2/carbon/identity/flow/extensions/internal/** diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java similarity index 82% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java index a620e514dc9e..603e9f32405b 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionConstants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions; +package org.wso2.carbon.identity.flow.extensions; import java.util.Arrays; import java.util.Collections; @@ -52,9 +52,25 @@ private InFlowExtensionConstants() { public static final String FAILURE_DESCRIPTION_KEY = "failureDescription"; // ---- Context path prefixes ---- - public static final String PROPERTIES_PATH_PREFIX = "/properties/"; - public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; + public static final String USER_PREFIX = "/user/"; + public static final String USER_ID_PATH = "/user/userId"; + public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; + public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; + public static final String USER_CLAIMS_SELECTOR_PREFIX = "/user/claims[uri="; + public static final String USER_CLAIMS_SELECTOR_SUFFIX = "]"; public static final String USER_CREDENTIALS_PATH_PREFIX = "/user/credentials/"; + public static final String PROPERTIES_PATH_PREFIX = "/properties/"; + public static final String FLOW_PREFIX = "/flow/"; + public static final String FLOW_TENANT_PATH = "/flow/tenantDomain"; + public static final String FLOW_APP_ID_PATH = "/flow/applicationId"; + public static final String FLOW_TYPE_PATH = "/flow/flowType"; + public static final String FLOW_CALLBACK_URL_PATH = "/flow/callbackUrl"; + public static final String FLOW_PORTAL_URL_PATH = "/flow/portalUrl"; + public static final String ORGANIZATION_PREFIX = "/organization/"; + public static final String ORGANIZATION_ID_PATH = "/organization/id"; + public static final String ORGANIZATION_NAME_PATH = "/organization/name"; + public static final String ORGANIZATION_HANDLE_PATH = "/organization/orgHandle"; + public static final String ORGANIZATION_DEPTH_PATH = "/organization/depth"; // ---- Miscellaneous ---- public static final String ACTION_ID_METADATA_KEY = "actionId"; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java similarity index 95% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java index ff89a11d48dd..c3b7e80b62b9 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutor.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -33,11 +33,12 @@ import org.wso2.carbon.identity.flow.execution.engine.Constants; import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; import org.wso2.carbon.utils.DiagnosticLog; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; -import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; -import org.wso2.carbon.identity.flow.inflow.extensions.util.InFlowExtensionContextFilterUtil; +import org.wso2.carbon.identity.flow.execution.engine.util.FlowExecutionEngineUtils; +import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionContextFilterUtil; import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; @@ -76,7 +77,6 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE String actionId = getMetadataValue(context, InFlowExtensionConstants.ACTION_ID_METADATA_KEY); if (actionId == null || actionId.isEmpty()) { - LOG.warn("No action ID configured for In-Flow Extension executor. Cannot execute."); triggerDiagnosticFailure(null, "In-Flow Extension action execution failed: action ID is not configured."); return buildErrorResponse("Extension is not configured.", @@ -92,14 +92,14 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE ActionExecutorService actionExecutorService = getActionExecutorService(); if (actionExecutorService == null) { - LOG.error("ActionExecutorService is not available. In-Flow Extension cannot execute. actionId: " + actionId); triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: ActionExecutorService is unavailable."); - throw new FlowEngineException("ActionExecutorService is not available."); + throw FlowExecutionEngineUtils.handleServerException( + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR, + "ActionExecutorService is not available. actionId: " + actionId); } - if (!actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)) { - LOG.debug("In-Flow Extension action execution is disabled."); + if (!actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)) { triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: action type is disabled."); return buildErrorResponse("Extension execution is disabled.", @@ -116,7 +116,7 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, filteredContext); ActionExecutionStatus executionStatus = actionExecutorService.execute( - ActionType.IN_FLOW_EXTENSION, actionId, flowContext, context.getTenantDomain()); + ActionType.FLOW_EXTENSIONS, actionId, flowContext, context.getTenantDomain()); ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context); @@ -260,6 +260,7 @@ private void handleFailedStatus(ExecutorResponse response, ActionExecutionStatus failureInfo.put(InFlowExtensionConstants.FAILURE_DESCRIPTION_KEY, failure.getFailureDescription()); } response.setAdditionalInfo(failureInfo); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_FAILURE.getCode()); response.setErrorMessage(buildUserFacingErrorMessage(failure)); } @@ -380,7 +381,7 @@ private void triggerDiagnosticFailure(String diagnosticActionId, String actionId DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) .resultMessage(resultMessage) - .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.FAILED); @@ -400,7 +401,7 @@ private void triggerDiagnosticSuccess(String diagnosticActionId, String actionId DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) .resultMessage(resultMessage) - .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java similarity index 77% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java index 584fdd165626..e04c4140b16c 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java @@ -16,14 +16,14 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.inflow.extensions.model.*; +import org.wso2.carbon.identity.flow.extensions.model.*; import org.wso2.carbon.utils.DiagnosticLog; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; @@ -31,7 +31,6 @@ import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; import org.wso2.carbon.identity.action.execution.api.model.Application; import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Header; import org.wso2.carbon.identity.action.execution.api.model.Operation; import org.wso2.carbon.identity.action.execution.api.model.Organization; import org.wso2.carbon.identity.action.execution.api.model.Tenant; @@ -42,7 +41,8 @@ import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.core.context.IdentityContext; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionPathUtil; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; @@ -73,7 +73,7 @@ public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuil @Override public ActionType getSupportedActionType() { - return ActionType.IN_FLOW_EXTENSION; + return ActionType.FLOW_EXTENSIONS; } @Override @@ -104,7 +104,7 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex InFlowExtensionEvent event = buildEvent(execCtx, exposeResolution.getEffectiveExposePaths(), accessConfig, certificatePEM); - return buildRequestPayload(execCtx, event, allowedOperations); + return buildRequestPayload(event, allowedOperations); } /** @@ -157,51 +157,19 @@ private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List headers = new ArrayList<>(); - for (org.wso2.carbon.identity.core.context.model.Header coreHeader : inboundRequest.getHeaders()) { - if (coreHeader.getName() == null) { - continue; - } - List values = coreHeader.getValue(); - String[] valueArray = values != null - ? values.toArray(new String[0]) : new String[0]; - headers.add(new Header(coreHeader.getName(), valueArray)); - } - request.setAdditionalHeaders(headers); - - return request; - } - /** * Build the {@link User} model from {@link FlowUser}, filtering by expose config. * Encrypts credential and claim values for expose paths marked as encrypted. @@ -212,7 +180,7 @@ private InFlowExtensionRequest buildRequest() { * @param certificatePEM The certificate PEM for JWE encryption (may be null). * @return The filtered User model with encrypted values where configured. */ - private User buildUser(FlowUser flowUser, List expose, + private InFlowUser buildUser(FlowUser flowUser, List expose, AccessConfig accessConfig, String certificatePEM) throws ActionExecutionRequestBuilderException { @@ -228,42 +196,12 @@ private User buildUser(FlowUser flowUser, List expose, userBuilder.userCredentials(filteredCredentials); } - return userBuilder.build(); - } - - /** - * Filter a map to only include entries whose paths are exposed. - * Values for expose paths marked as encrypted are JWE-encrypted. - * - * @param map The source map. - * @param areaPrefix The area prefix (e.g. "/properties/"). - * @param expose The expose prefix list. - * @param accessConfig The access config with encryption flags (may be null). - * @param certificatePEM The certificate PEM for JWE encryption (may be null). - * @param The value type. - * @return A new map containing only exposed entries, with encrypted values where configured. - */ - @SuppressWarnings("unchecked") - private Map filterMap(Map map, String areaPrefix, List expose, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - if (map == null) { - return Collections.emptyMap(); + if (isLeafExposed(InFlowExtensionConstants.USER_STORE_DOMAIN_PATH, expose)) { + String userStoreDomain = flowUser.getUserStoreDomain(); + userBuilder.userStoreDomain(new UserStore(userStoreDomain != null ? userStoreDomain : "")); } - Map filtered = new HashMap<>(); - for (Map.Entry entry : map.entrySet()) { - String fullPath = areaPrefix + entry.getKey(); - if (isLeafExposed(fullPath, expose)) { - T value = entry.getValue(); - if (value != null && shouldEncrypt(fullPath, accessConfig, certificatePEM)) { - value = (T) encryptValue(String.valueOf(value), certificatePEM); - } - filtered.put(entry.getKey(), value); - } - } - return filtered; + return new InFlowUser(userBuilder); } private FlowExecutionContext getFlowExecutionContextOrThrow(FlowContext flowContext) @@ -350,17 +288,18 @@ private ActionExecutionRequest buildFallbackRequest(FlowContext flowContext, Flo triggerFallbackDiagnostic(execCtx); InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flowId(execCtx.getContextIdentifier()) + .flow(new InFlowExtensionFlow.Builder() + .flowId(execCtx.getContextIdentifier()) + .build()) .build(); - return buildRequestPayload(execCtx, event, allowedOperations); + return buildRequestPayload(event, allowedOperations); } - private ActionExecutionRequest buildRequestPayload(FlowExecutionContext execCtx, InFlowExtensionEvent event, + private ActionExecutionRequest buildRequestPayload(InFlowExtensionEvent event, List allowedOperations) { return new ActionExecutionRequest.Builder() - .actionType(ActionType.IN_FLOW_EXTENSION) - .flowId(execCtx.getContextIdentifier()) + .actionType(ActionType.FLOW_EXTENSIONS) .event(event) .allowedOperations(allowedOperations) .build(); @@ -378,7 +317,7 @@ private void triggerRequestBuildDiagnostic(FlowExecutionContext execCtx, List omittedEncryptedExposeP ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) .resultMessage("Omitted encrypted expose paths because outbound certificate is not configured.") - .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) @@ -476,7 +415,7 @@ private AllowedModifyExtraction extractAllowedModifyPaths(List modi if (annotation != null) { pathTypeAnnotations.put(cleanPath, annotation); } - cleanPaths.add(cleanPath); + cleanPaths.add(toExternalPath(cleanPath)); } else { LOG.warn("Annotation for path " + cleanPath + " exceeds maximum attribute limit. Skipping path."); @@ -517,7 +456,7 @@ private void addRedirectOperation(List allowedOperations) { private void applyTenant(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, List expose) { - if (!isLeafExposed(HierarchicalPrefixMatcher.FLOW_TENANT_PATH, expose)) { + if (!isLeafExposed(InFlowExtensionConstants.FLOW_TENANT_PATH, expose)) { return; } @@ -525,10 +464,16 @@ private void applyTenant(InFlowExtensionEvent.Builder eventBuilder, FlowExecutio if (tenantDomain != null) { int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); eventBuilder.tenant(new Tenant(String.valueOf(tenantId), tenantDomain)); + } else { + eventBuilder.tenant(new Tenant("", "")); } } - private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder) { + private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder, List expose) { + + if (!isAreaExposed(InFlowExtensionConstants.ORGANIZATION_PREFIX, expose)) { + return; + } org.wso2.carbon.identity.core.context.model.Organization coreOrg = IdentityContext.getThreadLocalIdentityContext().getOrganization(); @@ -536,32 +481,40 @@ private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder) { return; } - eventBuilder.organization(new Organization.Builder() - .id(coreOrg.getId()) - .name(coreOrg.getName()) - .orgHandle(coreOrg.getOrganizationHandle()) - .depth(coreOrg.getDepth()) - .build()); + Organization.Builder orgBuilder = new Organization.Builder(); + + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_ID_PATH, expose)) { + orgBuilder.id(coreOrg.getId()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_NAME_PATH, expose)) { + orgBuilder.name(coreOrg.getName()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_HANDLE_PATH, expose)) { + orgBuilder.orgHandle(coreOrg.getOrganizationHandle()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_DEPTH_PATH, expose)) { + orgBuilder.depth(coreOrg.getDepth()); + } + + eventBuilder.organization(orgBuilder.build()); } private void applyApplication(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, List expose) { - if (!isLeafExposed(HierarchicalPrefixMatcher.FLOW_APP_ID_PATH, expose)) { + if (!isLeafExposed(InFlowExtensionConstants.FLOW_APP_ID_PATH, expose)) { return; } String appId = context.getApplicationId(); - if (appId != null) { - eventBuilder.application(new Application(appId, null)); - } + eventBuilder.application(new Application(appId != null ? appId : "", null)); } - private void applyUserAndUserStore(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + private void applyUserAndUserStore(InFlowExtensionFlow.Builder flowBuilder, FlowExecutionContext context, List expose, AccessConfig accessConfig, String certificatePEM) throws ActionExecutionRequestBuilderException { - if (!isAreaExposed(HierarchicalPrefixMatcher.USER_PREFIX, expose)) { + if (!isAreaExposed(InFlowExtensionConstants.USER_PREFIX, expose)) { return; } @@ -570,30 +523,25 @@ private void applyUserAndUserStore(InFlowExtensionEvent.Builder eventBuilder, Fl return; } - eventBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); - if (isLeafExposed(HierarchicalPrefixMatcher.USER_STORE_DOMAIN_PATH, expose) - && flowUser.getUserStoreDomain() != null) { - eventBuilder.userStore(new UserStore(flowUser.getUserStoreDomain())); - } + flowBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); } - private void applyFlowMetadata(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, - List expose) { + private void applyFlowMetadata(InFlowExtensionFlow.Builder flowBuilder, + InFlowExtensionEvent.Builder eventBuilder, + FlowExecutionContext context, List expose) { - if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_TYPE_PATH, expose)) { - eventBuilder.flowType(context.getFlowType()); + if (isLeafExposed(InFlowExtensionConstants.FLOW_TYPE_PATH, expose)) { + flowBuilder.flowType(context.getFlowType() != null ? context.getFlowType() : ""); } - eventBuilder.flowId(context.getContextIdentifier()); + flowBuilder.flowId(context.getContextIdentifier()); - if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_CALLBACK_URL_PATH, expose) - && context.getCallbackUrl() != null) { - eventBuilder.callbackUrl(context.getCallbackUrl()); + if (isLeafExposed(InFlowExtensionConstants.FLOW_CALLBACK_URL_PATH, expose)) { + eventBuilder.callbackUrl(context.getCallbackUrl() != null ? context.getCallbackUrl() : ""); } - if (isLeafExposed(HierarchicalPrefixMatcher.FLOW_PORTAL_URL_PATH, expose) - && context.getPortalUrl() != null) { - eventBuilder.portalUrl(context.getPortalUrl()); + if (isLeafExposed(InFlowExtensionConstants.FLOW_PORTAL_URL_PATH, expose)) { + eventBuilder.portalUrl(context.getPortalUrl() != null ? context.getPortalUrl() : ""); } } @@ -601,22 +549,33 @@ private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, Flow List expose, AccessConfig accessConfig, String certificatePEM) throws ActionExecutionRequestBuilderException { - if (!isAreaExposed(HierarchicalPrefixMatcher.PROPERTIES_PREFIX, expose)) { + if (!isAreaExposed(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX, expose)) { return; } Map properties = context.getProperties(); - if (properties != null && !properties.isEmpty()) { - eventBuilder.flowProperties( - filterMap(properties, HierarchicalPrefixMatcher.PROPERTIES_PREFIX, - expose, accessConfig, certificatePEM)); + Map filteredProperties = new HashMap<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { + continue; + } + String propKey = exposePath.substring(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX.length()); + Object value = properties != null ? properties.get(propKey) : null; + if (value != null && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { + value = encryptValue(String.valueOf(value), certificatePEM); + } + filteredProperties.put(propKey, value != null ? value : ""); } + + eventBuilder.flowProperties(filteredProperties); } private String resolveUserId(FlowUser flowUser, List expose) { - if (isLeafExposed(HierarchicalPrefixMatcher.USER_ID_PATH, expose)) { - return flowUser.getUserId(); + if (isLeafExposed(InFlowExtensionConstants.USER_ID_PATH, expose)) { + String userId = flowUser.getUserId(); + return userId != null ? userId : ""; } return null; } @@ -625,25 +584,24 @@ private List buildFilteredClaims(FlowUser flowUser, List expo AccessConfig accessConfig, String certificatePEM) throws ActionExecutionRequestBuilderException { - if (!isAreaExposed(HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX, expose)) { + if (!isAreaExposed(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX, expose)) { return Collections.emptyList(); } Map claims = flowUser.getClaims(); - if (claims == null || claims.isEmpty()) { - return Collections.emptyList(); - } - List userClaims = new ArrayList<>(); - for (Map.Entry claim : claims.entrySet()) { - String claimPath = HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX + claim.getKey(); - if (isLeafExposed(claimPath, expose)) { - String claimValue = claim.getValue(); - if (claimValue != null && shouldEncrypt(claimPath, accessConfig, certificatePEM)) { - claimValue = encryptValue(claimValue, certificatePEM); - } - userClaims.add(new UserClaim(claim.getKey(), claimValue)); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + continue; + } + String claimKey = exposePath.substring(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); + String claimValue = claims != null ? claims.get(claimKey) : null; + claimValue = claimValue != null ? claimValue : ""; + if (!claimValue.isEmpty() && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { + claimValue = encryptValue(claimValue, certificatePEM); } + userClaims.add(new UserClaim(claimKey, claimValue)); } return userClaims; } @@ -652,25 +610,27 @@ private Map buildFilteredCredentials(FlowUser flowUser, List credentials = flowUser.getUserCredentials(); - if (credentials == null || credentials.isEmpty()) { - return Collections.emptyMap(); - } - Map filteredCredentials = new HashMap<>(); - for (Map.Entry entry : credentials.entrySet()) { - String credentialPath = HierarchicalPrefixMatcher.USER_CREDENTIALS_PREFIX + entry.getKey(); - if (isLeafExposed(credentialPath, expose)) { - char[] credentialValue = entry.getValue(); - String plaintext = new String(credentialValue); - java.util.Arrays.fill(credentialValue, '\0'); - - filteredCredentials.put(entry.getKey(), - toEncryptedOrPlainCredentialChars(plaintext, credentialPath, accessConfig, certificatePEM)); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { + continue; + } + String credKey = exposePath.substring(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX.length()); + char[] credValue = credentials != null ? credentials.get(credKey) : null; + + if (credValue != null) { + String plaintext = new String(credValue); + java.util.Arrays.fill(credValue, '\0'); + filteredCredentials.put(credKey, + toEncryptedOrPlainCredentialChars(plaintext, exposePath, accessConfig, certificatePEM)); + } else { + filteredCredentials.put(credKey, new char[0]); } } return filteredCredentials; @@ -767,13 +727,30 @@ private List getSkippedPaths() { } } + /** + * Convert an internal path to its external (API-facing) form. + * User-claim paths stored internally as {@code /user/claims/} are emitted + * externally as {@code /user/claims[uri=]}. All other paths are unchanged. + */ + private static String toExternalPath(String internalPath) { + + if (internalPath != null + && internalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + String claimUri = internalPath.substring( + InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); + return InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + claimUri + + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX; + } + return internalPath; + } + /** * Check if any exposed leaf path falls under the given area prefix. * Used as a gate before iterating a data block (claims, credentials, properties). */ private boolean isAreaExposed(String areaPrefix, List expose) { - return HierarchicalPrefixMatcher.anyExposedUnder(areaPrefix, expose); + return InFlowExtensionPathUtil.anyExposedUnder(areaPrefix, expose); } /** @@ -782,7 +759,7 @@ private boolean isAreaExposed(String areaPrefix, List expose) { */ private boolean isLeafExposed(String leafPath, List expose) { - return HierarchicalPrefixMatcher.isExposedPath(leafPath, expose); + return InFlowExtensionPathUtil.isExposedPath(leafPath, expose); } /** diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java similarity index 92% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java index 1df1c1a4f765..6006aa36006d 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessor.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; @@ -26,7 +26,7 @@ import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; -import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; @@ -48,10 +48,11 @@ import org.wso2.carbon.identity.action.execution.api.model.SuccessStatus; import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.inflow.extensions.model.AccessConfig; -import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; -import org.wso2.carbon.identity.flow.inflow.extensions.model.OperationExecutionResult; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.model.AccessConfig; +import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionPathUtil; +import org.wso2.carbon.identity.flow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.extensions.model.OperationExecutionResult; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.utils.DiagnosticLog; @@ -90,7 +91,7 @@ public class InFlowExtensionResponseProcessor implements ActionExecutionResponse @Override public ActionType getSupportedActionType() { - return ActionType.IN_FLOW_EXTENSION; + return ActionType.FLOW_EXTENSIONS; } @Override @@ -194,7 +195,7 @@ private OperationExecutionResult processOperation(PerformableOperation operation } // Check if operation is on a read-only area. - if (HierarchicalPrefixMatcher.isReadOnly(path)) { + if (InFlowExtensionPathUtil.isReadOnly(path)) { return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, "Path is in a read-only area. Modifications not allowed: " + path); } @@ -202,7 +203,7 @@ private OperationExecutionResult processOperation(PerformableOperation operation // Route to appropriate handler based on path prefix. if (path.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { return handlePropertyOperation(operation, pathTypeAnnotations, pendingProperties); - } else if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + } else if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX)) { return handleUserClaimOperation(operation, pendingClaims, tenantDomain); } else if (path.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { return handleUserCredentialOperation(operation, pendingCredentials); @@ -210,7 +211,8 @@ private OperationExecutionResult processOperation(PerformableOperation operation return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, "Unknown path prefix. Supported: " + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX + - ", " + InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX + + ", " + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + "" + + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX + ", " + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); } @@ -285,8 +287,7 @@ private OperationExecutionResult handlePropertyOperation(PerformableOperation op private OperationExecutionResult handleUserClaimOperation(PerformableOperation operation, Map pendingClaims, String tenantDomain) { - String claimUri = extractNameFromPath(operation.getPath(), - InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX); + String claimUri = extractClaimUriFromPath(operation.getPath()); if (claimUri == null || claimUri.isEmpty()) { return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, @@ -402,6 +403,43 @@ private String extractNameFromPath(String path, String prefix) { return remaining; } + /** + * Extract the claim URI from an external-format claim path. + * Accepts the selector form {@code /user/claims[uri=]}. + * Returns {@code null} if the path is null or does not match the expected format. + */ + private String extractClaimUriFromPath(String path) { + + if (path == null) { + return null; + } + if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) + && path.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { + return path.substring( + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), + path.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); + } + return null; + } + + /** + * Normalize an external-format claim path to the internal format for encryption checks. + * Converts {@code /user/claims[uri=]} to {@code /user/claims/}. + * All other paths are returned unchanged. + */ + private static String normalizeToInternalPath(String externalPath) { + + if (externalPath != null + && externalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) + && externalPath.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { + String claimUri = externalPath.substring( + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), + externalPath.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); + return InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX + claimUri; + } + return externalPath; + } + @Override public ActionExecutionStatus processIncompleteResponse(FlowContext flowContext, ActionExecutionResponseContext responseContext) @@ -466,7 +504,7 @@ private void validateRedirectPresent(String redirectUrl) InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) .resultMessage( "INCOMPLETE response from In-Flow Extension is missing a REDIRECT operation.") - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.FAILED)); } @@ -507,7 +545,7 @@ private void logIncompleteSuccess() { InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) .resultMessage( "In-Flow Extension INCOMPLETE response processed. Redirect URL stored in flow context.") - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); } @@ -634,8 +672,9 @@ private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation return operation; } - // Check if this operation path has encryption enabled via modify paths in AccessConfig. - if (!accessConfig.isModifyPathEncrypted(operation.getPath())) { + // Normalize external claim path to internal format before checking encryption flags. + String internalPath = normalizeToInternalPath(operation.getPath()); + if (!accessConfig.isModifyPathEncrypted(internalPath)) { return operation; } @@ -678,7 +717,7 @@ private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation InFlowExtensionConstants.Log.COMPONENT_ID, InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) .resultMessage("Failed to decrypt inbound JWE value for modify path.") - .configParam("actionType", ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) .inputParam("path", operation.getPath()) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.FAILED)); @@ -701,7 +740,7 @@ private void emitEncryptionContractViolation(String path, String reason) { InFlowExtensionConstants.Log.COMPONENT_ID, InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) .resultMessage(reason) - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.IN_FLOW_EXTENSION.getDisplayName()) + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) .inputParam("path", path) .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) .resultStatus(DiagnosticLog.ResultStatus.FAILED)); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java index d456fbfd6861..f3b390d10543 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/JWEEncryptionUtil.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import com.nimbusds.jose.EncryptionMethod; import com.nimbusds.jose.JOSEException; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java index 5d0de0248d4e..f3bf8654fada 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtil.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java index 9c23f6ce71c8..77ce34a3e158 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionDataHolder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.internal; +package org.wso2.carbon.identity.flow.extensions.internal; import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java similarity index 92% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java index 65cefb682f07..93a9995e678c 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/internal/InFlowExtensionServiceComponent.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.internal; +package org.wso2.carbon.identity.flow.extensions.internal; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -37,17 +37,17 @@ import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; -import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionExecutor; -import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionRequestBuilder; -import org.wso2.carbon.identity.flow.inflow.extensions.executor.InFlowExtensionResponseProcessor; -import org.wso2.carbon.identity.flow.inflow.extensions.management.InFlowExtensionActionConverter; -import org.wso2.carbon.identity.flow.inflow.extensions.management.InFlowExtensionActionDTOModelResolver; +import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionExecutor; +import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionRequestBuilder; +import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionResponseProcessor; +import org.wso2.carbon.identity.flow.extensions.management.InFlowExtensionActionConverter; +import org.wso2.carbon.identity.flow.extensions.management.InFlowExtensionActionDTOModelResolver; /** * OSGi declarative services component which registers the In-Flow Extension services. */ @Component( - name = "flow.inflow.extensions.component", + name = "flow.extensions.component", immediate = true) public class InFlowExtensionServiceComponent { diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java similarity index 87% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java index ab23dd441f2f..26e7db244e07 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionConverter.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java @@ -16,28 +16,28 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.management; +package org.wso2.carbon.identity.flow.extensions.management; import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.action.management.api.model.ActionDTO; import org.wso2.carbon.identity.action.management.api.model.ActionProperty; import org.wso2.carbon.identity.action.management.api.service.ActionConverter; import org.wso2.carbon.identity.certificate.management.model.Certificate; -import org.wso2.carbon.identity.flow.inflow.extensions.model.AccessConfig; -import org.wso2.carbon.identity.flow.inflow.extensions.model.Encryption; -import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; -import org.wso2.carbon.identity.flow.inflow.extensions.model.InFlowExtensionAction; +import org.wso2.carbon.identity.flow.extensions.model.AccessConfig; +import org.wso2.carbon.identity.flow.extensions.model.Encryption; +import org.wso2.carbon.identity.flow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.extensions.model.InFlowExtensionAction; import java.util.HashMap; import java.util.List; import java.util.Map; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; /** * ActionConverter implementation for In-Flow Extension actions. @@ -52,7 +52,7 @@ public class InFlowExtensionActionConverter implements ActionConverter { @Override public Action.ActionTypes getSupportedActionType() { - return Action.ActionTypes.IN_FLOW_EXTENSION; + return Action.ActionTypes.FLOW_EXTENSIONS; } /** diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java similarity index 95% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java index dd8e6a7609f8..2519b1edba66 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/management/InFlowExtensionActionDTOModelResolver.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.management; +package org.wso2.carbon.identity.flow.extensions.management; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -33,7 +33,7 @@ import org.wso2.carbon.identity.certificate.management.exception.CertificateMgtException; import org.wso2.carbon.identity.certificate.management.model.Certificate; import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; -import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.extensions.model.ContextPath; import java.io.IOException; import java.util.ArrayList; @@ -44,14 +44,14 @@ import java.util.Set; import static java.util.Collections.emptyList; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; -import static org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; +import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; /** * ActionDTOModelResolver implementation for In-Flow Extension actions. @@ -85,7 +85,7 @@ public InFlowExtensionActionDTOModelResolver(CertificateManagementService certif @Override public Action.ActionTypes getSupportedActionType() { - return Action.ActionTypes.IN_FLOW_EXTENSION; + return Action.ActionTypes.FLOW_EXTENSIONS; } @Override diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java similarity index 98% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java index f65e85adeb13..700fd6e8a317 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java @@ -16,9 +16,9 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; import java.util.ArrayList; import java.util.Arrays; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java index d26d2ca2780b..4ff222c0b3bb 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeMetadata.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; import java.util.Collections; import java.util.List; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java similarity index 98% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java index e00a7fef97e7..b657b6ea8370 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; import java.util.Collections; import java.util.List; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java similarity index 92% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java index 8765e89079d8..92d2f46c5653 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeService.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java @@ -16,9 +16,9 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; /** * Public-API entry point for retrieving the controlled In-Flow Extension context tree. diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java index d970a3245add..0b34091a3d72 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfig.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java @@ -16,9 +16,9 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; -import org.wso2.carbon.identity.flow.inflow.extensions.executor.PathTypeAnnotationUtil; +import org.wso2.carbon.identity.flow.extensions.executor.PathTypeAnnotationUtil; import java.util.Collections; import java.util.List; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java similarity index 96% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java index 603ea3c159ec..e9ccb9eec7ef 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/ContextPath.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java similarity index 96% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java index a9296bcfb4e3..c93057302c8d 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/Encryption.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.wso2.carbon.identity.certificate.management.model.Certificate; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java index e1ddc736a0ba..ae1828d2d84f 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/FlowContextHandoverConfig.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java @@ -16,9 +16,9 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; import java.util.Collections; import java.util.Set; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java index 30a297000df3..b6369eda1166 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionAction.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.action.management.api.model.EndpointConfig; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java similarity index 72% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java index c1f3a1ccabee..0ead5b5c4b9e 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEvent.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java @@ -16,14 +16,13 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; +import com.fasterxml.jackson.annotation.JsonInclude; import org.wso2.carbon.identity.action.execution.api.model.Application; import org.wso2.carbon.identity.action.execution.api.model.Event; import org.wso2.carbon.identity.action.execution.api.model.Organization; -import org.wso2.carbon.identity.action.execution.api.model.Request; import org.wso2.carbon.identity.action.execution.api.model.Tenant; -import org.wso2.carbon.identity.action.execution.api.model.User; import org.wso2.carbon.identity.action.execution.api.model.UserStore; import java.util.Collections; @@ -36,53 +35,43 @@ */ public class InFlowExtensionEvent extends Event { - private final String flowType; - private final String flowId; + private final InFlowExtensionFlow flow; private final String callbackUrl; private final String portalUrl; private final Map flowProperties; private InFlowExtensionEvent(Builder builder) { - this.request = builder.request; this.tenant = builder.tenant; this.organization = builder.organization; - this.user = builder.user; this.userStore = builder.userStore; this.application = builder.application; - this.flowType = builder.flowType; - this.flowId = builder.flowId; + this.flow = builder.flow; this.callbackUrl = builder.callbackUrl; this.portalUrl = builder.portalUrl; - this.flowProperties = builder.flowProperties != null ? + this.flowProperties = builder.flowProperties != null ? Collections.unmodifiableMap(new HashMap<>(builder.flowProperties)) : Collections.emptyMap(); } /** - * Get the flow type. + * Get the flow context (type, ID, and user). * - * @return The flow type (e.g., "REGISTRATION", "PASSWORD_RESET"). + * @return The flow context object, or {@code null} if not set. */ - public String getFlowType() { + @JsonInclude(JsonInclude.Include.NON_NULL) + public InFlowExtensionFlow getFlow() { - return flowType; - } - - /** - * Get the flow identifier (context identifier of the executing flow). - * - * @return The flow identifier. - */ - public String getFlowId() { - - return flowId; + return flow; } /** * Get the callback URL for the flow, if exposed. + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed callbackUrl with no + * context value is serialized as {@code ""} rather than omitted. * - * @return The callback URL, or null if not exposed. + * @return The callback URL, or {@code null} if not exposed. */ + @JsonInclude(JsonInclude.Include.NON_NULL) public String getCallbackUrl() { return callbackUrl; @@ -90,9 +79,12 @@ public String getCallbackUrl() { /** * Get the portal URL for the flow, if exposed. + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed portalUrl with no + * context value is serialized as {@code ""} rather than omitted. * - * @return The portal URL, or null if not exposed. + * @return The portal URL, or {@code null} if not exposed. */ + @JsonInclude(JsonInclude.Include.NON_NULL) public String getPortalUrl() { return portalUrl; @@ -113,21 +105,18 @@ public Map getFlowProperties() { */ public static class Builder { - private Request request; + private InFlowExtensionFlow flow; private Tenant tenant; private Organization organization; - private User user; private UserStore userStore; private Application application; - private String flowType; - private String flowId; private String callbackUrl; private String portalUrl; private Map flowProperties; - public Builder request(Request request) { + public Builder flow(InFlowExtensionFlow flow) { - this.request = request; + this.flow = flow; return this; } @@ -143,12 +132,6 @@ public Builder organization(Organization organization) { return this; } - public Builder user(User user) { - - this.user = user; - return this; - } - public Builder userStore(UserStore userStore) { this.userStore = userStore; @@ -161,18 +144,6 @@ public Builder application(Application application) { return this; } - public Builder flowType(String flowType) { - - this.flowType = flowType; - return this; - } - - public Builder flowId(String flowId) { - - this.flowId = flowId; - return this; - } - public Builder callbackUrl(String callbackUrl) { this.callbackUrl = callbackUrl; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java new file mode 100644 index 000000000000..78fa97678e00 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extensions.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Models the {@code flow} object nested inside the In-Flow Extension Event. + * Groups flow-scoped context: the flow type, flow identifier, and the acting user. + */ +public class InFlowExtensionFlow { + + private final String flowType; + private final String flowId; + private final InFlowUser user; + + private InFlowExtensionFlow(Builder builder) { + + this.flowType = builder.flowType; + this.flowId = builder.flowId; + this.user = builder.user; + } + + /** + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed flowType with no + * context value is serialized as {@code ""} rather than omitted. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getFlowType() { + + return flowType; + } + + public String getFlowId() { + + return flowId; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public InFlowUser getUser() { + + return user; + } + + public static class Builder { + + private String flowType; + private String flowId; + private InFlowUser user; + + public Builder flowType(String flowType) { + + this.flowType = flowType; + return this; + } + + public Builder flowId(String flowId) { + + this.flowId = flowId; + return this; + } + + public Builder user(InFlowUser user) { + + this.user = user; + return this; + } + + public InFlowExtensionFlow build() { + + return new InFlowExtensionFlow(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java similarity index 94% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java index 0f36f68c0a69..6ff67856fc67 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionRequest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.wso2.carbon.identity.action.execution.api.model.Request; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java new file mode 100644 index 000000000000..9889b24292e0 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extensions.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.wso2.carbon.identity.action.execution.api.model.User; + +/** + * In-Flow Extension specific view of {@link User} that serializes the user identifier as + * {@code "userId"} rather than the shared model's {@code "id"} field name. + */ +public class InFlowUser extends User { + + public InFlowUser(User.Builder builder) { + + super(builder); + } + + @Override + @JsonProperty("userId") + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getId() { + + return super.getId(); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java similarity index 96% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java index 73eeb00ac073..6c07b714dd8d 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResult.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java similarity index 96% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java index 1a307c79989b..cc18940b10de 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/util/InFlowExtensionContextFilterUtil.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java @@ -16,14 +16,14 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.util; +package org.wso2.carbon.identity.flow.extensions.util; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants.HandoverPolicy; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.HandoverPolicy; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; import java.beans.IntrospectionException; import java.beans.Introspector; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java new file mode 100644 index 000000000000..2801917a68ca --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extensions.util; + +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; + +import java.util.List; + +/** + * Path-matching utilities for In-Flow Extension access control. + */ +public final class InFlowExtensionPathUtil { + + private InFlowExtensionPathUtil() { + + } + + /** + * Returns {@code true} if the path is in the read-only {@code /flow/} area. + */ + public static boolean isReadOnly(String path) { + + if (path == null) { + return false; + } + return path.startsWith(InFlowExtensionConstants.FLOW_PREFIX); + } + + /** + * Returns {@code true} if at least one leaf path in {@code leafPaths} starts with + * {@code areaPrefix}. Used as an area-gate before iterating a data block. + */ + public static boolean anyExposedUnder(String areaPrefix, List leafPaths) { + + if (areaPrefix == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + for (String path : leafPaths) { + if (path != null && path.startsWith(areaPrefix)) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if {@code leafPath} is present in {@code leafPaths}. + */ + public static boolean isExposedPath(String leafPath, List leafPaths) { + + if (leafPath == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + return leafPaths.contains(leafPath); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java similarity index 93% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java index df63f77f8cb3..64bf3b6c8931 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/InFlowExtensionTestUtils.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java @@ -16,9 +16,9 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions; +package org.wso2.carbon.identity.flow.extensions; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; import java.util.Arrays; import java.util.HashSet; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java similarity index 93% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java index 1264361abf1b..bafe798517b3 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionExecutorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -34,9 +34,9 @@ import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; -import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; @@ -157,7 +157,7 @@ public void testExecuteDisabledExecution() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(false); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(false); ExecutorResponse response = executor.execute(context); @@ -176,12 +176,12 @@ public void testExecuteSuccess() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); ActionExecutionStatus successStatus = mock(ActionExecutionStatus.class); when(successStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.SUCCESS); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(successStatus); @@ -200,14 +200,14 @@ public void testExecuteFailed() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Failure failure = new Failure("risk_detected", "Risk score exceeds threshold"); ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); when(failedStatus.getResponse()).thenReturn(failure); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(failedStatus); @@ -228,14 +228,14 @@ public void testExecuteFailedNoDescription() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Failure failure = new Failure("risk_detected", null); ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); when(failedStatus.getResponse()).thenReturn(failure); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(failedStatus); @@ -254,14 +254,14 @@ public void testExecuteFailedBothNull() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Failure failure = new Failure(null, null); ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); when(failedStatus.getResponse()).thenReturn(failure); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(failedStatus); @@ -282,14 +282,14 @@ public void testExecuteError() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Error error = new Error("internal_error", "DB connection failed"); ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); when(errorStatus.getResponse()).thenReturn(error); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(errorStatus); @@ -311,14 +311,14 @@ public void testExecuteErrorNoDescription() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Error error = new Error("internal_error", null); ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); when(errorStatus.getResponse()).thenReturn(error); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(errorStatus); @@ -339,14 +339,14 @@ public void testExecuteErrorBothNull() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); Error error = new Error(null, null); ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); when(errorStatus.getResponse()).thenReturn(error); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(errorStatus); @@ -373,12 +373,12 @@ public void testExecuteIncompleteWithoutRedirectUrlReturnsError() throws Excepti metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(incompleteStatus); @@ -399,7 +399,7 @@ public void testExecuteIncompleteWithRedirectUrlReturnsExternalRedirection() thr // Set a context identifier so the OTFI collision-guard has something to compare against. context.setContextIdentifier("original-flow-id"); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); @@ -407,7 +407,7 @@ public void testExecuteIncompleteWithRedirectUrlReturnsExternalRedirection() thr // Simulate the response processor stashing the redirect URL into the FlowContext // during the actionExecutorService.execute call. when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenAnswer(invocation -> { FlowContext fc = invocation.getArgument(2); @@ -449,13 +449,13 @@ public void testExecuteIncompleteRedirectAppendsFlowIdWithAmpersandWhenUrlHasQue FlowExecutionContext context = createContextWithMetadata(metadata); context.setContextIdentifier("original-flow-id"); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenAnswer(invocation -> { FlowContext fc = invocation.getArgument(2); @@ -483,13 +483,13 @@ public void testExecuteIncompleteRedirectEmptyUrlReturnsError() throws Exception metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenAnswer(invocation -> { FlowContext fc = invocation.getArgument(2); @@ -514,9 +514,9 @@ public void testExecuteNullStatus() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenReturn(null); @@ -534,9 +534,9 @@ public void testExecuteActionException() throws Exception { metadata.put("actionId", "test-action-001"); FlowExecutionContext context = createContextWithMetadata(metadata); - when(actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); when(actionExecutorService.execute( - eq(ActionType.IN_FLOW_EXTENSION), eq("test-action-001"), + eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), any(FlowContext.class), eq("carbon.super"))) .thenThrow(new ActionExecutionException("Connection timeout")); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java similarity index 75% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java index 489687ed4783..4383ffef407f 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionRequestBuilderTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.mockito.MockedStatic; import org.testng.annotations.AfterMethod; @@ -32,10 +32,10 @@ import org.wso2.carbon.identity.action.execution.api.model.Operation; import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; -import org.wso2.carbon.identity.flow.inflow.extensions.model.*; +import org.wso2.carbon.identity.flow.extensions.model.*; import org.wso2.carbon.identity.certificate.management.model.Certificate; import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; @@ -87,7 +87,7 @@ public void tearDown() { @Test public void testGetSupportedActionType() { - assertEquals(requestBuilder.getSupportedActionType(), ActionType.IN_FLOW_EXTENSION); + assertEquals(requestBuilder.getSupportedActionType(), ActionType.FLOW_EXTENSIONS); } // ========================= buildActionExecutionRequest — basics ========================= @@ -112,7 +112,7 @@ public void testBuildRequestWithMinimalContext() throws ActionExecutionRequestBu ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); assertNotNull(request); - assertEquals(request.getActionType(), ActionType.IN_FLOW_EXTENSION); + assertEquals(request.getActionType(), ActionType.FLOW_EXTENSIONS); assertNotNull(request.getEvent()); } @@ -130,7 +130,7 @@ public void testBuildRequestUsesEmptyExposeWhenExposeIsNull() // With empty expose, no context areas should be included in the event. InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); assertNotNull(event); - assertNull(event.getFlowType()); + assertNull(event.getFlow().getFlowType()); // flowProperties defaults to emptyMap() in the builder — verify it is empty. assertTrue(event.getFlowProperties().isEmpty()); } @@ -194,15 +194,16 @@ public void testBuildRequestWithNonInFlowActionFallsBackToMinimalPayload() ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); assertNotNull(request); - assertEquals(request.getActionType(), ActionType.IN_FLOW_EXTENSION); + assertEquals(request.getActionType(), ActionType.FLOW_EXTENSIONS); assertEquals(request.getAllowedOperations().size(), 1); assertEquals(request.getAllowedOperations().get(0).getOp(), Operation.REDIRECT); InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); assertNotNull(event); - assertEquals(event.getFlowId(), execCtx.getContextIdentifier()); - assertNull(event.getUser()); - assertNull(event.getFlowType()); + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowId(), execCtx.getContextIdentifier()); + assertNull(event.getFlow().getUser()); + assertNull(event.getFlow().getFlowType()); } @Test @@ -450,9 +451,9 @@ public void testExposeFilteringOnlyExposedAreaIncluded() InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); // User should NOT be in the event since /user/ is not exposed. - assertNull(event.getUser()); + assertNull(event.getFlow().getUser()); // Flow type should be present. - assertNotNull(event.getFlowType()); + assertNotNull(event.getFlow().getFlowType()); // Tenant should be present. assertNotNull(event.getTenant()); } @@ -536,12 +537,247 @@ public void testExposeFilteringSpecificClaim() flowContext, mockReqCtx(accessConfig, null)); InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getUser()); + assertNotNull(event.getFlow().getUser()); // Only the email claim should be present, not the country claim. - List claims = event.getUser().getClaims(); + List claims = event.getFlow().getUser().getClaims(); assertEquals(claims.size(), 1); } + // ========================= Null-to-empty-string: consistent contract ========================= + + @Test + public void testExposedCallbackUrlNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // callbackUrl is NOT set — context returns null. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/callbackUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getCallbackUrl(), "", + "Exposed callbackUrl must be '' when context value is null"); + } + + @Test + public void testExposedPortalUrlNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // portalUrl is NOT set — context returns null. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/portalUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getPortalUrl(), "", + "Exposed portalUrl must be '' when context value is null"); + } + + @Test + public void testExposedFlowTypeNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + // flowType is not set in the minimal context. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getFlow().getFlowType(), "", + "Exposed flowType must be '' when context value is null"); + } + + @Test + public void testExposedTenantDomainNullYieldsEmptyStrings() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + execCtx.setTenantDomain(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/tenantDomain", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getTenant(), + "Tenant must be present when /flow/tenantDomain is exposed"); + assertEquals(event.getTenant().getId(), "", + "Tenant id must be '' when tenantDomain is null"); + assertEquals(event.getTenant().getName(), "", + "Tenant name must be '' when tenantDomain is null"); + } + + @Test + public void testExposedApplicationIdNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + // applicationId is not set in the minimal context. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/applicationId", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getApplication(), + "Application must be present when /flow/applicationId is exposed"); + assertEquals(event.getApplication().getId(), "", + "Application id must be '' when applicationId is null"); + } + + @Test + public void testExposedUserIdNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.getFlowUser().setUserId(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/userId", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + assertEquals(event.getFlow().getUser().getId(), "", + "User id must be '' when userId is null and path is exposed"); + } + + @Test + public void testExposedUserStoreDomainNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.getFlowUser().setUserStoreDomain(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/userStoreDomain", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser(), "User must be present when /user/userStoreDomain is exposed"); + assertNotNull(event.getFlow().getUser().getUserStoreDomain(), + "UserStore must be present when /user/userStoreDomain is exposed"); + assertEquals(event.getFlow().getUser().getUserStoreDomain().getName(), "", + "UserStore name must be '' when userStoreDomain is null"); + } + + @Test + public void testExposedClaimAbsentFromClaimsMapYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose a claim URI that is NOT present in the flowUser's claims map. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/mobile", false), + new ContextPath("/user/userId", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 1); + org.wso2.carbon.identity.action.execution.api.model.UserClaim mobileClaim = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); + assertEquals(mobileClaim.getUri(), "http://wso2.org/claims/mobile"); + assertEquals(mobileClaim.getValue(), "", + "Exposed claim absent from claims map must yield ''"); + } + + @Test + public void testExposedPropertyAbsentFromPropertiesMapYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose a property key that is NOT present in the context properties map. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/properties/riskScore", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + assertTrue(event.getFlowProperties().containsKey("riskScore"), + "Exposed property absent from properties map must still appear"); + assertEquals(event.getFlowProperties().get("riskScore"), "", + "Exposed property absent from properties map must yield ''"); + } + + @Test + public void testExposedClaimWithNullValueYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Overwrite existing claim with null value. + execCtx.getFlowUser().getClaims().put("http://wso2.org/claims/email", null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 1); + org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); + assertEquals(emailClaim.getValue(), "", + "Claim value must be '' when source value is null"); + } + // ========================= Outbound encryption of properties and inputs ========================= @Test @@ -612,8 +848,8 @@ public void testClaimEncryptedWhenExposePathMarkedEncrypted() flowContext, mockReqCtx(accessConfig, encryption)); InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getUser()); - List claims = event.getUser().getClaims(); + assertNotNull(event.getFlow().getUser()); + List claims = event.getFlow().getUser().getClaims(); assertEquals(claims.size(), 2); org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = null; @@ -667,8 +903,8 @@ public void testCredentialEncryptedWhenExposePathMarkedEncrypted() flowContext, mockReqCtx(accessConfig, encryption)); InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getUser()); - Map eventCreds = event.getUser().getUserCredentials(); + assertNotNull(event.getFlow().getUser()); + Map eventCreds = event.getFlow().getUser().getUserCredentials(); assertNotNull(eventCreds); assertTrue(eventCreds.containsKey("password")); String credValue = new String(eventCreds.get("password")); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java index 8a698ac866f5..a88bce808e2a 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/InFlowExtensionResponseProcessorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.mockito.MockedStatic; import org.testng.annotations.AfterMethod; @@ -41,9 +41,9 @@ import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.inflow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.flow.inflow.extensions.model.ContextPath; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extensions.model.ContextPath; import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; @@ -100,7 +100,7 @@ public void tearDown() { @Test public void testGetSupportedActionType() { - assertEquals(responseProcessor.getSupportedActionType(), ActionType.IN_FLOW_EXTENSION); + assertEquals(responseProcessor.getSupportedActionType(), ActionType.FLOW_EXTENSIONS); } // ========================= processSuccessResponse — Property REPLACE ========================= @@ -342,7 +342,7 @@ public void testUserClaimReplace() throws ActionExecutionResponseProcessorExcept execCtx.getFlowUser().addClaim("http://wso2.org/claims/email", "old@example.com"); PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", "new@example.com"); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "new@example.com"); executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); Map pendingClaims = @@ -357,7 +357,7 @@ public void testUserClaimReplaceCreatesNewClaim() throws ActionExecutionResponse FlowExecutionContext execCtx = createFlowExecutionContext(); PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/country", "US"); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", "US"); executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); Map pendingClaims = @@ -373,7 +373,7 @@ public void testUserClaimReplaceStringifiesValue() throws ActionExecutionRespons // Numeric value should be stringified. PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/country", 42); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", 42); executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); Map pendingClaims = @@ -390,7 +390,7 @@ public void testUserClaimReplaceIdentityClaimRejected() // Identity claim should be rejected by the identity-claim prefix guard. PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/identity/accountLocked", "true"); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/identity/accountLocked]", "true"); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); @@ -414,7 +414,7 @@ public void testUserClaimReplaceNonExistentClaimRejected() // Claim not registered in the system should be rejected. PerformableOperation claimOp = createOperation( Operation.REPLACE, - "/user/claims/http://wso2.org/claims/nonexistent", "value"); + "/user/claims[uri=http://wso2.org/claims/nonexistent]", "value"); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); @@ -432,7 +432,7 @@ public void testUserClaimReplaceNonLocalDialectRejected() // Non-local dialect claim should be rejected by the local-dialect prefix guard. PerformableOperation claimOp = createOperation( Operation.REPLACE, - "/user/claims/urn:ietf:params:scim:schemas:core:2.0:User:name.givenName", "John"); + "/user/claims[uri=urn:ietf:params:scim:schemas:core:2.0:User:name.givenName]", "John"); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); @@ -447,7 +447,7 @@ public void testUserClaimReplaceNullValue() throws ActionExecutionResponseProces FlowExecutionContext execCtx = createFlowExecutionContext(); PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", null); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", null); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); @@ -461,7 +461,7 @@ public void testUserClaimReplaceEmptyClaimUri() throws ActionExecutionResponsePr FlowExecutionContext execCtx = createFlowExecutionContext(); - PerformableOperation claimOp = createOperation(Operation.REPLACE, "/user/claims/", "value"); + PerformableOperation claimOp = createOperation(Operation.REPLACE, "/user/claims[uri=]", "value"); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); @@ -476,7 +476,7 @@ public void testUserClaimReplaceNoFlowUser() throws ActionExecutionResponseProce execCtx.setFlowUser(null); PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims/http://wso2.org/claims/email", "test@email.com"); + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "test@email.com"); ActionExecutionStatus status = executeSuccessResponse( execCtx, claimOp, Collections.emptyMap()); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java index 409dc05acbfd..eb4f3a06588b 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/PathTypeAnnotationUtilTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.executor; import org.testng.annotations.Test; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java similarity index 82% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java index 422bf15a7ef6..b2b48f3c238f 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/FlowContextHandoverConfigTestHelper.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java @@ -16,10 +16,10 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; -import org.wso2.carbon.identity.flow.inflow.extensions.InFlowExtensionTestUtils; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.InFlowExtensionTestUtils; import java.util.Set; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java index 74c52d9003fd..69117af4a681 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java @@ -16,10 +16,10 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.metadata; +package org.wso2.carbon.identity.flow.extensions.metadata; import org.testng.annotations.Test; -import org.wso2.carbon.identity.flow.inflow.extensions.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; import java.util.Arrays; import java.util.HashSet; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java similarity index 99% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java index 47d30a127cf1..c763c07e29dd 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/AccessConfigTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.testng.annotations.Test; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java similarity index 83% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java index 97ad067bb1f3..2827cd16feb6 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/InFlowExtensionEventTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.testng.annotations.Test; @@ -39,16 +39,21 @@ public void testBuilderWithAllFields() { Map flowProperties = new HashMap<>(); flowProperties.put("riskScore", 85); - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() .flowType("REGISTRATION") .flowId("flow-id-123") + .build(); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flow(flow) .callbackUrl("https://example.com/callback") .portalUrl("https://example.com/portal") .flowProperties(flowProperties) .build(); - assertEquals(event.getFlowType(), "REGISTRATION"); - assertEquals(event.getFlowId(), "flow-id-123"); + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowType(), "REGISTRATION"); + assertEquals(event.getFlow().getFlowId(), "flow-id-123"); assertEquals(event.getCallbackUrl(), "https://example.com/callback"); assertEquals(event.getPortalUrl(), "https://example.com/portal"); assertEquals(event.getFlowProperties().get("riskScore"), 85); @@ -57,14 +62,19 @@ public void testBuilderWithAllFields() { @Test public void testOptionalFieldsDefaultToNull() { - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() .flowType("LOGIN") .flowId("flow-id-456") + .build(); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flow(flow) .flowProperties(null) .build(); - assertEquals(event.getFlowType(), "LOGIN"); - assertEquals(event.getFlowId(), "flow-id-456"); + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowType(), "LOGIN"); + assertEquals(event.getFlow().getFlowId(), "flow-id-456"); assertNull(event.getCallbackUrl()); assertNull(event.getPortalUrl()); assertNotNull(event.getFlowProperties()); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java similarity index 97% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java index 8e1b3f1fa8e2..ba4fa2992d3e 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/model/OperationExecutionResultTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.model; +package org.wso2.carbon.identity.flow.extensions.model; import org.testng.annotations.Test; import org.wso2.carbon.identity.action.execution.api.model.Operation; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java similarity index 67% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java index ea938ea70f5c..e920a6035d1b 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcherTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java @@ -16,7 +16,7 @@ * under the License. */ -package org.wso2.carbon.identity.flow.inflow.extensions.executor; +package org.wso2.carbon.identity.flow.extensions.util; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -30,9 +30,9 @@ import static org.testng.Assert.assertTrue; /** - * Unit tests for {@link HierarchicalPrefixMatcher}. + * Unit tests for {@link InFlowExtensionPathUtil}. */ -public class HierarchicalPrefixMatcherTest { +public class InFlowExtensionPathUtilTest { // ========================= isReadOnly ========================= @@ -52,7 +52,7 @@ public Object[][] readOnlyPaths() { @Test(dataProvider = "readOnlyPaths") public void testIsReadOnly(String path, boolean expected) { - assertEquals(HierarchicalPrefixMatcher.isReadOnly(path), expected); + assertEquals(InFlowExtensionPathUtil.isReadOnly(path), expected); } // ========================= anyExposedUnder ========================= @@ -63,44 +63,43 @@ public void testAnyExposedUnderMatchesLeafUnderPrefix() { List leafPaths = Arrays.asList( "/user/claims/http://wso2.org/claims/email", "/properties/riskScore"); - assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); - assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/properties/", leafPaths)); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); } @Test public void testAnyExposedUnderNoMatch() { List leafPaths = Arrays.asList("/flow/tenantDomain", "/flow/applicationId"); - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/properties/", leafPaths)); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); } @Test public void testAnyExposedUnderNullPrefix() { - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder(null, + assertFalse(InFlowExtensionPathUtil.anyExposedUnder(null, Arrays.asList("/user/claims/email"))); } @Test public void testAnyExposedUnderNullList() { - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", null)); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", null)); } @Test public void testAnyExposedUnderEmptyList() { - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", Collections.emptyList())); } @Test public void testAnyExposedUnderDoesNotMatchShortPath() { - // A leaf path of "/user/userId" should NOT be matched by area prefix "/user/claims/" List leafPaths = Collections.singletonList("/user/userId"); - assertFalse(HierarchicalPrefixMatcher.anyExposedUnder("/user/claims/", leafPaths)); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); } @Test @@ -109,7 +108,7 @@ public void testAnyExposedUnderMultipleLeafsOneMatches() { List leafPaths = Arrays.asList( "/flow/tenantDomain", "/user/credentials/password"); - assertTrue(HierarchicalPrefixMatcher.anyExposedUnder("/user/credentials/", leafPaths)); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/credentials/", leafPaths)); } // ========================= isExposedPath ========================= @@ -121,45 +120,44 @@ public void testIsExposedPathExactMatch() { "/user/claims/http://wso2.org/claims/email", "/flow/tenantDomain", "/user/userId"); - assertTrue(HierarchicalPrefixMatcher.isExposedPath( + assertTrue(InFlowExtensionPathUtil.isExposedPath( "/user/claims/http://wso2.org/claims/email", leafPaths)); - assertTrue(HierarchicalPrefixMatcher.isExposedPath("/flow/tenantDomain", leafPaths)); - assertTrue(HierarchicalPrefixMatcher.isExposedPath("/user/userId", leafPaths)); + assertTrue(InFlowExtensionPathUtil.isExposedPath("/flow/tenantDomain", leafPaths)); + assertTrue(InFlowExtensionPathUtil.isExposedPath("/user/userId", leafPaths)); } @Test public void testIsExposedPathNoMatch() { List leafPaths = Arrays.asList("/flow/tenantDomain", "/user/userId"); - assertFalse(HierarchicalPrefixMatcher.isExposedPath( + assertFalse(InFlowExtensionPathUtil.isExposedPath( "/user/claims/http://wso2.org/claims/email", leafPaths)); } @Test public void testIsExposedPathNullPath() { - assertFalse(HierarchicalPrefixMatcher.isExposedPath(null, + assertFalse(InFlowExtensionPathUtil.isExposedPath(null, Arrays.asList("/user/userId"))); } @Test public void testIsExposedPathNullList() { - assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/userId", null)); + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", null)); } @Test public void testIsExposedPathEmptyList() { - assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/userId", + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", Collections.emptyList())); } @Test public void testIsExposedPathPrefixNotSufficient() { - // An area prefix "/user/claims/" must NOT match when the list only has a leaf under it. List leafPaths = Collections.singletonList("/user/claims/http://wso2.org/claims/email"); - assertFalse(HierarchicalPrefixMatcher.isExposedPath("/user/claims/", leafPaths)); + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/claims/", leafPaths)); } } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml similarity index 100% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/repository/conf/carbon.xml rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml new file mode 100644 index 000000000000..dcba439c641d --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java deleted file mode 100644 index e61d75868057..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/main/java/org/wso2/carbon/identity/flow/inflow/extensions/executor/HierarchicalPrefixMatcher.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.inflow.extensions.executor; - -import java.util.List; - -/** - * Utility class for hierarchical prefix-based path matching used in In-Flow Extension - * access control. - * Provides area-gate checks via {@link #anyExposedUnder(String, List)} and exact - * leaf-path checks via {@link #isExposedPath(String, List)}. - */ -public final class HierarchicalPrefixMatcher { - - // Context area prefix constants - public static final String USER_PREFIX = "/user/"; - public static final String USER_CLAIMS_PREFIX = "/user/claims/"; - public static final String USER_CREDENTIALS_PREFIX = "/user/credentials/"; - public static final String USER_ID_PATH = "/user/userId"; - public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; - - public static final String PROPERTIES_PREFIX = "/properties/"; - - public static final String FLOW_PREFIX = "/flow/"; - public static final String FLOW_TENANT_PATH = "/flow/tenantDomain"; - public static final String FLOW_APP_ID_PATH = "/flow/applicationId"; - public static final String FLOW_TYPE_PATH = "/flow/flowType"; - public static final String FLOW_CALLBACK_URL_PATH = "/flow/callbackUrl"; - public static final String FLOW_PORTAL_URL_PATH = "/flow/portalUrl"; - - private HierarchicalPrefixMatcher() { - - } - - /** - * Check if a path is read-only (in /flow/ area). - * - * @param path The path to check - * @return true if the path is in a read-only area - */ - public static boolean isReadOnly(String path) { - - if (path == null) { - return false; - } - return path.startsWith(FLOW_PREFIX); - } - - /** - * Check if any leaf path in the list falls under the given area prefix. - * - *

Used as an area-gate check before iterating over a data block — e.g., to decide - * whether to include any claims, credentials, or properties in the outgoing request. - * The {@code areaPrefix} always ends with {@code /} (e.g. {@code /user/claims/}). - * The {@code leafPaths} list contains only exact leaf paths with no trailing {@code /}.

- * - * @param areaPrefix The area prefix to check (must end with {@code /}). - * @param leafPaths The list of exposed leaf paths. - * @return {@code true} if at least one leaf path starts with the area prefix. - */ - public static boolean anyExposedUnder(String areaPrefix, List leafPaths) { - - if (areaPrefix == null || leafPaths == null || leafPaths.isEmpty()) { - return false; - } - for (String path : leafPaths) { - if (path != null && path.startsWith(areaPrefix)) { - return true; - } - } - return false; - } - - /** - * Check if an exact leaf path is present in the expose list. - * - *

Used for leaf-level filtering — e.g., to decide whether a specific claim URI, - * credential key, or scalar field should be included in the outgoing request. - * The {@code leafPath} has no trailing {@code /}. - * The {@code leafPaths} list contains only exact leaf paths.

- * - * @param leafPath The exact path to look up. - * @param leafPaths The list of exposed leaf paths. - * @return {@code true} if the path is present in the list. - */ - public static boolean isExposedPath(String leafPath, List leafPaths) { - - if (leafPath == null || leafPaths == null || leafPaths.isEmpty()) { - return false; - } - return leafPaths.contains(leafPath); - } - -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml deleted file mode 100644 index 4c8191946b3e..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions/src/test/resources/testng.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/components/flow-orchestration-framework/pom.xml b/components/flow-orchestration-framework/pom.xml index a96984613bea..1cc64f59b836 100644 --- a/components/flow-orchestration-framework/pom.xml +++ b/components/flow-orchestration-framework/pom.xml @@ -37,7 +37,7 @@ org.wso2.carbon.identity.flow.mgt org.wso2.carbon.identity.flow.execution.engine - org.wso2.carbon.identity.flow.inflow.extensions + org.wso2.carbon.identity.flow.extensions diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml similarity index 88% rename from features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml rename to features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml index 08505499f447..4f84e1d51a60 100644 --- a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.inflow.extensions.server.feature/pom.xml +++ b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml @@ -20,12 +20,12 @@ org.wso2.carbon.identity.framework flow-orchestration-framework-feature - 7.11.70-SNAPSHOT + 7.11.94-SNAPSHOT ../pom.xml 4.0.0 - org.wso2.carbon.identity.flow.inflow.extensions.server.feature + org.wso2.carbon.identity.flow.extensions.server.feature pom Flow InFlow Extensions Feature https://wso2.com @@ -34,7 +34,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.inflow.extensions + org.wso2.carbon.identity.flow.extensions @@ -52,7 +52,7 @@ p2-feature-gen - org.wso2.carbon.identity.flow.inflow.extensions.server + org.wso2.carbon.identity.flow.extensions.server ../../etc/feature.properties @@ -61,7 +61,7 @@ - org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.inflow.extensions + org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extensions diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml index 62ffa867c08d..95e531b7c45d 100644 --- a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml +++ b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml @@ -43,6 +43,11 @@ org.wso2.carbon.identity.flow.execution.engine.server.feature zip
+ + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.extensions.server.feature + zip + @@ -67,6 +72,9 @@ org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.execution.engine.server.feature + + org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extensions.server.feature + diff --git a/features/flow-orchestration-framework/pom.xml b/features/flow-orchestration-framework/pom.xml index 38c83202767e..1ebf9f0ae13d 100644 --- a/features/flow-orchestration-framework/pom.xml +++ b/features/flow-orchestration-framework/pom.xml @@ -35,7 +35,7 @@ org.wso2.carbon.identity.flow.mgt.server.feature org.wso2.carbon.identity.flow.orchestration.framework.feature org.wso2.carbon.identity.flow.execution.engine.server.feature - org.wso2.carbon.identity.flow.inflow.extensions.server.feature + org.wso2.carbon.identity.flow.extensions.server.feature diff --git a/pom.xml b/pom.xml index 886cafe3421e..f4111d633583 100644 --- a/pom.xml +++ b/pom.xml @@ -856,7 +856,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.inflow.extensions.server.feature + org.wso2.carbon.identity.flow.extensions.server.feature zip ${project.version} @@ -1949,7 +1949,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.inflow.extensions + org.wso2.carbon.identity.flow.extensions ${project.version} From ea66f241b45ba913424e211f62af16b6050059e5 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Sun, 24 May 2026 10:58:37 +0530 Subject: [PATCH 03/17] change to "id" from "useId" --- .../engine/graph/TaskExecutionNode.java | 6 +-- .../flow/execution/engine/model/FlowUser.java | 10 ++--- .../util/AuthenticationAssertionUtils.java | 2 +- .../AuthenticationAssertionUtilsTest.java | 4 +- .../engine/util/FlowEngineUtilsTest.java | 4 +- .../extensions/InFlowExtensionConstants.java | 4 +- .../InFlowExtensionRequestBuilder.java | 6 +-- .../InFlowExtensionContextTreeBuilder.java | 8 ++-- .../extensions/model/InFlowExtensionFlow.java | 9 ++-- .../flow/extensions/model/InFlowUser.java | 43 ------------------- .../extensions/InFlowExtensionTestUtils.java | 2 +- .../InFlowExtensionRequestBuilderTest.java | 12 +++--- .../InFlowExtensionResponseProcessorTest.java | 2 +- ...InFlowExtensionContextTreeBuilderTest.java | 6 +-- 14 files changed, 38 insertions(+), 80 deletions(-) delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java index e08b8e3accf2..669577f412e5 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java @@ -208,7 +208,7 @@ private NodeResponse handleCompleteStatus(FlowExecutionContext context, Executor FlowUser user = context.getFlowUser(); if (response.getUserId() != null) { - user.setUserId(response.getUserId()); + user.setId(response.getUserId()); } if (response.getUpdatedUserClaims() != null) { response.getUpdatedUserClaims().forEach((key, value) -> user.addClaim(key, String.valueOf(value))); @@ -216,7 +216,7 @@ private NodeResponse handleCompleteStatus(FlowExecutionContext context, Executor if (response.getUserCredentials() != null) { user.getUserCredentials().putAll(response.getUserCredentials()); } - if (user.getUserId() == null) { + if (user.getId() == null) { resolveUserIdFromUserStore(user, context.getTenantDomain()); } @@ -239,7 +239,7 @@ private void resolveUserIdFromUserStore(FlowUser user, String tenantDomain) { .getTenantUserRealm(tenantId).getUserStoreManager(); String userId = userStoreManager.getUserIDFromUserName(username); if (StringUtils.isNotBlank(userId)) { - user.setUserId(userId); + user.setId(userId); } } catch (Exception e) { LOG.warn("Failed to resolve userId for user '" + username + "' from user store.", e); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java index 12fdfac768eb..9fbc31d16e5a 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java @@ -81,7 +81,7 @@ public class FlowUser implements Serializable { @JsonProperty("username") private String username; - private String userId; + private String id; private String userStoreDomain; @JsonIgnore @@ -153,14 +153,14 @@ public void setUserCredentials(Map credentials) { this.userCredentials.putAll(credentials); } - public String getUserId() { + public String getId() { - return userId; + return id; } - public void setUserId(String userId) { + public void setId(String id) { - this.userId = userId; + this.id = id; } public String getUserStoreDomain() { diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java index b59ae3e70500..3ab898425641 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java @@ -88,7 +88,7 @@ private static JWTClaimsSet buildUserAssertionClaimSet(FlowExecutionContext cont Date expirationTime = calculateUserAssertionExpiryTime(now); String serverURL = ServiceURLBuilder.create().build(IdentityUtil.getHostName()).getAbsolutePublicURL(); String username = context.getFlowUser().getUsername(); - String userId = context.getFlowUser().getUserId(); + String userId = context.getFlowUser().getId(); List amrValues = context.getCompletedNodes().stream() .map(node -> node.getExecutorConfig().getName()) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java index a8bc1c4082d3..da425415136d 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java @@ -102,7 +102,7 @@ public void setUp() { when(mockContext.getContextIdentifier()).thenReturn(TEST_CONTEXT_IDENTIFIER); when(mockContext.getFlowUser()).thenReturn(mockFlowUser); when(mockFlowUser.getUsername()).thenReturn(TEST_USERNAME); - when(mockFlowUser.getUserId()).thenReturn(TEST_USER_ID); + when(mockFlowUser.getId()).thenReturn(TEST_USER_ID); when(mockFlowUser.getUserStoreDomain()).thenReturn("PRIMARY"); } @@ -338,7 +338,7 @@ private void setupCommonMockBehaviors() { when(mockContext.getContextIdentifier()).thenReturn(TEST_CONTEXT_IDENTIFIER); when(mockContext.getFlowUser()).thenReturn(mockFlowUser); when(mockFlowUser.getUsername()).thenReturn(TEST_USERNAME); - when(mockFlowUser.getUserId()).thenReturn(TEST_USER_ID); + when(mockFlowUser.getId()).thenReturn(TEST_USER_ID); } private void setupBasicMocks(MockedStatic dataHolderMock, diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java index 75796d3f3754..1f2f31519d2b 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java @@ -322,7 +322,7 @@ public void testAssertionGenerationIntegration() throws Exception { FlowUser flowUser = new FlowUser(); flowUser.setUsername(testUsername); - flowUser.setUserId(testUserId); + flowUser.setId(testUserId); mockContext.setFlowUser(flowUser); @@ -354,7 +354,7 @@ public void testAssertionGenerationWithEmptyCompletedNodes() throws Exception { FlowUser flowUser = new FlowUser(); flowUser.setUsername("testuser"); - flowUser.setUserId("user123"); + flowUser.setId("user123"); mockContext.setFlowUser(flowUser); String expectedAssertion = "minimal.jwt.token"; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java index 603e9f32405b..cb8a20254e2d 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java @@ -53,7 +53,7 @@ private InFlowExtensionConstants() { // ---- Context path prefixes ---- public static final String USER_PREFIX = "/user/"; - public static final String USER_ID_PATH = "/user/userId"; + public static final String USER_ID_PATH = "/user/id"; public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; public static final String USER_CLAIMS_SELECTOR_PREFIX = "/user/claims[uri="; @@ -169,7 +169,7 @@ private HandoverPolicy() { } */ public static final Set INCLUDED_USER_ATTRIBUTES = Collections.unmodifiableSet( new HashSet<>(Arrays.asList( - "userId", + "id", "username", "userStoreDomain", "claims", diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java index e04c4140b16c..b036f5eabe34 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java @@ -180,7 +180,7 @@ private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose, + private User buildUser(FlowUser flowUser, List expose, AccessConfig accessConfig, String certificatePEM) throws ActionExecutionRequestBuilderException { @@ -201,7 +201,7 @@ private InFlowUser buildUser(FlowUser flowUser, List expose, userBuilder.userStoreDomain(new UserStore(userStoreDomain != null ? userStoreDomain : "")); } - return new InFlowUser(userBuilder); + return userBuilder.build(); } private FlowExecutionContext getFlowExecutionContextOrThrow(FlowContext flowContext) @@ -574,7 +574,7 @@ private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, Flow private String resolveUserId(FlowUser flowUser, List expose) { if (isLeafExposed(InFlowExtensionConstants.USER_ID_PATH, expose)) { - String userId = flowUser.getUserId(); + String userId = flowUser.getId(); return userId != null ? userId : ""; } return null; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java index 700fd6e8a317..b5ba0d80e8b7 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java @@ -121,7 +121,7 @@ static boolean resolveAllowReadOnlyClaimsModification(String flowType) { * *

Strategy: *

    - *
  • Read-only fields (userId, username, userStoreDomain): only emitted when the + *
  • Read-only fields (id, username, userStoreDomain): only emitted when the * attribute is in the allow-list (or full-passthrough is active). They support EXPOSE * only, so excluding them when restricted is the correct behaviour.
  • *
  • Modifiable fields (claims, credentials): always emitted because modifications @@ -139,11 +139,11 @@ private InFlowExtensionContextTreeNode buildUserNode(Set userAttrs) { List children = new ArrayList<>(); // ── Read-only fields: emit only when exposed ──────────────────────────────────────── - if (fullPassthrough || userAttrs.contains("userId")) { + if (fullPassthrough || userAttrs.contains("id")) { children.add(InFlowExtensionContextTreeNode.builder() - .key("userId") + .key("id") .title("User ID") - .path("/user/userId") + .path("/user/id") .dataType(DATA_TYPE_STRING) .nodeType(NODE_LEAF) .allowedOperations(Collections.singletonList(OP_EXPOSE)) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java index 78fa97678e00..cf555573a469 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java @@ -19,6 +19,7 @@ package org.wso2.carbon.identity.flow.extensions.model; import com.fasterxml.jackson.annotation.JsonInclude; +import org.wso2.carbon.identity.action.execution.api.model.User; /** * Models the {@code flow} object nested inside the In-Flow Extension Event. @@ -28,7 +29,7 @@ public class InFlowExtensionFlow { private final String flowType; private final String flowId; - private final InFlowUser user; + private final User user; private InFlowExtensionFlow(Builder builder) { @@ -53,7 +54,7 @@ public String getFlowId() { } @JsonInclude(JsonInclude.Include.NON_NULL) - public InFlowUser getUser() { + public User getUser() { return user; } @@ -62,7 +63,7 @@ public static class Builder { private String flowType; private String flowId; - private InFlowUser user; + private User user; public Builder flowType(String flowType) { @@ -76,7 +77,7 @@ public Builder flowId(String flowId) { return this; } - public Builder user(InFlowUser user) { + public Builder user(User user) { this.user = user; return this; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java deleted file mode 100644 index 9889b24292e0..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowUser.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import org.wso2.carbon.identity.action.execution.api.model.User; - -/** - * In-Flow Extension specific view of {@link User} that serializes the user identifier as - * {@code "userId"} rather than the shared model's {@code "id"} field name. - */ -public class InFlowUser extends User { - - public InFlowUser(User.Builder builder) { - - super(builder); - } - - @Override - @JsonProperty("userId") - @JsonInclude(JsonInclude.Include.NON_NULL) - public String getId() { - - return super.getId(); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java index 64bf3b6c8931..ae67302b62c2 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java @@ -38,7 +38,7 @@ public final class InFlowExtensionTestUtils { "properties", "contextIdentifier")); public static final Set ALL_USER_ATTRS = new HashSet<>(Arrays.asList( - "username", "userId", "userStoreDomain", "claims", "userCredentials")); + "username", "id", "userStoreDomain", "claims", "userCredentials")); private InFlowExtensionTestUtils() { diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java index 4383ffef407f..622270620fca 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java @@ -528,7 +528,7 @@ public void testExposeFilteringSpecificClaim() // Only expose a specific claim. AccessConfig accessConfig = new AccessConfig(Arrays.asList( new ContextPath("/user/claims/http://wso2.org/claims/email", false), - new ContextPath("/user/userId", false)), null); + new ContextPath("/user/id", false)), null); FlowContext flowContext = FlowContext.create() .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); @@ -661,10 +661,10 @@ public void testExposedUserIdNullYieldsEmptyString() throws ActionExecutionRequestBuilderException { FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.getFlowUser().setUserId(null); + execCtx.getFlowUser().setId(null); AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/userId", false)), null); + new ContextPath("/user/id", false)), null); FlowContext flowContext = FlowContext.create() .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); @@ -675,7 +675,7 @@ public void testExposedUserIdNullYieldsEmptyString() InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); assertNotNull(event.getFlow().getUser()); assertEquals(event.getFlow().getUser().getId(), "", - "User id must be '' when userId is null and path is exposed"); + "User id must be '' when id is null and path is exposed"); } @Test @@ -710,7 +710,7 @@ public void testExposedClaimAbsentFromClaimsMapYieldsEmptyString() // Expose a claim URI that is NOT present in the flowUser's claims map. AccessConfig accessConfig = new AccessConfig(Arrays.asList( new ContextPath("/user/claims/http://wso2.org/claims/mobile", false), - new ContextPath("/user/userId", false)), null); + new ContextPath("/user/id", false)), null); FlowContext flowContext = FlowContext.create() .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); @@ -1030,7 +1030,7 @@ private FlowExecutionContext createFullFlowExecutionContext() { context.setCurrentNode(node); FlowUser flowUser = new FlowUser(); - flowUser.setUserId("user-456"); + flowUser.setId("user-456"); flowUser.setUsername("testuser"); flowUser.setUserStoreDomain("PRIMARY"); flowUser.addClaim("http://wso2.org/claims/email", "test@example.com"); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java index a88bce808e2a..1860406a7fb4 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java @@ -843,7 +843,7 @@ private FlowExecutionContext createFlowExecutionContext() { context.setContextIdentifier("test-id"); FlowUser flowUser = new FlowUser(); - flowUser.setUserId("user-1"); + flowUser.setId("user-1"); flowUser.setUsername("testuser"); context.setFlowUser(flowUser); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java index 69117af4a681..4e137e658bf2 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java @@ -182,7 +182,7 @@ public void testReadOnlyUserFieldsAbsentWhenNoUserAttrsIncluded() { List children = userNode.getChildren(); // Read-only user fields absent when not configured. - assertFalse(hasChildKey(children, "userId"), "userId must not appear"); + assertFalse(hasChildKey(children, "id"), "userId must not appear"); assertFalse(hasChildKey(children, "username"), "username must not appear"); assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain must not appear"); @@ -213,7 +213,7 @@ public void testUserNodeAppearsWithSelectedUserAttrs() { assertTrue(claimsNode.getAllowedOperations().contains("MODIFY"), "claims must have MODIFY"); // Read-only fields not configured → absent. - assertFalse(hasChildKey(children, "userId"), "userId not in allow-list"); + assertFalse(hasChildKey(children, "id"), "userId not in allow-list"); assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain not in allow-list"); // credentials not configured but always present → MODIFY only, no EXPOSE. @@ -237,7 +237,7 @@ public void testFullUserPassthroughShowsAllUserChildren() { assertNotNull(userNode, "user node should be present with full passthrough"); List children = userNode.getChildren(); - assertTrue(hasChildKey(children, "userId"), "userId must appear in passthrough"); + assertTrue(hasChildKey(children, "id"), "userId must appear in passthrough"); assertTrue(hasChildKey(children, "username"), "username must appear in passthrough"); assertTrue(hasChildKey(children, "userStoreDomain"),"userStoreDomain must appear in passthrough"); assertTrue(hasChildKey(children, "claims"), "claims must appear in passthrough"); From f77b29dfd1516f3c08973e8ab3ecfeaed0024995 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Sun, 24 May 2026 11:21:30 +0530 Subject: [PATCH 04/17] Refactor FLOW_EXTENSION --- .../execution/api/model/ActionType.java | 2 +- .../internal/util/ActionExecutorConfig.java | 10 +- .../action/management/api/model/Action.java | 4 +- .../service/impl/DefaultActionValidator.java | 4 +- .../impl/ActionDTOModelResolverFactory.java | 4 +- .../impl/ActionManagementServiceImpl.java | 6 +- .../internal/util/ActionManagementConfig.java | 6 +- .../management/model/ActionTypesTest.java | 4 +- .../pom.xml | 304 ----- .../extensions/InFlowExtensionConstants.java | 179 --- .../executor/InFlowExtensionExecutor.java | 477 -------- .../InFlowExtensionRequestBuilder.java | 800 ------------- .../InFlowExtensionResponseProcessor.java | 750 ------------ .../executor/JWEEncryptionUtil.java | 262 ----- .../executor/PathTypeAnnotationUtil.java | 418 ------- .../internal/InFlowExtensionDataHolder.java | 90 -- .../InFlowExtensionServiceComponent.java | 179 --- .../InFlowExtensionActionConverter.java | 211 ---- ...InFlowExtensionActionDTOModelResolver.java | 604 ---------- .../InFlowExtensionContextTreeBuilder.java | 295 ----- .../InFlowExtensionContextTreeMetadata.java | 70 -- .../InFlowExtensionContextTreeNode.java | 205 ---- .../InFlowExtensionContextTreeService.java | 56 - .../flow/extensions/model/AccessConfig.java | 153 --- .../flow/extensions/model/ContextPath.java | 66 -- .../flow/extensions/model/Encryption.java | 60 - .../model/FlowContextHandoverConfig.java | 118 -- .../model/InFlowExtensionAction.java | 304 ----- .../model/InFlowExtensionEvent.java | 170 --- .../extensions/model/InFlowExtensionFlow.java | 91 -- .../model/InFlowExtensionRequest.java | 34 - .../model/OperationExecutionResult.java | 62 - .../InFlowExtensionContextFilterUtil.java | 179 --- .../util/InFlowExtensionPathUtil.java | 72 -- .../extensions/InFlowExtensionTestUtils.java | 62 - .../executor/InFlowExtensionExecutorTest.java | 616 ---------- .../InFlowExtensionRequestBuilderTest.java | 1047 ----------------- .../InFlowExtensionResponseProcessorTest.java | 999 ---------------- .../executor/PathTypeAnnotationUtilTest.java | 618 ---------- .../FlowContextHandoverConfigTestHelper.java | 40 - ...InFlowExtensionContextTreeBuilderTest.java | 433 ------- .../extensions/model/AccessConfigTest.java | 205 ---- .../model/InFlowExtensionEventTest.java | 116 -- .../model/OperationExecutionResultTest.java | 68 -- .../util/InFlowExtensionPathUtilTest.java | 163 --- .../test/resources/repository/conf/carbon.xml | 687 ----------- .../src/test/resources/testng.xml | 43 - .../flow-orchestration-framework/pom.xml | 2 +- .../pom.xml | 73 -- .../pom.xml | 4 +- features/flow-orchestration-framework/pom.xml | 2 +- pom.xml | 4 +- 52 files changed, 26 insertions(+), 11405 deletions(-) delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java delete mode 100755 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml delete mode 100644 features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java index 4408c0a227b9..03a7a466ba5a 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java @@ -30,7 +30,7 @@ public enum ActionType { PRE_UPDATE_PROFILE, AUTHENTICATION, PRE_ISSUE_ID_TOKEN, - FLOW_EXTENSIONS; + FLOW_EXTENSION; public String getDisplayName() { diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java index 2e59c61d0afa..66858fda1f04 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java @@ -87,8 +87,8 @@ public boolean isExecutionForActionTypeEnabled(ActionType actionType) { return isActionTypeEnabled(ActionTypeConfig.PRE_UPDATE_PROFILE.getActionTypeEnableProperty()); case PRE_ISSUE_ID_TOKEN: return isActionTypeEnabled(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getActionTypeEnableProperty()); - case FLOW_EXTENSIONS: - return isActionTypeEnabled(ActionTypeConfig.FLOW_EXTENSIONS.getActionTypeEnableProperty()); + case FLOW_EXTENSION: + return isActionTypeEnabled(ActionTypeConfig.FLOW_EXTENSION.getActionTypeEnableProperty()); default: return false; } @@ -335,8 +335,8 @@ public String getRetiredUpToVersion(ActionType actionType) { return getVersion(ActionTypeConfig.PRE_UPDATE_PROFILE.getRetiredUpToVersionProperty()); case PRE_ISSUE_ID_TOKEN: return getVersion(ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getRetiredUpToVersionProperty()); - case FLOW_EXTENSIONS: - return getVersion(ActionTypeConfig.FLOW_EXTENSIONS.getRetiredUpToVersionProperty()); + case FLOW_EXTENSION: + return getVersion(ActionTypeConfig.FLOW_EXTENSION.getRetiredUpToVersionProperty()); default: return null; } @@ -422,7 +422,7 @@ private enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.AllowedHeaders.Header", "Actions.Types.PreIssueIdToken.ActionRequest.AllowedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.RetiredUpTo"), - FLOW_EXTENSIONS("Actions.Types.InFlowExtension.Enable", + FLOW_EXTENSION("Actions.Types.InFlowExtension.Enable", "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.InFlowExtension.ActionRequest.AllowedHeaders.Header", diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java index c4b276dd529c..edee2e5fd252 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java @@ -76,9 +76,9 @@ public enum ActionTypes { "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", Category.PRE_POST), - FLOW_EXTENSIONS( + FLOW_EXTENSION( "inFlowExtension", - "FLOW_EXTENSIONS", + "FLOW_EXTENSION", "In-Flow Extension", "Configure an extension point within any flow via a custom service.", Category.IN_FLOW_EXTENSION); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java index 7d753b92dc28..cb342dad3ae9 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java @@ -99,7 +99,7 @@ public void doPreAddActionValidations(Action.ActionTypes actionType, String acti List existingActionsOfType) throws ActionMgtException { doPreAddActionValidations(actionType, actionVersion, action); - if (ActionTypes.FLOW_EXTENSIONS.equals(actionType)) { + if (ActionTypes.FLOW_EXTENSION.equals(actionType)) { validateActionNameUniqueness(action.getName(), null, existingActionsOfType); } } @@ -139,7 +139,7 @@ public void doPreUpdateActionValidations(Action.ActionTypes actionType, String a throws ActionMgtException { doPreUpdateActionValidations(actionType, actionVersion, action); - if (action.getName() != null && ActionTypes.FLOW_EXTENSIONS.equals(actionType)) { + if (action.getName() != null && ActionTypes.FLOW_EXTENSION.equals(actionType)) { validateActionNameUniqueness(action.getName(), excludeId, existingActionsOfType); } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java index 671bbde7af0f..7a576530ecb3 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/dao/impl/ActionDTOModelResolverFactory.java @@ -46,8 +46,8 @@ public static ActionDTOModelResolver getActionDTOModelResolver(Action.ActionType return actionDTOModelResolvers.get(Action.ActionTypes.PRE_UPDATE_PASSWORD); case PRE_ISSUE_ACCESS_TOKEN: return actionDTOModelResolvers.get(Action.ActionTypes.PRE_ISSUE_ACCESS_TOKEN); - case FLOW_EXTENSIONS: - return actionDTOModelResolvers.get(Action.ActionTypes.FLOW_EXTENSIONS); + case FLOW_EXTENSION: + return actionDTOModelResolvers.get(Action.ActionTypes.FLOW_EXTENSION); default: return null; } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index f5afa5b50c06..dcd822a9a4d7 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -76,7 +76,7 @@ public Action addAction(String actionType, Action action, String tenantDomain) t int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); String resolvedActionType = getActionTypeFromPath(actionType); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); - List existingActions = ActionTypes.FLOW_EXTENSIONS.equals(castedActionType) + List existingActions = ActionTypes.FLOW_EXTENSION.equals(castedActionType) ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreAddActionValidations( @@ -165,7 +165,7 @@ public Action updateAction(String actionType, String actionId, Action action, St String resolvedActionType = getActionTypeFromPath(actionType); ActionDTO existingActionDTO = checkIfActionExists(resolvedActionType, actionId, tenantDomain); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); - List existingActions = ActionTypes.FLOW_EXTENSIONS.equals(castedActionType) + List existingActions = ActionTypes.FLOW_EXTENSION.equals(castedActionType) ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreUpdateActionValidations( @@ -378,7 +378,7 @@ private ActionDTO buildActionDTOForCreation(String actionType, String actionId, Action.ActionTypes resolvedActionType = Action.ActionTypes.valueOf(actionType); // PRE_POST actions start INACTIVE (require explicit activation). - // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSIONS) + // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSION) // start ACTIVE and can be used immediately. Action.Status resolvedStatus = resolvedActionType.getCategory() == Action.ActionTypes.Category.PRE_POST ? Action.Status.INACTIVE : Action.Status.ACTIVE; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java index f77edb4759db..4a72a543c2e7 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java @@ -96,9 +96,9 @@ public String getLatestVersion(ActionTypes actionType) throws ActionMgtServerExc case PRE_ISSUE_ID_TOKEN: return getVersion( ActionTypeConfig.PRE_ISSUE_ID_TOKEN.getLatestVersionProperty(), actionType); - case FLOW_EXTENSIONS: + case FLOW_EXTENSION: return getVersion( - ActionTypeConfig.FLOW_EXTENSIONS.getLatestVersionProperty(), actionType); + ActionTypeConfig.FLOW_EXTENSION.getLatestVersionProperty(), actionType); default: throw new ActionMgtServerException("Unsupported action type: " + actionType); } @@ -145,7 +145,7 @@ public enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.ExcludedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.Latest" ), - FLOW_EXTENSIONS( + FLOW_EXTENSION( "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", "Actions.Types.InFlowExtension.Version.Latest" ); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java index b896ac8796ac..c1ba35657fb6 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java @@ -53,7 +53,7 @@ public Object[][] actionTypesProvider() { "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", Action.ActionTypes.Category.PRE_POST}, - {Action.ActionTypes.FLOW_EXTENSIONS, "inFlowExtension", "FLOW_EXTENSIONS", + {Action.ActionTypes.FLOW_EXTENSION, "inFlowExtension", "FLOW_EXTENSION", "In-Flow Extension", "Configure an extension point within any flow via a custom service.", Action.ActionTypes.Category.IN_FLOW_EXTENSION} @@ -82,7 +82,7 @@ public Object[][] filterByCategoryProvider() { Action.ActionTypes.PRE_REGISTRATION, Action.ActionTypes.PRE_ISSUE_ID_TOKEN}}, {Action.ActionTypes.Category.IN_FLOW, new Action.ActionTypes[]{Action.ActionTypes.AUTHENTICATION}}, {Action.ActionTypes.Category.IN_FLOW_EXTENSION, - new Action.ActionTypes[]{Action.ActionTypes.FLOW_EXTENSIONS}} + new Action.ActionTypes[]{Action.ActionTypes.FLOW_EXTENSION}} }; } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml deleted file mode 100644 index ddb55fc0508f..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/pom.xml +++ /dev/null @@ -1,304 +0,0 @@ - - - - - org.wso2.carbon.identity.framework - identity-framework - 7.11.94-SNAPSHOT - ../../../pom.xml - - - 4.0.0 - org.wso2.carbon.identity.flow.extensions - bundle - WSO2 Carbon - Identity Flow In-Flow Extensions - WSO2 flow engine in-flow extensions - http://www.wso2.com - - - - org.wso2.carbon - org.wso2.carbon.core - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.base - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.core - - - org.ops4j.pax.logging - pax-logging-api - - - com.fasterxml.jackson.core - jackson-databind - provided - - - com.fasterxml.jackson.core - jackson-core - provided - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.mgt - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.execution.engine - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.action.execution - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.action.management - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.claim.metadata.mgt - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.central.log.mgt - - - org.testng - testng - test - - - org.mockito - mockito-core - test - - - org.mockito - mockito-testng - test - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.application.authentication.framework - test - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.testutil - test - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.user.action - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.certificate.management - - - org.wso2.orbit.com.nimbusds - nimbus-jose-jwt - - - - - - - org.apache.felix - maven-bundle-plugin - true - - - ${project.artifactId} - ${project.artifactId} - - org.wso2.carbon.identity.flow.extensions.internal, - org.wso2.carbon.identity.flow.extensions.util - - - javax.xml.parsers; version="${javax.xml.parsers.import.pkg.version}", - org.xml.sax, - org.w3c.dom, - org.osgi.framework; version="${osgi.framework.imp.pkg.version.range}", - org.apache.axiom.om; version="${axiom.osgi.version.range}", - org.apache.xerces.util; resolution:=optional, - org.apache.commons.logging; version="${import.package.version.commons.logging}", - org.apache.commons.collections; version="${commons-collections.wso2.osgi.version.range}", - org.osgi.service.component; version="${osgi.service.component.imp.pkg.version.range}", - org.wso2.carbon.context; version="${carbon.kernel.package.import.version.range}", - org.wso2.carbon.user.core.*;version="${carbon.kernel.package.import.version.range}", - org.wso2.carbon.utils.multitenancy;version="${carbon.kernel.package.import.version.range}", - org.wso2.carbon.user.api; version="${carbon.user.api.imp.pkg.version.range}", - org.wso2.carbon.identity.base; version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.core.*; version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.application.*; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.claim.metadata.mgt.*; - version="${carbon.identity.package.import.version.range}", - javax.servlet.http; version="${imp.pkg.version.javax.servlet}", - org.wso2.carbon.identity.flow.execution.engine; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.execution.engine.exception; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.execution.engine.graph; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.execution.engine.model; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.mgt; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.mgt.model; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.flow.mgt.exception; - version="${carbon.identity.package.import.version.range}", - com.nimbusds.jose.*; version="${nimbusds.osgi.version.range}", - com.nimbusds.jwt; version="${nimbusds.osgi.version.range}", - org.wso2.carbon.identity.user.action.api.exception; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.core.util; version="${carbon.kernel.package.import.version.range}", - org.wso2.carbon.identity.action.execution.api.*; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.action.management.api.*; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.central.log.mgt.utils; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.certificate.management.exception; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.certificate.management.model; - version="${carbon.identity.package.import.version.range}", - org.wso2.carbon.identity.certificate.management.service; - version="${carbon.identity.package.import.version.range}", - com.fasterxml.jackson.core.*; version="${com.fasterxml.jackson.annotation.version.range}", - com.fasterxml.jackson.databind.*; - version="${com.fasterxml.jackson.annotation.version.range}", - com.fasterxml.jackson.annotation.*; - version="${com.fasterxml.jackson.annotation.version.range}", - org.wso2.carbon.utils; version="${carbon.kernel.package.import.version.range}", - org.slf4j; version="${org.slf4j.imp.pkg.version.range}" - - - !org.wso2.carbon.identity.flow.extensions.internal, - org.wso2.carbon.identity.flow.extensions, - org.wso2.carbon.identity.flow.extensions.executor, - org.wso2.carbon.identity.flow.extensions.model, - org.wso2.carbon.identity.flow.extensions.management, - org.wso2.carbon.identity.flow.extensions.metadata; - version="${carbon.identity.package.export.version}" - - - - - - org.apache.maven.plugins - maven-surefire-plugin - ${maven.surefire.plugin.version} - - - src/test/resources/testng.xml - - - ${project.build.testOutputDirectory} - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - - true - - - - org.codehaus.mojo - findbugs-maven-plugin - - true - - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - org/wso2/carbon/identity/flow/extensions/internal/** - - - - - default-prepare-agent - - prepare-agent - - - - default-prepare-agent-integration - - prepare-agent-integration - - - - default-report - - report - - - - default-report-integration - - report-integration - - - - default-check - - check - - - - - BUNDLE - - - LINE - COVEREDRATIO - 0.45 - - - - - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - - 21 - - - - - - diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java deleted file mode 100644 index cb8a20254e2d..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionConstants.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -/** - * Constants for the In-Flow Extension executor pipeline. - * - *

    Keys are shared across the executor, request builder, and response processor - * via the {@link org.wso2.carbon.identity.action.execution.api.model.FlowContext} - * handoff mechanism. Path prefixes drive operation routing in the response processor.

    - */ -public class InFlowExtensionConstants { - - private InFlowExtensionConstants() { - - } - - // ---- FlowContext pipeline keys ---- - public static final String FLOW_EXECUTION_CONTEXT_KEY = "flowExecutionContext"; - public static final String PATH_TYPE_ANNOTATIONS_KEY = "pathTypeAnnotations"; - public static final String MODIFY_PATHS_KEY = "modifyPaths"; - public static final String PENDING_CLAIMS_KEY = "pendingClaims"; - public static final String PENDING_CREDENTIALS_KEY = "pendingCredentials"; - public static final String PENDING_PROPERTIES_KEY = "pendingProperties"; - public static final String PENDING_REDIRECT_URL_KEY = "pendingRedirectUrl"; - - // ---- Response info keys (FAILED path) ---- - public static final String FAILURE_TYPE_KEY = "failureType"; - public static final String IN_FLOW_EXTENSION_FAILURE_TYPE = "IN_FLOW_EXTENSION_FAILURE"; - public static final String FAILURE_MESSAGE_KEY = "failureMessage"; - public static final String FAILURE_DESCRIPTION_KEY = "failureDescription"; - - // ---- Context path prefixes ---- - public static final String USER_PREFIX = "/user/"; - public static final String USER_ID_PATH = "/user/id"; - public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; - public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; - public static final String USER_CLAIMS_SELECTOR_PREFIX = "/user/claims[uri="; - public static final String USER_CLAIMS_SELECTOR_SUFFIX = "]"; - public static final String USER_CREDENTIALS_PATH_PREFIX = "/user/credentials/"; - public static final String PROPERTIES_PATH_PREFIX = "/properties/"; - public static final String FLOW_PREFIX = "/flow/"; - public static final String FLOW_TENANT_PATH = "/flow/tenantDomain"; - public static final String FLOW_APP_ID_PATH = "/flow/applicationId"; - public static final String FLOW_TYPE_PATH = "/flow/flowType"; - public static final String FLOW_CALLBACK_URL_PATH = "/flow/callbackUrl"; - public static final String FLOW_PORTAL_URL_PATH = "/flow/portalUrl"; - public static final String ORGANIZATION_PREFIX = "/organization/"; - public static final String ORGANIZATION_ID_PATH = "/organization/id"; - public static final String ORGANIZATION_NAME_PATH = "/organization/name"; - public static final String ORGANIZATION_HANDLE_PATH = "/organization/orgHandle"; - public static final String ORGANIZATION_DEPTH_PATH = "/organization/depth"; - - // ---- Miscellaneous ---- - public static final String ACTION_ID_METADATA_KEY = "actionId"; - - /** - * Constants for In-Flow Extension action management (action properties stored in - * IDN_ACTION_PROPERTIES, certificate naming, and expose-path limits). - */ - public static final class ActionManagement { - - public static final String ACCESS_CONFIG_EXPOSE = "ACCESS_CONFIG_EXPOSE"; - public static final String ACCESS_CONFIG_MODIFY = "ACCESS_CONFIG_MODIFY"; - public static final String OVERRIDE_KEY_SEPARATOR = ":"; - public static final String ACCESS_CONFIG_EXPOSE_PREFIX = ACCESS_CONFIG_EXPOSE + OVERRIDE_KEY_SEPARATOR; - public static final String ACCESS_CONFIG_MODIFY_PREFIX = ACCESS_CONFIG_MODIFY + OVERRIDE_KEY_SEPARATOR; - public static final int MAX_EXPOSE_PATHS = 50; - public static final String ICON_URL = "ICON_URL"; - public static final String CERTIFICATE = "CERTIFICATE"; - public static final String CERTIFICATE_NAME_PREFIX = "ACTIONS:"; - - private ActionManagement() { } - } - - /** - * Diagnostic log constants for the In-Flow Extension layer. - */ - public static final class Log { - - public static final String COMPONENT_ID = "inflow-extension"; - - private Log() { - - } - - /** - * Action IDs for diagnostic events emitted by the In-Flow Extension layer. - */ - public static final class ActionIDs { - - public static final String EXECUTE = "execute-inflow-extension"; - public static final String PROCESS_RESPONSE = "process-inflow-extension-response"; - - private ActionIDs() { - - } - } - } - - /** - * Compile-time default handover policy constants. - * - *

    These constants define which {@code FlowExecutionContext} and {@code FlowUser} - * fields are handed to the action framework during in-flow extension execution. - * {@code "properties"} is intentionally excluded from {@link #INCLUDED_ATTRIBUTES}: - * it is always modifiable via the executor response path (context tree always exposes - * it with MODIFY ops), but must not be forwarded to external services by default.

    - * - *

    When the toml-based dynamic config PR is merged, these constants serve as the - * documented defaults for {@code identity.xml.j2}.

    - */ - public static final class HandoverPolicy { - - private HandoverPolicy() { } - - /** Attribute name for the {@code flowUser} field. When present in - * {@link #INCLUDED_ATTRIBUTES}, {@code fullUserPassthrough} is set to true. */ - public static final String ATTR_FLOW_USER = "flowUser"; - - /** Context identifier; always copied by the filter regardless of config. */ - public static final String ATTR_CONTEXT_IDENTIFIER = "contextIdentifier"; - - /** User-credentials property name; requires per-entry {@code char[]} cloning. */ - public static final String ATTR_USER_CREDENTIALS = "userCredentials"; - - /** - * Top-level {@code FlowExecutionContext} fields that are handed to the action framework. - * Corresponds to the future toml key: - * {@code flow_execution_context.handover.filtering.included_attributes}. - */ - public static final Set INCLUDED_ATTRIBUTES = Collections.unmodifiableSet( - new HashSet<>(Arrays.asList( - "contextIdentifier", - "tenantDomain", - "applicationId", - "flowType", - "callbackUrl", - "portalUrl", - "flowUser" // presence sets fullUserPassthrough = true - // "properties" intentionally excluded — sensitive flow-state data - ))); - - /** - * {@code FlowUser} fields that are handed over when full-passthrough is not active. - * Corresponds to the future toml key: - * {@code flow_execution_context.handover.filtering.included_user_attributes}. - */ - public static final Set INCLUDED_USER_ATTRIBUTES = Collections.unmodifiableSet( - new HashSet<>(Arrays.asList( - "id", - "username", - "userStoreDomain", - "claims", - "userCredentials" - ))); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java deleted file mode 100644 index c3b7e80b62b9..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutor.java +++ /dev/null @@ -1,477 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.Error; -import org.wso2.carbon.identity.action.execution.api.model.Failure; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; -import org.wso2.carbon.utils.DiagnosticLog; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; -import org.wso2.carbon.identity.flow.execution.engine.util.FlowExecutionEngineUtils; -import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; -import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionContextFilterUtil; -import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; -import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; -import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * Executes In-Flow Extension actions during flow execution by delegating to - * {@link ActionExecutorService} and mapping the result to an {@link ExecutorResponse}. - * On success, pending context updates (claims, credentials, properties) are forwarded - * to the flow engine through the response object. - */ -public class InFlowExtensionExecutor implements Executor { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class); - private static final String EXECUTOR_NAME = "InFlowExtensionExecutor"; - private static final String CONFIG_PARAM_ACTION_TYPE = "actionType"; - private static final String CONFIG_PARAM_ACTION_ID = "actionId"; - - - - @Override - public String getName() { - - return EXECUTOR_NAME; - } - - @Override - public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineException { - - String actionId = getMetadataValue(context, InFlowExtensionConstants.ACTION_ID_METADATA_KEY); - if (actionId == null || actionId.isEmpty()) { - triggerDiagnosticFailure(null, - "In-Flow Extension action execution failed: action ID is not configured."); - return buildErrorResponse("Extension is not configured.", - "The In-Flow Extension action is missing required configuration. " + - "Contact your administrator."); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Executing In-Flow Extension action. actionId: " + actionId - + ", flowType: " + context.getFlowType() - + ", tenant: " + context.getTenantDomain()); - } - - ActionExecutorService actionExecutorService = getActionExecutorService(); - if (actionExecutorService == null) { - triggerDiagnosticFailure(actionId, - "In-Flow Extension action execution failed: ActionExecutorService is unavailable."); - throw FlowExecutionEngineUtils.handleServerException( - Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR, - "ActionExecutorService is not available. actionId: " + actionId); - } - - if (!actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)) { - triggerDiagnosticFailure(actionId, - "In-Flow Extension action execution failed: action type is disabled."); - return buildErrorResponse("Extension execution is disabled.", - "The In-Flow Extension action type is currently disabled on this server."); - } - - try { - // Hand the action framework only a FILTERED copy of the FlowExecutionContext - // (non-whitelisted fields nulled out). Policy is sourced from compile-time constants. - FlowExecutionContext filteredContext = InFlowExtensionContextFilterUtil.filter( - context, FlowContextHandoverConfig.defaultPolicy()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, filteredContext); - - ActionExecutionStatus executionStatus = actionExecutorService.execute( - ActionType.FLOW_EXTENSIONS, actionId, flowContext, context.getTenantDomain()); - - ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context); - - // On success, extract pending context updates collected by the response processor - // and forward them to TaskExecutionNode via ExecutorResponse fields. - if (ExecutorStatus.STATUS_COMPLETE.equals(executionResponse.getResult())) { - applyPendingContextUpdates(executionResponse, flowContext, actionId); - } - - if (ExecutorStatus.STATUS_RETRY.equals(executionResponse.getResult())) { - applyRetryMetadata(executionResponse, actionId); - } - - return executionResponse; - - } catch (ActionExecutionException e) { - logActionExecutionException(e, actionId); - triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: " + e.getMessage()); - return buildErrorResponse("An error occurred while processing the extension. Please try again.", - "The external extension service could not complete the request. " + - "If the problem persists, contact your administrator."); - } - } - - @Override - public List getInitiationData() { - - return Collections.emptyList(); - } - - @Override - public ExecutorResponse rollback(FlowExecutionContext context) { - - return null; - } - - /** - * Map the {@link ActionExecutionStatus} to an {@link ExecutorResponse}. - * Performs status translation and (for INCOMPLETE/redirect) generates the OTFI used by - * {@code FlowExecutionService} to swap caches on resume — same pattern as {@code MagicLinkExecutor}. - * - * @param executionStatus The status returned by ActionExecutorService. - * @param flowContext The action {@link FlowContext} where the response processor stashed the redirect URL. - * @param context The engine {@link FlowExecutionContext} (used for OTFI collision-guard). - * @return The ExecutorResponse for the flow execution engine. - */ - private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionStatus, - FlowContext flowContext, FlowExecutionContext context) { - - ExecutorResponse response = new ExecutorResponse(); - - if (executionStatus == null) { - response.setResult(ExecutorStatus.STATUS_ERROR); - response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - response.setErrorMessage("Extension did not return a response."); - response.setErrorDescription("The In-Flow Extension action did not return a status. Please try again."); - return response; - } - - switch (executionStatus.getStatus()) { - case SUCCESS: - response.setResult(ExecutorStatus.STATUS_COMPLETE); - return response; - - case FAILED: - handleFailedStatus(response, executionStatus); - return response; - - case ERROR: - handleErrorStatus(response, executionStatus); - return response; - - case INCOMPLETE: - return handleIncompleteExecutionStatus(response, flowContext, context); - - default: - return handleUnknownExecutionStatus(response, executionStatus); - } - } - - /** - * Build a user-facing error message from the failure details returned by the external service. - * Prefers the failureDescription (human-readable). Falls back to failureReason if description is absent. - * - * @param failure The failure object from the external service. - * @return A display-ready error message string. - */ - private String buildUserFacingErrorMessage(Failure failure) { - - String description = failure.getFailureDescription(); - String reason = failure.getFailureReason(); - - if (description != null && !description.isEmpty()) { - return description; - } - if (reason != null && !reason.isEmpty()) { - return reason; - } - return "The operation could not be completed due to an external service failure."; - } - - private ExecutorResponse buildErrorResponse(String errorMessage, String errorDescription) { - - ExecutorResponse response = new ExecutorResponse(); - response.setResult(ExecutorStatus.STATUS_ERROR); - response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - response.setErrorMessage(errorMessage); - response.setErrorDescription(errorDescription); - return response; - } - - private void applyRetryMetadata(ExecutorResponse response, String actionId) { - - Map additionalInfo = response.getAdditionalInfo(); - if (additionalInfo == null) { - additionalInfo = new HashMap<>(); - } - additionalInfo.put(InFlowExtensionConstants.FAILURE_TYPE_KEY, - InFlowExtensionConstants.IN_FLOW_EXTENSION_FAILURE_TYPE); - response.setAdditionalInfo(additionalInfo); - - if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension action returned FAILED. actionId: " + actionId - + ", reason: " + additionalInfo.get(InFlowExtensionConstants.FAILURE_MESSAGE_KEY)); - } - } - - private void handleFailedStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { - - response.setResult(ExecutorStatus.STATUS_RETRY); - Failure failure = (Failure) executionStatus.getResponse(); - if (failure == null) { - return; - } - - Map failureInfo = new HashMap<>(); - if (failure.getFailureReason() != null) { - failureInfo.put(InFlowExtensionConstants.FAILURE_MESSAGE_KEY, failure.getFailureReason()); - } - if (failure.getFailureDescription() != null) { - failureInfo.put(InFlowExtensionConstants.FAILURE_DESCRIPTION_KEY, failure.getFailureDescription()); - } - response.setAdditionalInfo(failureInfo); - response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_FAILURE.getCode()); - response.setErrorMessage(buildUserFacingErrorMessage(failure)); - } - - private void handleErrorStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { - - response.setResult(ExecutorStatus.STATUS_ERROR); - response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - Error error = (Error) executionStatus.getResponse(); - if (error == null) { - return; - } - - response.setErrorMessage(stripI18nBraces(error.getErrorMessage())); - response.setErrorDescription(stripI18nBraces(error.getErrorDescription())); - } - - private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse response, FlowContext flowContext, - FlowExecutionContext context) { - - String redirectUrl = flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class); - if (redirectUrl == null || redirectUrl.isEmpty()) { - // Defensive: response processor should have rejected this earlier. - LOG.debug("In-Flow Extension returned INCOMPLETE without a redirect URL."); - triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, - "In-Flow Extension returned INCOMPLETE without a redirect URL."); - return buildErrorResponse("Extension returned INCOMPLETE without a redirect URL.", - "The external extension returned an incomplete response. Please try again."); - } - - String otfi = generateUniqueOtfi(context.getContextIdentifier()); - Map redirectProps = new HashMap<>(); - redirectProps.put(Constants.OTFI, otfi); - response.setContextProperty(redirectProps); - - String urlWithFlowId = appendFlowId(redirectUrl, otfi); - Map redirectInfo = new HashMap<>(); - redirectInfo.put(Constants.REDIRECT_URL, urlWithFlowId); - response.setAdditionalInfo(redirectInfo); - - response.setResult(ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); - triggerDiagnosticSuccess(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, - "In-Flow Extension returned INCOMPLETE with a redirect URL."); - - if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension returned INCOMPLETE. Redirect initiated and flowId (OTFI) generated."); - } - - return response; - } - - private ExecutorResponse handleUnknownExecutionStatus(ExecutorResponse response, - ActionExecutionStatus executionStatus) { - - LOG.warn("Unknown execution status: " + executionStatus.getStatus()); - response.setResult(ExecutorStatus.STATUS_ERROR); - response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - response.setErrorMessage("Extension returned an unexpected response."); - response.setErrorDescription("The In-Flow Extension returned an unrecognised status. Please try again."); - return response; - } - - private String generateUniqueOtfi(String currentContextIdentifier) { - - // Avoid accidental collision with the current context identifier. - String otfi = UUID.randomUUID().toString(); - while (otfi.equals(currentContextIdentifier)) { - otfi = UUID.randomUUID().toString(); - } - return otfi; - } - - private String appendFlowId(String redirectUrl, String otfi) { - - String separator = redirectUrl.contains("?") ? "&" : "?"; - return redirectUrl + separator + "flowId=" + otfi; - } - - @SuppressWarnings("unchecked") - private void applyPendingContextUpdates(ExecutorResponse response, FlowContext flowContext, String actionId) { - - Map pendingClaims = - flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); - if (pendingClaims != null && !pendingClaims.isEmpty()) { - response.setUpdatedUserClaims(pendingClaims); - } - - Map pendingCredentials = - flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class); - if (pendingCredentials != null && !pendingCredentials.isEmpty()) { - response.setUserCredentials(pendingCredentials); - } - - Map pendingProperties = - flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - if (pendingProperties != null && !pendingProperties.isEmpty()) { - response.setContextProperty(pendingProperties); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension action succeeded. actionId: " + actionId - + ", pendingClaims: " + (pendingClaims != null ? pendingClaims.size() : 0) - + ", pendingCredentials: " + (pendingCredentials != null ? pendingCredentials.size() : 0) - + ", pendingProperties: " + (pendingProperties != null ? pendingProperties.size() : 0)); - } - } - - private void triggerDiagnosticFailure(String actionId, String resultMessage) { - - triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.EXECUTE, actionId, resultMessage); - } - - private void triggerDiagnosticFailure(String diagnosticActionId, String actionId, String resultMessage) { - - if (!LoggerUtils.isDiagnosticLogsEnabled()) { - return; - } - - DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) - .resultMessage(resultMessage) - .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.FAILED); - - if (actionId != null) { - builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); - } - - LoggerUtils.triggerDiagnosticLogEvent(builder); - } - - private void triggerDiagnosticSuccess(String diagnosticActionId, String actionId, String resultMessage) { - - if (!LoggerUtils.isDiagnosticLogsEnabled()) { - return; - } - - DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) - .resultMessage(resultMessage) - .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); - - if (actionId != null) { - builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); - } - - LoggerUtils.triggerDiagnosticLogEvent(builder); - } - - private ActionExecutorService getActionExecutorService() { - - return InFlowExtensionDataHolder.getInstance().getActionExecutorService(); - } - - /** - * Log an {@link ActionExecutionException} at the appropriate level based on its root cause. - * Config and contract violations are logged at WARN; infrastructure and unexpected failures at ERROR. - */ - private void logActionExecutionException(ActionExecutionException e, String actionId) { - - Throwable cause = e.getCause(); - if (cause instanceof ActionExecutionRequestBuilderException) { - LOG.warn("In-Flow Extension action '" + actionId - + "' request build failed. Check action access configuration: " + e.getMessage()); - } else if (cause instanceof ActionExecutionResponseProcessorException) { - LOG.error("In-Flow Extension action '" + actionId - + "' response processing failed (extension contract violation or internal error).", e); - } else { - LOG.error("Error executing In-Flow Extension action '" + actionId + "'.", e); - } - } - - /** - * Strip the {@code {{...}}} wrapper from an i18n key so the JSP error page can resolve it - * via {@code AuthenticationEndpointUtil.i18n(resourceBundle, key)}. Raw text values (without - * the wrapper) and {@code null} are returned unchanged. - */ - private static String stripI18nBraces(String value) { - - if (value == null) { - return null; - } - if (value.startsWith("{{") && value.endsWith("}}") && value.length() > 4) { - return value.substring(2, value.length() - 2); - } - return value; - } - - /** - * Read a single metadata value from the current node's executor configuration. - * - * @param context The FlowExecutionContext. - * @param key The metadata key. - * @return The value, or {@code null} if not found. - */ - private String getMetadataValue(FlowExecutionContext context, String key) { - - NodeConfig currentNode = context.getCurrentNode(); - if (currentNode == null) { - return null; - } - ExecutorDTO executorConfig = currentNode.getExecutorConfig(); - if (executorConfig == null) { - return null; - } - Map metadata = executorConfig.getMetadata(); - if (metadata == null || metadata.isEmpty()) { - return null; - } - return metadata.get(key); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java deleted file mode 100644 index b036f5eabe34..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilder.java +++ /dev/null @@ -1,800 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.extensions.model.*; -import org.wso2.carbon.utils.DiagnosticLog; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; -import org.wso2.carbon.identity.action.execution.api.model.Application; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Operation; -import org.wso2.carbon.identity.action.execution.api.model.Organization; -import org.wso2.carbon.identity.action.execution.api.model.Tenant; -import org.wso2.carbon.identity.action.execution.api.model.User; -import org.wso2.carbon.identity.action.execution.api.model.UserClaim; -import org.wso2.carbon.identity.action.execution.api.model.UserStore; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; -import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.core.context.IdentityContext; -import org.wso2.carbon.identity.core.util.IdentityTenantUtil; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionPathUtil; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * This class is responsible for building the {@link ActionExecutionRequest} for In-Flow Extension - * actions. - * - *

    Responsibility: expose-based filtering and request construction. - * It receives a {@link FlowContext} containing the full {@link FlowExecutionContext}, the expose - * list, and the access config. It filters the {@link FlowExecutionContext} data according - * to the expose configuration and maps the result into the {@link InFlowExtensionEvent} model. - * Modify paths from the access config are converted to a single REPLACE {@link AllowedOperation}.

    - */ -public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuilder { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionRequestBuilder.class); - private static final int MAX_DIAGNOSTIC_PATHS = 20; - private static final String DIAGNOSTIC_REASON = "reason"; - private static final String REASON_UNSUPPORTED_ACTION = "unsupported-action-model"; - private static final String REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS = "omitted-encrypted-expose-paths"; - - @Override - public ActionType getSupportedActionType() { - - return ActionType.FLOW_EXTENSIONS; - } - - @Override - public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContext, - ActionExecutionRequestContext actionExecutionContext) - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = getFlowExecutionContextOrThrow(flowContext); - ResolvedActionConfig resolvedActionConfig = resolveActionConfig(actionExecutionContext, execCtx.getFlowType()); - if (resolvedActionConfig.isFallback()) { - return buildFallbackRequest(flowContext, execCtx); - } - - AccessConfig accessConfig = resolvedActionConfig.getAccessConfig(); - Encryption encryption = resolvedActionConfig.getEncryption(); - - List exposePaths = resolveExposePaths(accessConfig); - List modifyPaths = resolveModifyPaths(accessConfig); - flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); - - List allowedOperations = buildAllowedOperations(accessConfig, flowContext); - String certificatePEM = resolveOutboundCertificate(accessConfig, encryption); - ExposeResolution exposeResolution = pruneEncryptedExposePathsWithoutCertificate( - exposePaths, accessConfig, certificatePEM); - - triggerRequestBuildDiagnostic(execCtx, exposeResolution.getEffectiveExposePaths(), - modifyPaths, encryption, exposeResolution.getOmittedEncryptedExposePaths()); - - InFlowExtensionEvent event = buildEvent(execCtx, exposeResolution.getEffectiveExposePaths(), - accessConfig, certificatePEM); - return buildRequestPayload(event, allowedOperations); - } - - /** - * Build the allowed operations advertised to the external extension service. Always - * includes a REDIRECT operation so the extension may signal external redirection. A - * REPLACE operation is added only when the access config defines modify paths. Path - * type annotations (e.g. {@code []} or {@code [schema]}) are stripped and stored in - * the {@link FlowContext} under {@link InFlowExtensionConstants#PATH_TYPE_ANNOTATIONS_KEY} - * for the response processor. - * - * @param accessConfig The access config containing modify paths (may be null). - * @param flowContext The FlowContext to store path annotations. - * @return List of allowed operations (REPLACE if applicable, plus REDIRECT). - */ - private List buildAllowedOperations(AccessConfig accessConfig, - FlowContext flowContext) { - - List allowedOps = new ArrayList<>(); - - if (hasModifyPaths(accessConfig)) { - AllowedModifyExtraction extraction = extractAllowedModifyPaths(accessConfig.getModify()); - addReplaceOperationIfAny(allowedOps, extraction.getCleanPaths()); - storeAnnotationsIfAny(flowContext, extraction.getPathTypeAnnotations()); - - if (LOG.isDebugEnabled() && !extraction.getSkippedPaths().isEmpty()) { - LOG.debug("Skipped " + extraction.getSkippedPaths().size() - + " modify path(s) due to invalid or missing path definitions."); - } - } - - addRedirectOperation(allowedOps); - - return allowedOps; - } - - /** - * Build the {@link InFlowExtensionEvent} from the {@link FlowExecutionContext}, - * filtering data according to the expose configuration. - * Values for expose paths with {@code encrypted: true} are JWE-encrypted using the - * external service's certificate before being included in the event. - * - * @param context The FlowExecutionContext (full source of truth). - * @param expose The expose prefix list controlling which data is included. - * @param accessConfig The access config (may be null if no encryption). - * @param certificatePEM The external service's certificate PEM for JWE encryption (may be null). - * @return The InFlowExtensionEvent. - */ - private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - InFlowExtensionEvent.Builder eventBuilder = new InFlowExtensionEvent.Builder(); - InFlowExtensionFlow.Builder flowBuilder = new InFlowExtensionFlow.Builder(); - - applyTenant(eventBuilder, context, expose); - applyOrganization(eventBuilder, expose); - applyApplication(eventBuilder, context, expose); - applyUserAndUserStore(flowBuilder, context, expose, accessConfig, certificatePEM); - applyFlowMetadata(flowBuilder, eventBuilder, context, expose); - applyFlowProperties(eventBuilder, context, expose, accessConfig, certificatePEM); - - eventBuilder.flow(flowBuilder.build()); - return eventBuilder.build(); - } - - /** - * Build the {@link User} model from {@link FlowUser}, filtering by expose config. - * Encrypts credential and claim values for expose paths marked as encrypted. - * - * @param flowUser The FlowUser from the FlowExecutionContext. - * @param expose The expose prefix list. - * @param accessConfig The access config with encryption flags (may be null). - * @param certificatePEM The certificate PEM for JWE encryption (may be null). - * @return The filtered User model with encrypted values where configured. - */ - private User buildUser(FlowUser flowUser, List expose, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - User.Builder userBuilder = new User.Builder(resolveUserId(flowUser, expose)); - List userClaims = buildFilteredClaims(flowUser, expose, accessConfig, certificatePEM); - if (!userClaims.isEmpty()) { - userBuilder.claims(userClaims); - } - - Map filteredCredentials = - buildFilteredCredentials(flowUser, expose, accessConfig, certificatePEM); - if (!filteredCredentials.isEmpty()) { - userBuilder.userCredentials(filteredCredentials); - } - - if (isLeafExposed(InFlowExtensionConstants.USER_STORE_DOMAIN_PATH, expose)) { - String userStoreDomain = flowUser.getUserStoreDomain(); - userBuilder.userStoreDomain(new UserStore(userStoreDomain != null ? userStoreDomain : "")); - } - - return userBuilder.build(); - } - - private FlowExecutionContext getFlowExecutionContextOrThrow(FlowContext flowContext) - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = flowContext.getValue( - InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); - if (execCtx == null) { - throw new ActionExecutionRequestBuilderException("FlowExecutionContext not found in FlowContext."); - } - return execCtx; - } - - private ResolvedActionConfig resolveActionConfig(ActionExecutionRequestContext actionExecutionContext, - String flowType) { - - Action rawAction = actionExecutionContext.getAction(); - if (rawAction instanceof InFlowExtensionAction) { - InFlowExtensionAction ext = (InFlowExtensionAction) rawAction; - return new ResolvedActionConfig(ext.resolveAccessConfig(flowType), ext.getEncryption(), false); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("No InFlowExtensionAction resolved. Falling back to an empty request body."); - } - return new ResolvedActionConfig(null, null, true); - } - - private List resolveExposePaths(AccessConfig accessConfig) { - - if (accessConfig == null || accessConfig.getExpose() == null) { - return Collections.emptyList(); - } - return accessConfig.getExposePaths(); - } - - private List resolveModifyPaths(AccessConfig accessConfig) { - - if (accessConfig == null || accessConfig.getModify() == null) { - return Collections.emptyList(); - } - return accessConfig.getModify(); - } - - private String resolveOutboundCertificate(AccessConfig accessConfig, Encryption encryption) { - - if (accessConfig == null || encryption == null || encryption.getCertificate() == null) { - return null; - } - return encryption.getCertificate().getCertificateContent(); - } - - private ExposeResolution pruneEncryptedExposePathsWithoutCertificate(List exposePaths, - AccessConfig accessConfig, - String certificatePEM) { - - if (certificatePEM != null || accessConfig == null || exposePaths.isEmpty()) { - return new ExposeResolution(exposePaths, Collections.emptyList()); - } - - List effectiveExposePaths = new ArrayList<>(); - List omittedEncryptedExposePaths = new ArrayList<>(); - for (String exposePath : exposePaths) { - if (accessConfig.isExposePathEncrypted(exposePath)) { - omittedEncryptedExposePaths.add(exposePath); - } else { - effectiveExposePaths.add(exposePath); - } - } - - if (!omittedEncryptedExposePaths.isEmpty()) { - LOG.warn("Outbound certificate is not configured. Omitted " + omittedEncryptedExposePaths.size() - + " encrypted expose path(s)."); - triggerOmittedExposeDiagnostic(omittedEncryptedExposePaths); - } - - return new ExposeResolution(effectiveExposePaths, omittedEncryptedExposePaths); - } - - private ActionExecutionRequest buildFallbackRequest(FlowContext flowContext, FlowExecutionContext execCtx) { - - flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, Collections.emptyList()); - List allowedOperations = buildAllowedOperations(null, flowContext); - triggerFallbackDiagnostic(execCtx); - - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flow(new InFlowExtensionFlow.Builder() - .flowId(execCtx.getContextIdentifier()) - .build()) - .build(); - return buildRequestPayload(event, allowedOperations); - } - - private ActionExecutionRequest buildRequestPayload(InFlowExtensionEvent event, - List allowedOperations) { - - return new ActionExecutionRequest.Builder() - .actionType(ActionType.FLOW_EXTENSIONS) - .event(event) - .allowedOperations(allowedOperations) - .build(); - } - - private void triggerRequestBuildDiagnostic(FlowExecutionContext execCtx, List exposePaths, - List modifyPaths, Encryption encryption, - List omittedEncryptedExposePaths) { - - if (!LoggerUtils.isDiagnosticLogsEnabled()) { - return; - } - - DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( - ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, - ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) - .resultMessage("Building request for In-Flow Extension action.") - .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) - .configParam("flowType", execCtx.getFlowType()) - .configParam("exposePaths", exposePaths.size()) - .configParam("modifyPaths", modifyPaths.size()) - .configParam("outboundEncryption", encryption != null) - .inputParam("exposePathKeys", limitForDiagnostic(exposePaths)) - .inputParam("modifyPathKeys", limitForDiagnostic(extractModifyPathKeys(modifyPaths))) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); - - if (!omittedEncryptedExposePaths.isEmpty()) { - diagnosticLogBuilder - .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) - .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)); - } - - LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); - } - - private List extractModifyPathKeys(List modifyPaths) { - - List modifyPathKeys = new ArrayList<>(); - for (ContextPath modifyPath : modifyPaths) { - if (modifyPath != null && modifyPath.getPath() != null) { - modifyPathKeys.add(modifyPath.getPath()); - } - } - return modifyPathKeys; - } - - private List limitForDiagnostic(List values) { - - if (values.size() <= MAX_DIAGNOSTIC_PATHS) { - return values; - } - return new ArrayList<>(values.subList(0, MAX_DIAGNOSTIC_PATHS)); - } - - private void triggerFallbackDiagnostic(FlowExecutionContext execCtx) { - - if (!LoggerUtils.isDiagnosticLogsEnabled()) { - return; - } - - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, - ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) - .resultMessage("No InFlowExtensionAction resolved. Built minimal fallback request.") - .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) - .configParam("flowType", execCtx.getFlowType()) - .configParam(DIAGNOSTIC_REASON, REASON_UNSUPPORTED_ACTION) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); - } - - private void triggerOmittedExposeDiagnostic(List omittedEncryptedExposePaths) { - - if (!LoggerUtils.isDiagnosticLogsEnabled()) { - return; - } - - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, - ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) - .resultMessage("Omitted encrypted expose paths because outbound certificate is not configured.") - .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) - .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) - .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); - } - - private boolean hasModifyPaths(AccessConfig accessConfig) { - - return accessConfig != null && accessConfig.getModify() != null && !accessConfig.getModify().isEmpty(); - } - - private AllowedModifyExtraction extractAllowedModifyPaths(List modifyPaths) { - - List cleanPaths = new ArrayList<>(); - Map pathTypeAnnotations = new HashMap<>(); - List skippedPaths = new ArrayList<>(); - - for (ContextPath modifyPath : modifyPaths) { - String rawPath = modifyPath == null ? null : modifyPath.getPath(); - if (rawPath == null) { - skippedPaths.add(""); - } else { - String[] strippedPath = PathTypeAnnotationUtil.stripAnnotation(rawPath); - String cleanPath = strippedPath[0]; - String annotation = strippedPath[1]; - - boolean isAnnotationValid = annotation == null || PathTypeAnnotationUtil - .validateAnnotationLimits(annotation); - if (isAnnotationValid) { - if (annotation != null) { - pathTypeAnnotations.put(cleanPath, annotation); - } - cleanPaths.add(toExternalPath(cleanPath)); - } else { - LOG.warn("Annotation for path " + cleanPath - + " exceeds maximum attribute limit. Skipping path."); - skippedPaths.add(cleanPath); - } - } - } - - return new AllowedModifyExtraction(cleanPaths, pathTypeAnnotations, skippedPaths); - } - - private void addReplaceOperationIfAny(List allowedOperations, List cleanPaths) { - - if (cleanPaths.isEmpty()) { - return; - } - - AllowedOperation replaceOp = new AllowedOperation(); - replaceOp.setOp(Operation.REPLACE); - replaceOp.setPaths(cleanPaths); - allowedOperations.add(replaceOp); - } - - private void storeAnnotationsIfAny(FlowContext flowContext, Map pathTypeAnnotations) { - - if (!pathTypeAnnotations.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); - } - } - - private void addRedirectOperation(List allowedOperations) { - - AllowedOperation redirectOp = new AllowedOperation(); - redirectOp.setOp(Operation.REDIRECT); - allowedOperations.add(redirectOp); - } - - private void applyTenant(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, - List expose) { - - if (!isLeafExposed(InFlowExtensionConstants.FLOW_TENANT_PATH, expose)) { - return; - } - - String tenantDomain = context.getTenantDomain(); - if (tenantDomain != null) { - int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); - eventBuilder.tenant(new Tenant(String.valueOf(tenantId), tenantDomain)); - } else { - eventBuilder.tenant(new Tenant("", "")); - } - } - - private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder, List expose) { - - if (!isAreaExposed(InFlowExtensionConstants.ORGANIZATION_PREFIX, expose)) { - return; - } - - org.wso2.carbon.identity.core.context.model.Organization coreOrg = - IdentityContext.getThreadLocalIdentityContext().getOrganization(); - if (coreOrg == null) { - return; - } - - Organization.Builder orgBuilder = new Organization.Builder(); - - if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_ID_PATH, expose)) { - orgBuilder.id(coreOrg.getId()); - } - if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_NAME_PATH, expose)) { - orgBuilder.name(coreOrg.getName()); - } - if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_HANDLE_PATH, expose)) { - orgBuilder.orgHandle(coreOrg.getOrganizationHandle()); - } - if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_DEPTH_PATH, expose)) { - orgBuilder.depth(coreOrg.getDepth()); - } - - eventBuilder.organization(orgBuilder.build()); - } - - private void applyApplication(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, - List expose) { - - if (!isLeafExposed(InFlowExtensionConstants.FLOW_APP_ID_PATH, expose)) { - return; - } - - String appId = context.getApplicationId(); - eventBuilder.application(new Application(appId != null ? appId : "", null)); - } - - private void applyUserAndUserStore(InFlowExtensionFlow.Builder flowBuilder, FlowExecutionContext context, - List expose, AccessConfig accessConfig, - String certificatePEM) throws ActionExecutionRequestBuilderException { - - if (!isAreaExposed(InFlowExtensionConstants.USER_PREFIX, expose)) { - return; - } - - FlowUser flowUser = context.getFlowUser(); - if (flowUser == null) { - return; - } - - flowBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); - } - - private void applyFlowMetadata(InFlowExtensionFlow.Builder flowBuilder, - InFlowExtensionEvent.Builder eventBuilder, - FlowExecutionContext context, List expose) { - - if (isLeafExposed(InFlowExtensionConstants.FLOW_TYPE_PATH, expose)) { - flowBuilder.flowType(context.getFlowType() != null ? context.getFlowType() : ""); - } - - flowBuilder.flowId(context.getContextIdentifier()); - - if (isLeafExposed(InFlowExtensionConstants.FLOW_CALLBACK_URL_PATH, expose)) { - eventBuilder.callbackUrl(context.getCallbackUrl() != null ? context.getCallbackUrl() : ""); - } - - if (isLeafExposed(InFlowExtensionConstants.FLOW_PORTAL_URL_PATH, expose)) { - eventBuilder.portalUrl(context.getPortalUrl() != null ? context.getPortalUrl() : ""); - } - } - - private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, - List expose, AccessConfig accessConfig, - String certificatePEM) throws ActionExecutionRequestBuilderException { - - if (!isAreaExposed(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX, expose)) { - return; - } - - Map properties = context.getProperties(); - Map filteredProperties = new HashMap<>(); - - for (String exposePath : expose) { - if (!exposePath.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { - continue; - } - String propKey = exposePath.substring(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX.length()); - Object value = properties != null ? properties.get(propKey) : null; - if (value != null && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { - value = encryptValue(String.valueOf(value), certificatePEM); - } - filteredProperties.put(propKey, value != null ? value : ""); - } - - eventBuilder.flowProperties(filteredProperties); - } - - private String resolveUserId(FlowUser flowUser, List expose) { - - if (isLeafExposed(InFlowExtensionConstants.USER_ID_PATH, expose)) { - String userId = flowUser.getId(); - return userId != null ? userId : ""; - } - return null; - } - - private List buildFilteredClaims(FlowUser flowUser, List expose, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - if (!isAreaExposed(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX, expose)) { - return Collections.emptyList(); - } - - Map claims = flowUser.getClaims(); - List userClaims = new ArrayList<>(); - - for (String exposePath : expose) { - if (!exposePath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { - continue; - } - String claimKey = exposePath.substring(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); - String claimValue = claims != null ? claims.get(claimKey) : null; - claimValue = claimValue != null ? claimValue : ""; - if (!claimValue.isEmpty() && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { - claimValue = encryptValue(claimValue, certificatePEM); - } - userClaims.add(new UserClaim(claimKey, claimValue)); - } - return userClaims; - } - - private Map buildFilteredCredentials(FlowUser flowUser, List expose, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - if (!isAreaExposed(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX, expose)) { - return Collections.emptyMap(); - } - - Map credentials = flowUser.getUserCredentials(); - Map filteredCredentials = new HashMap<>(); - - for (String exposePath : expose) { - if (!exposePath.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { - continue; - } - String credKey = exposePath.substring(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX.length()); - char[] credValue = credentials != null ? credentials.get(credKey) : null; - - if (credValue != null) { - String plaintext = new String(credValue); - java.util.Arrays.fill(credValue, '\0'); - filteredCredentials.put(credKey, - toEncryptedOrPlainCredentialChars(plaintext, exposePath, accessConfig, certificatePEM)); - } else { - filteredCredentials.put(credKey, new char[0]); - } - } - return filteredCredentials; - } - - private char[] toEncryptedOrPlainCredentialChars(String plaintext, String credentialPath, - AccessConfig accessConfig, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - if (shouldEncrypt(credentialPath, accessConfig, certificatePEM)) { - return encryptValue(plaintext, certificatePEM).toCharArray(); - } - return plaintext.toCharArray(); - } - - private static class ResolvedActionConfig { - - private final AccessConfig accessConfig; - private final Encryption encryption; - private final boolean fallback; - - private ResolvedActionConfig(AccessConfig accessConfig, Encryption encryption, boolean fallback) { - - this.accessConfig = accessConfig; - this.encryption = encryption; - this.fallback = fallback; - } - - private AccessConfig getAccessConfig() { - - return accessConfig; - } - - private Encryption getEncryption() { - - return encryption; - } - - private boolean isFallback() { - - return fallback; - } - } - - private static class ExposeResolution { - - private final List effectiveExposePaths; - private final List omittedEncryptedExposePaths; - - private ExposeResolution(List effectiveExposePaths, List omittedEncryptedExposePaths) { - - this.effectiveExposePaths = effectiveExposePaths; - this.omittedEncryptedExposePaths = omittedEncryptedExposePaths; - } - - private List getEffectiveExposePaths() { - - return effectiveExposePaths; - } - - private List getOmittedEncryptedExposePaths() { - - return omittedEncryptedExposePaths; - } - } - - private static class AllowedModifyExtraction { - - private final List cleanPaths; - private final Map pathTypeAnnotations; - private final List skippedPaths; - - private AllowedModifyExtraction(List cleanPaths, Map pathTypeAnnotations, - List skippedPaths) { - - this.cleanPaths = cleanPaths; - this.pathTypeAnnotations = pathTypeAnnotations; - this.skippedPaths = skippedPaths; - } - - private List getCleanPaths() { - - return cleanPaths; - } - - private Map getPathTypeAnnotations() { - - return pathTypeAnnotations; - } - - private List getSkippedPaths() { - - return skippedPaths; - } - } - - /** - * Convert an internal path to its external (API-facing) form. - * User-claim paths stored internally as {@code /user/claims/} are emitted - * externally as {@code /user/claims[uri=]}. All other paths are unchanged. - */ - private static String toExternalPath(String internalPath) { - - if (internalPath != null - && internalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { - String claimUri = internalPath.substring( - InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); - return InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + claimUri - + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX; - } - return internalPath; - } - - /** - * Check if any exposed leaf path falls under the given area prefix. - * Used as a gate before iterating a data block (claims, credentials, properties). - */ - private boolean isAreaExposed(String areaPrefix, List expose) { - - return InFlowExtensionPathUtil.anyExposedUnder(areaPrefix, expose); - } - - /** - * Check if a specific leaf path is present in the expose list. - * Used for exact leaf-level inclusion decisions. - */ - private boolean isLeafExposed(String leafPath, List expose) { - - return InFlowExtensionPathUtil.isExposedPath(leafPath, expose); - } - - /** - * Determine if a value at the given path should be JWE-encrypted before sending to the - * external service. Only expose paths with {@code encrypted: true} trigger outbound encryption. - * - * @param path The expose path. - * @param accessConfig The access config with encryption flags. - * @param certificatePEM The certificate PEM (null if no encryption configured). - * @return {@code true} if the value should be encrypted. - */ - private boolean shouldEncrypt(String path, AccessConfig accessConfig, String certificatePEM) { - - if (certificatePEM == null || accessConfig == null) { - return false; - } - return accessConfig.isExposePathEncrypted(path); - } - - /** - * JWE-encrypt a plaintext value using the external service's certificate. - * - * @param plaintext The value to encrypt. - * @param certificatePEM The external service's certificate PEM. - * @return The JWE compact serialization string. - * @throws ActionExecutionRequestBuilderException If encryption fails. - */ - private String encryptValue(String plaintext, String certificatePEM) - throws ActionExecutionRequestBuilderException { - - try { - return JWEEncryptionUtil.encrypt(plaintext, certificatePEM); - } catch (Exception e) { - throw new ActionExecutionRequestBuilderException( - "Failed to JWE-encrypt outbound value for In-Flow Extension action.", e); - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java deleted file mode 100644 index 6006aa36006d..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessor.java +++ /dev/null @@ -1,750 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; -import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; -import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; -import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.Error; -import org.wso2.carbon.identity.action.execution.api.model.ErrorStatus; -import org.wso2.carbon.identity.action.execution.api.model.FailedStatus; -import org.wso2.carbon.identity.action.execution.api.model.Failure; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Incomplete; -import org.wso2.carbon.identity.action.execution.api.model.IncompleteStatus; -import org.wso2.carbon.identity.action.execution.api.model.Operation; -import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; -import org.wso2.carbon.identity.action.execution.api.model.Success; -import org.wso2.carbon.identity.action.execution.api.model.SuccessStatus; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.extensions.model.AccessConfig; -import org.wso2.carbon.identity.flow.extensions.util.InFlowExtensionPathUtil; -import org.wso2.carbon.identity.flow.extensions.model.ContextPath; -import org.wso2.carbon.identity.flow.extensions.model.OperationExecutionResult; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.utils.DiagnosticLog; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * This class is responsible for processing the response from In-Flow Extension actions. - * - *

    Responsibility: operation processing and collecting context updates into pending maps - * that are stored in {@link FlowContext} for the executor to forward to {@code TaskExecutionNode} - * via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse} fields. - * It processes {@code REPLACE} operations on flow properties, user claims, and user credentials.

    - * - *

    Only {@code REPLACE} operations are supported. The {@code allowedOperations} list - * (derived from modify paths and sent to the external service in the request, enforced - * upstream by {@code ActionExecutorServiceImpl}) is the sole mechanism for gating which - * operations are permitted. This processor performs additional validations:

    - *
      - *
    • Read-only areas: No modifications allowed to {@code /flow/} paths.
    • - *
    - */ -public class InFlowExtensionResponseProcessor implements ActionExecutionResponseProcessor { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionResponseProcessor.class); - - /** Shared error message for null-value REPLACE operations. */ - private static final String ERROR_VALUE_REQUIRED_FOR_REPLACE = "Value is required for REPLACE operation."; - - /** Diagnostic log parameter key for the action type. */ - private static final String DIAG_PARAM_ACTION_TYPE = "actionType"; - - @Override - public ActionType getSupportedActionType() { - - return ActionType.FLOW_EXTENSIONS; - } - - @Override - @SuppressWarnings("unchecked") - public ActionExecutionStatus processSuccessResponse(FlowContext flowContext, - ActionExecutionResponseContext responseContext) - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = flowContext.getValue( - InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); - String tenantDomain = execCtx != null ? execCtx.getTenantDomain() : null; - - // Read path type annotations set by the request builder. - // Maps clean paths to annotation content. - Map pathTypeAnnotations = flowContext.getValue( - InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); - if (pathTypeAnnotations == null) { - pathTypeAnnotations = Collections.emptyMap(); - } - - // Reconstruct AccessConfig from the resolved modify paths stored by the request builder. - // This reuses AccessConfig.isModifyPathEncrypted() for canonical prefix-based encryption checking. - List modifyPaths = flowContext.getValue( - InFlowExtensionConstants.MODIFY_PATHS_KEY, List.class); - AccessConfig accessConfig = modifyPaths != null ? new AccessConfig(null, modifyPaths) : null; - - // Accumulate pending updates — applied by TaskExecutionNode via ExecutorResponse fields. - Map pendingClaims = new HashMap<>(); - Map pendingCredentials = new HashMap<>(); - Map pendingProperties = new HashMap<>(); - - List results = new ArrayList<>(); - - List operations = - responseContext.getActionInvocationResponse().getOperations(); - - if (operations != null && !operations.isEmpty()) { - for (PerformableOperation operation : operations) { - operation = decryptOperationValueIfNeeded(operation, accessConfig, tenantDomain); - results.add(processOperation( - operation, pathTypeAnnotations, pendingClaims, pendingCredentials, - pendingProperties, tenantDomain)); - } - } else { - if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension SUCCESS response contained no operations. No context updates applied."); - } - } - - // Store non-empty pending maps in FlowContext for the executor to forward to TaskExecutionNode. - if (!pendingClaims.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PENDING_CLAIMS_KEY, pendingClaims); - } - if (!pendingCredentials.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, pendingCredentials); - } - if (!pendingProperties.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, pendingProperties); - } - - logOperationExecutionResults(results); - - return new SuccessStatus.Builder() - .setSuccess(new InFlowExtensionSuccess()) - .setResponseContext(Collections.emptyMap()) - .build(); - } - - - /** - * Process a single operation by validating and collecting it into the appropriate pending map. - * Updates are not applied directly — they are stored in the pending maps and forwarded to - * {@code TaskExecutionNode} via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse}. - * - * @param operation The operation to process. - * @param pathTypeAnnotations Map of clean paths to their type annotations from allowed operations. - * @param pendingClaims Accumulator map for user claim updates. - * @param pendingCredentials Accumulator map for user credential updates. - * @param pendingProperties Accumulator map for flow property updates. - * @param tenantDomain Tenant domain, used for claim URI validation. - * @return The result of the operation execution. - */ - private OperationExecutionResult processOperation(PerformableOperation operation, - Map pathTypeAnnotations, - Map pendingClaims, - Map pendingCredentials, - Map pendingProperties, - String tenantDomain) { - - // Reject null/empty path early to prevent NPE in subsequent startsWith checks. - String path = operation.getPath(); - if (path == null || path.isEmpty()) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Operation path is null or empty."); - } - - // Only REPLACE is supported; reject any other operation type. - if (operation.getOp() != Operation.REPLACE) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Unsupported operation type: " + operation.getOp() + ". Only REPLACE is supported."); - } - - // Check if operation is on a read-only area. - if (InFlowExtensionPathUtil.isReadOnly(path)) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Path is in a read-only area. Modifications not allowed: " + path); - } - - // Route to appropriate handler based on path prefix. - if (path.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { - return handlePropertyOperation(operation, pathTypeAnnotations, pendingProperties); - } else if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX)) { - return handleUserClaimOperation(operation, pendingClaims, tenantDomain); - } else if (path.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { - return handleUserCredentialOperation(operation, pendingCredentials); - } - - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Unknown path prefix. Supported: " + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX + - ", " + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + "" + - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX + - ", " + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); - } - - /** - * Handle operation on flow properties — collect into pending properties map. - * - *

    Only flat property paths are supported (e.g., {@code /properties/riskScore}). - * Value coercion is applied based on path type annotations from the request builder via - * {@link PathTypeAnnotationUtil#coerceValue}.

    - * - * @param operation The performable operation. - * @param pathTypeAnnotations Path type annotations map from request builder. - * @param pendingProperties Accumulator map for property updates. - */ - private OperationExecutionResult handlePropertyOperation(PerformableOperation operation, - Map pathTypeAnnotations, Map pendingProperties) { - - String propertyName = extractNameFromPath(operation.getPath(), - InFlowExtensionConstants.PROPERTIES_PATH_PREFIX); - - if (propertyName == null || propertyName.isEmpty()) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Invalid property path. Property name is required."); - } - - if (operation.getValue() == null) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - ERROR_VALUE_REQUIRED_FOR_REPLACE); - } - - // Validate complex object structure against annotation schema before coercion. - if (!PathTypeAnnotationUtil.validateValueAgainstAnnotation( - operation.getPath(), operation.getValue(), pathTypeAnnotations)) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Value does not match annotation schema for path: " + operation.getPath()); - } - - // Coerce value based on path type annotation. - Object coercedValue = PathTypeAnnotationUtil.coerceValue( - operation.getPath(), operation.getValue(), pathTypeAnnotations); - - // Enforce array item limits after coercion. - if (!PathTypeAnnotationUtil.enforceArrayItemLimit( - operation.getPath(), coercedValue, pathTypeAnnotations)) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Array value exceeds maximum item limit for path: " + operation.getPath()); - } - - pendingProperties.put(propertyName, coercedValue); - - return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, - "Property replace applied."); - } - - /** - * Handle REPLACE operation on user claims — validate the claim URI then collect into pending - * claims map. Validation gates: - *
      - *
    1. Claim URI must be in the WSO2 local claim dialect ({@code http://wso2.org/claims/}).
    2. - *
    3. Identity-system claims ({@code http://wso2.org/claims/identity/}) are rejected.
    4. - *
    5. If {@link org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService} - * is available, the claim URI must correspond to a registered local claim; unknown - * claims are rejected. When the service is unavailable the check is skipped - * (fail-open).
    6. - *
    - * The value is always stringified via {@code String.valueOf()}. - * - * @param operation The performable operation. - * @param pendingClaims Accumulator map for user claim updates. - * @param tenantDomain Tenant domain for claim existence lookup. - */ - private OperationExecutionResult handleUserClaimOperation(PerformableOperation operation, - Map pendingClaims, String tenantDomain) { - - String claimUri = extractClaimUriFromPath(operation.getPath()); - - if (claimUri == null || claimUri.isEmpty()) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Invalid claim path. Claim URI is required."); - } - - if (operation.getValue() == null) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - ERROR_VALUE_REQUIRED_FOR_REPLACE); - } - - // Validate claim URI — must be local dialect, not an identity sub-claim. - if (!claimUri.startsWith(PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX)) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Claim URI must be in the local dialect (" + - PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX + "): " + claimUri); - } - if (claimUri.startsWith(PathTypeAnnotationUtil.IDENTITY_CLAIM_URI_PREFIX)) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Identity-system claims cannot be modified by In-Flow Extensions: " + claimUri); - } - - // Verify claim existence via ClaimMetadataManagementService when available. - String claimValidationFailure = validateLocalClaimExists(claimUri, tenantDomain); - if (claimValidationFailure != null) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - claimValidationFailure); - } - - pendingClaims.put(claimUri, String.valueOf(operation.getValue())); - return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, - "User claim replace applied."); - } - - /** - * Validate that the given claim URI corresponds to a registered local claim in the tenant. - * Returns {@code null} if the claim is valid or if the service is unavailable (fail-open). - * Returns a descriptive failure message if validation fails. - * - * @param claimUri Local claim URI to check. - * @param tenantDomain Tenant domain for the lookup. - * @return Failure reason string, or {@code null} when validation passes or is skipped. - */ - private String validateLocalClaimExists(String claimUri, String tenantDomain) { - - org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService claimService = - InFlowExtensionDataHolder.getInstance().getClaimMetadataManagementService(); - if (claimService == null) { - if (LOG.isDebugEnabled()) { - LOG.debug("ClaimMetadataManagementService is unavailable. Skipping claim existence check for: " - + claimUri); - } - return null; - } - try { - java.util.Optional localClaim = claimService.getLocalClaim(claimUri, tenantDomain); - if (!localClaim.isPresent()) { - return "Unknown local claim URI. Claim is not registered in the system: " + claimUri; - } - return null; - } catch (ClaimMetadataException e) { - LOG.warn("Failed to look up claim URI '" + claimUri + "' in tenant '" + tenantDomain + - "'. Skipping claim existence check.", e); - return null; - } - } - - /** - * Handle REPLACE operation on user credentials — collect into pending credentials map. - * No key validation is applied; any credential key is accepted. The value is converted - * to {@code char[]} immediately to avoid holding the secret as a plain {@code String} - * any longer than necessary. - * - * @param operation The performable operation. - * @param pendingCredentials Accumulator map for user credential updates. - */ - private OperationExecutionResult handleUserCredentialOperation(PerformableOperation operation, - Map pendingCredentials) { - - String credentialKey = extractNameFromPath(operation.getPath(), - InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); - - if (credentialKey == null || credentialKey.isEmpty()) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - "Invalid credential path. Credential key is required."); - } - - if (operation.getValue() == null) { - return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, - ERROR_VALUE_REQUIRED_FOR_REPLACE); - } - - pendingCredentials.put(credentialKey, String.valueOf(operation.getValue()).toCharArray()); - return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, - "User credential replace applied."); - } - - /** - * Extract the name/key from the operation path after the prefix. - */ - private String extractNameFromPath(String path, String prefix) { - - if (path == null || !path.startsWith(prefix)) { - return null; - } - - String remaining = path.substring(prefix.length()); - - if (remaining.endsWith("/")) { - remaining = remaining.substring(0, remaining.length() - 1); - } - - return remaining; - } - - /** - * Extract the claim URI from an external-format claim path. - * Accepts the selector form {@code /user/claims[uri=]}. - * Returns {@code null} if the path is null or does not match the expected format. - */ - private String extractClaimUriFromPath(String path) { - - if (path == null) { - return null; - } - if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) - && path.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { - return path.substring( - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), - path.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); - } - return null; - } - - /** - * Normalize an external-format claim path to the internal format for encryption checks. - * Converts {@code /user/claims[uri=]} to {@code /user/claims/}. - * All other paths are returned unchanged. - */ - private static String normalizeToInternalPath(String externalPath) { - - if (externalPath != null - && externalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) - && externalPath.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { - String claimUri = externalPath.substring( - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), - externalPath.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); - return InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX + claimUri; - } - return externalPath; - } - - @Override - public ActionExecutionStatus processIncompleteResponse(FlowContext flowContext, - ActionExecutionResponseContext responseContext) - throws ActionExecutionResponseProcessorException { - - // Contract: INCOMPLETE must carry a REDIRECT op. Every other op is intentionally - // discarded — the extension is expected to resend them on the resume call after redirect. - List operations = - responseContext.getActionInvocationResponse().getOperations(); - IncompleteOpScan scan = scanIncompleteOperations(operations); - - validateRedirectPresent(scan.redirectUrl); - logIgnoredIncompleteOps(scan.ignoredOpCount); - - flowContext.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, scan.redirectUrl); - debugLogRedirectHost(scan.redirectUrl); - logIncompleteSuccess(); - - return new IncompleteStatus.Builder() - .responseContext(Collections.emptyMap()) - .build(); - } - - /** - * Scan the operation list from an INCOMPLETE response to extract the redirect URL and count - * non-REDIRECT operations that will be ignored. - * - * @param operations List of operations from the INCOMPLETE response (may be null). - * @return Scan result holding the redirect URL and ignored-op count. - */ - private IncompleteOpScan scanIncompleteOperations(List operations) { - - if (operations == null) { - return new IncompleteOpScan(null, 0); - } - String redirectUrl = null; - int ignoredOpCount = 0; - for (PerformableOperation op : operations) { - if (op.getOp() == Operation.REDIRECT) { - redirectUrl = op.getUrl(); - } else { - ignoredOpCount++; - } - } - return new IncompleteOpScan(redirectUrl, ignoredOpCount); - } - - /** - * Assert that a redirect URL was found in the INCOMPLETE response; throw and emit diagnostics - * if it is absent. - */ - private void validateRedirectPresent(String redirectUrl) - throws ActionExecutionResponseProcessorException { - - if (redirectUrl != null && !redirectUrl.isEmpty()) { - return; - } - LOG.warn("In-Flow Extension INCOMPLETE response is missing a REDIRECT operation."); - if (LoggerUtils.isDiagnosticLogsEnabled()) { - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, - InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) - .resultMessage( - "INCOMPLETE response from In-Flow Extension is missing a REDIRECT operation.") - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.FAILED)); - } - throw new ActionExecutionResponseProcessorException( - "INCOMPLETE response from In-Flow Extension must contain a REDIRECT operation."); - } - - /** Log the number of non-REDIRECT ops discarded in an INCOMPLETE response, if any. */ - private void logIgnoredIncompleteOps(int ignoredOpCount) { - - if (ignoredOpCount > 0 && LOG.isDebugEnabled()) { - LOG.debug("Ignored " + ignoredOpCount + " non-REDIRECT operation(s) on INCOMPLETE response. " - + "REPLACE ops are by-contract dropped — the extension is expected to resend " - + "them on the resume call after callback."); - } - } - - /** Log the redirect URL host at DEBUG level after parsing the URI. */ - private void debugLogRedirectHost(String redirectUrl) { - - if (!LOG.isDebugEnabled()) { - return; - } - try { - String host = new java.net.URI(redirectUrl).getHost(); - LOG.debug("In-Flow Extension INCOMPLETE: redirect URL host resolved: " + host); - } catch (java.net.URISyntaxException ignored) { - LOG.debug("In-Flow Extension INCOMPLETE: redirect URL stored in flow context."); - } - } - - /** Emit a diagnostic success event after a valid INCOMPLETE response is processed. */ - private void logIncompleteSuccess() { - - if (LoggerUtils.isDiagnosticLogsEnabled()) { - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, - InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) - .resultMessage( - "In-Flow Extension INCOMPLETE response processed. Redirect URL stored in flow context.") - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); - } - } - - /** Lightweight value holder for the result of scanning INCOMPLETE response operations. */ - private static final class IncompleteOpScan { - - private final String redirectUrl; - private final int ignoredOpCount; - - private IncompleteOpScan(String redirectUrl, int ignoredOpCount) { - - this.redirectUrl = redirectUrl; - this.ignoredOpCount = ignoredOpCount; - } - } - - @Override - public ActionExecutionStatus processErrorResponse(FlowContext flowContext, - ActionExecutionResponseContext responseContext) - throws ActionExecutionResponseProcessorException { - - String errorMessage = responseContext.getActionInvocationResponse().getErrorMessage(); - String errorDescription = responseContext.getActionInvocationResponse().getErrorDescription(); - - if (LOG.isDebugEnabled()) { - LOG.debug("Processing error response from In-Flow Extension. Error: " + errorMessage + - ", Description: " + errorDescription); - } - - return new ErrorStatus(new Error(errorMessage, errorDescription)); - } - - @Override - public ActionExecutionStatus processFailureResponse(FlowContext flowContext, - ActionExecutionResponseContext responseContext) - throws ActionExecutionResponseProcessorException { - - String failureReason = responseContext.getActionInvocationResponse().getFailureReason(); - String failureDescription = responseContext.getActionInvocationResponse().getFailureDescription(); - - if (LOG.isDebugEnabled()) { - LOG.debug("Processing failure response from In-Flow Extension. Reason: " + failureReason + - ", Description: " + failureDescription); - } - - return new FailedStatus(new Failure(failureReason, failureDescription)); - } - - /** - * Log operation execution results for diagnostics and debugging. - */ - private void logOperationExecutionResults(List results) { - - if (results.isEmpty()) { - return; - } - - if (LoggerUtils.isDiagnosticLogsEnabled()) { - List> operationDetailsList = new ArrayList<>(); - results.forEach(result -> { - Map details = new HashMap<>(); - details.put("operation", result.getOperation().getOp() + " path: " + - result.getOperation().getPath()); - details.put("status", result.getStatus().toString()); - details.put("message", result.getMessage()); - operationDetailsList.add(details); - }); - - DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( - ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, - ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_RESPONSE); - diagnosticLogBuilder - .inputParam("executedOperations", operationDetailsList) - .resultMessage("Processed operations for " + getSupportedActionType().getDisplayName() + - " action.") - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.SUCCESS) - .build(); - LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); - } - - if (LOG.isDebugEnabled()) { - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); - try { - String summary = mapper.writeValueAsString(results); - LOG.debug(String.format("Processed response for action type: %s. Results: %s", - getSupportedActionType(), summary)); - } catch (JsonProcessingException e) { - LOG.debug("Error occurred while logging operation execution results.", e); - } - } - } - - /** - * Inner class representing a successful In-Flow Extension execution result. - */ - public static class InFlowExtensionSuccess implements Success { - - // Marker class for successful execution. - } - - // ========================= Inbound decryption ========================= - - /** - * Decrypt the operation value if the operation path has encryption enabled in the AccessConfig. - * Uses {@link AccessConfig#isModifyPathEncrypted(String)} for canonical checking. - * For operations with encrypted paths, checks if the value looks like a JWE compact - * serialization and decrypts it using the IS's private key. - * - * @param operation The operation to potentially decrypt. - * @param accessConfig The access config with encryption flags (may be null). - * @param tenantDomain Tenant domain for IS private key resolution. - * @return The operation with decrypted value, or the original operation if no decryption needed. - */ - private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation operation, - AccessConfig accessConfig, String tenantDomain) - throws ActionExecutionResponseProcessorException { - - if (accessConfig == null || operation.getValue() == null) { - return operation; - } - - // Normalize external claim path to internal format before checking encryption flags. - String internalPath = normalizeToInternalPath(operation.getPath()); - if (!accessConfig.isModifyPathEncrypted(internalPath)) { - return operation; - } - - // For encrypted modify paths the value MUST be a JWE compact serialization. - // Accepting plaintext would make the encryption flag advisory; it is enforced here. - Object value = operation.getValue(); - - if (!(value instanceof String)) { - emitEncryptionContractViolation(operation.getPath(), - "Value for encrypted modify path is not a String."); - throw new ActionExecutionResponseProcessorException( - "Value for encrypted modify path '" + operation.getPath() + - "' must be a JWE-encrypted string, but received a non-String value."); - } - - String stringValue = (String) value; - if (!JWEEncryptionUtil.isJWEEncrypted(stringValue)) { - emitEncryptionContractViolation(operation.getPath(), - "Value for encrypted modify path is not JWE-encrypted."); - throw new ActionExecutionResponseProcessorException( - "Value for encrypted modify path '" + operation.getPath() + - "' must be JWE-encrypted, but received a plaintext value."); - } - - try { - String decrypted = JWEEncryptionUtil.decrypt(stringValue, tenantDomain); - PerformableOperation decryptedOp = new PerformableOperation(); - decryptedOp.setOp(operation.getOp()); - decryptedOp.setPath(operation.getPath()); - decryptedOp.setValue(decrypted); - if (LOG.isDebugEnabled()) { - LOG.debug("Successfully decrypted inbound JWE value for path: " + operation.getPath()); - } - return decryptedOp; - } catch (Exception e) { - LOG.error("Failed to decrypt inbound JWE value for path '" + operation.getPath() - + "', tenant: " + tenantDomain, e); - if (LoggerUtils.isDiagnosticLogsEnabled()) { - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, - InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) - .resultMessage("Failed to decrypt inbound JWE value for modify path.") - .configParam("actionType", ActionType.FLOW_EXTENSIONS.getDisplayName()) - .inputParam("path", operation.getPath()) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.FAILED)); - } - throw new ActionExecutionResponseProcessorException( - "Failed to decrypt inbound JWE value for path: " + operation.getPath(), e); - } - } - - // ========================= Decryption diagnostics helpers ========================= - - /** - * Emit a diagnostic failure event for a broken encryption contract on a modify path. - * Extracted to keep {@link #decryptOperationValueIfNeeded} readable. - */ - private void emitEncryptionContractViolation(String path, String reason) { - - if (LoggerUtils.isDiagnosticLogsEnabled()) { - LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( - InFlowExtensionConstants.Log.COMPONENT_ID, - InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) - .resultMessage(reason) - .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSIONS.getDisplayName()) - .inputParam("path", path) - .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) - .resultStatus(DiagnosticLog.ResultStatus.FAILED)); - } - } - -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java deleted file mode 100644 index f3b390d10543..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/JWEEncryptionUtil.java +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import com.nimbusds.jose.EncryptionMethod; -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.JWEAlgorithm; -import com.nimbusds.jose.JWEHeader; -import com.nimbusds.jose.JWEObject; -import com.nimbusds.jose.Payload; -import com.nimbusds.jose.crypto.RSADecrypter; -import com.nimbusds.jose.crypto.RSAEncrypter; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.base.MultitenantConstants; -import org.wso2.carbon.core.util.KeyStoreManager; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; -import org.wso2.carbon.identity.core.util.IdentityTenantUtil; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.security.cert.CertificateExpiredException; -import java.security.cert.CertificateFactory; -import java.security.cert.CertificateNotYetValidException; -import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.text.ParseException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; - -/** - * Utility class for JWE encryption and decryption operations used by In-Flow Extension actions. - * - *

    Outbound encryption (IS → external service): uses the external service's X.509 - * certificate public key with {@code RSA-OAEP-256} + {@code A256GCM}. This follows the same - * pattern as {@code PasswordUpdatingUser.encryptCredential()}.

    - * - *

    Inbound decryption (external service → IS): uses the IS server's RSA private key - * retrieved via {@link KeyStoreManager} for the tenant. This follows the same - * pattern as {@code UserAssertionUtils.getPrivateKey()}.

    - */ -public final class JWEEncryptionUtil { - - private static final Log LOG = LogFactory.getLog(JWEEncryptionUtil.class); - - /** Cache of resolved private keys with TTL, keyed by tenant ID. */ - private static final Map PRIVATE_KEYS = new ConcurrentHashMap<>(); - private static final long PRIVATE_KEY_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(30); - - private JWEEncryptionUtil() { - - } - - /** - * Encrypt a plaintext string using JWE with the external service's X.509 certificate. - * Uses RSA-OAEP-256 key encryption and A256GCM content encryption. - * - * @param plaintext The plaintext JSON string to encrypt. - * @param certificatePEM The external service's X.509 certificate in PEM format. - * @return JWE compact serialization string (5-part dot-separated). - * @throws ActionExecutionException If encryption fails. - */ - public static String encrypt(String plaintext, String certificatePEM) throws ActionExecutionException { - - try { - X509Certificate certificate = parsePEMCertificate(certificatePEM); - RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); - - JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) - .contentType("application/json") - .build(); - - JWEObject jweObject = new JWEObject(header, new Payload(plaintext)); - jweObject.encrypt(new RSAEncrypter(publicKey)); - - return jweObject.serialize(); - } catch (JOSEException e) { - throw new ActionExecutionException( - "Failed to JWE-encrypt data for In-Flow Extension action.", e); - } - } - - /** - * Decrypt a JWE compact string using the IS server's RSA private key. - * The private key is resolved via {@link KeyStoreManager} for the tenant. - * - * @param jweString The JWE compact serialization string. - * @param tenantDomain The tenant domain to resolve the private key. - * @return Decrypted plaintext string. - * @throws ActionExecutionException If decryption fails. - */ - public static String decrypt(String jweString, String tenantDomain) throws ActionExecutionException { - - try { - JWEObject jweObject = JWEObject.parse(jweString); - - Key privateKey = getPrivateKey(tenantDomain); - - RSADecrypter decrypter = new RSADecrypter((RSAPrivateKey) privateKey); - jweObject.decrypt(decrypter); - - return jweObject.getPayload().toString(); - } catch (ParseException e) { - throw new ActionExecutionException( - "Failed to parse JWE string from In-Flow Extension response.", e); - } catch (JOSEException e) { - throw new ActionExecutionException( - "Failed to decrypt JWE value from In-Flow Extension response.", e); - } catch (ActionExecutionException e) { - throw e; - } catch (Exception e) { - throw new ActionExecutionException( - "Error resolving IS private key for In-Flow Extension JWE decryption.", e); - } - } - - /** - * Retrieve the IS server's RSA private key for the given tenant. - * Uses {@link KeyStoreManager} directly, bypassing IdentityKeyStoreResolver, - * to avoid requiring a protocol-specific keystore mapping. - * - * @param tenantDomain The tenant domain. - * @return The RSA private key. - * @throws ActionExecutionException If retrieval fails. - */ - private static Key getPrivateKey(String tenantDomain) throws ActionExecutionException { - - int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); - String cacheKey = String.valueOf(tenantId); - CachedKey cached = PRIVATE_KEYS.get(cacheKey); - if (cached != null && !cached.isExpired()) { - return cached.getKey(); - } - - try { - IdentityTenantUtil.initializeRegistry(tenantId); - KeyStoreManager keyStoreManager = KeyStoreManager.getInstance(tenantId); - Key privateKey; - - if (MultitenantConstants.SUPER_TENANT_DOMAIN_NAME.equals(tenantDomain)) { - privateKey = keyStoreManager.getDefaultPrivateKey(); - } else { - String tenantKeyStoreName = tenantDomain.trim().replace(".", "-") + ".jks"; - privateKey = keyStoreManager.getPrivateKey(tenantKeyStoreName, tenantDomain); - } - - PRIVATE_KEYS.put(cacheKey, new CachedKey(privateKey)); - return privateKey; - } catch (Exception e) { - throw new ActionExecutionException( - "Error retrieving private key for tenant: " + tenantDomain, e); - } - } - - /** - * Detect whether a string value is a JWE compact serialization. - * A JWE compact serialization has exactly 5 dot-separated Base64url-encoded parts. - * - * @param value The value to check. - * @return {@code true} if the value appears to be a JWE compact string. - */ - public static boolean isJWEEncrypted(String value) { - - if (value == null || value.isEmpty()) { - return false; - } - // Count dots — JWE compact serialization has exactly 4 dots (5 parts). - int dotCount = 0; - for (int i = 0; i < value.length(); i++) { - if (value.charAt(i) == '.') { - dotCount++; - if (dotCount > 4) { - return false; - } - } - } - return dotCount == 4; - } - - /** - * Parse a Base64-encoded PEM X.509 certificate string into an {@link X509Certificate} object. - * Expects the input to be a fully Base64-encoded string of a standard PEM file. - * - * @param base64EncodedPem The Base64 encoded PEM string. - * @return The parsed X509Certificate. - * @throws ActionExecutionException If parsing or decoding fails. - */ - public static X509Certificate parsePEMCertificate(String base64EncodedPem) throws ActionExecutionException { - - if (base64EncodedPem == null || base64EncodedPem.trim().isEmpty()) { - throw new ActionExecutionException("Certificate string is null or empty."); - } - - try { - byte[] decodedPemBytes = java.util.Base64.getDecoder().decode(base64EncodedPem.trim()); - - CertificateFactory factory = CertificateFactory.getInstance("X.509"); - X509Certificate certificate = - (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decodedPemBytes)); - certificate.checkValidity(); - return certificate; - - } catch (CertificateExpiredException e) { - throw new ActionExecutionException( - "Certificate has expired for In-Flow Extension action.", e); - } catch (CertificateNotYetValidException e) { - throw new ActionExecutionException( - "Certificate is not yet valid for In-Flow Extension action.", e); - } catch (IllegalArgumentException e) { - throw new ActionExecutionException( - "Failed to decode certificate: Input is not valid Base64.", e); - } catch (Exception e) { - throw new ActionExecutionException( - "Failed to parse the decoded PEM certificate for In-Flow Extension action.", e); - } - } - - /** - * Cache wrapper for private keys with TTL support. - */ - private static class CachedKey { - - private final Key key; - private final long createdAt; - - CachedKey(Key key) { - - this.key = key; - this.createdAt = System.currentTimeMillis(); - } - - Key getKey() { - - return key; - } - - boolean isExpired() { - - return (System.currentTimeMillis() - createdAt) > PRIVATE_KEY_CACHE_TTL_MS; - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java deleted file mode 100644 index f3bf8654fada..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtil.java +++ /dev/null @@ -1,418 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Utility class for path type annotation parsing, stripping, and value coercion. - * - *

    Path type annotations use a trailing brace expression at the end of a modify path - * to declare the expected data type for that path. The unified format uses curly braces:

    - *
      - *
    • {@code /properties/risk-factor{String}} — primary data type.
    • - *
    • {@code /properties/risk-factors{[String]}} — multivalued primary (array of type).
    • - *
    • {@code /properties/risk{risk: Float, factor: String}} — complex object with schema.
    • - *
    • {@code /properties/risk{[risk: Float, factor: String]}} — multivalued complex object array.
    • - *
    - * - *

    This class provides methods to strip annotations from paths and coerce incoming values - * based on the stored annotations.

    - */ -public final class PathTypeAnnotationUtil { - - /** - * Regex pattern to match a trailing curly brace annotation at the end of a path. - * Captures the content inside the braces (Group 1). - * Examples: {@code {String}}, {@code {[String]}}, {@code {risk: Float, factor: String}}. - */ - static final Pattern ANNOTATION_PATTERN = Pattern.compile("\\{([^}]*)}$"); - - /** Claim URI prefix for the WSO2 local claim dialect. */ - static final String LOCAL_CLAIM_DIALECT_PREFIX = "http://wso2.org/claims/"; - - /** Claim URI prefix for WSO2 identity claims (subset of local claims, not user-modifiable). */ - static final String IDENTITY_CLAIM_URI_PREFIX = "http://wso2.org/claims/identity/"; - - /** Reusable ObjectMapper for parsing JSON-string values received for complex-typed paths. */ - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private PathTypeAnnotationUtil() { - - } - - /** - * Strip a trailing path type annotation from a raw path. - * - * @param rawPath The raw path potentially containing a trailing {@code {annotation}}. - * @return A two-element array: {@code [cleanPath, annotation]}. - * If no annotation is found, annotation element is {@code null}. - */ - public static String[] stripAnnotation(String rawPath) { - - if (rawPath == null) { - return new String[]{null, null}; - } - - Matcher matcher = ANNOTATION_PATTERN.matcher(rawPath); - if (matcher.find()) { - String cleanPath = rawPath.substring(0, matcher.start()); - String annotation = matcher.group(1); - return new String[]{cleanPath, annotation}; - } - return new String[]{rawPath, null}; - } - - /** - * Coerce a value based on path type annotations. - * - *

    Annotation interpretation:

    - *
      - *
    • {@code null} (no annotation): value is coerced to String via {@code String.valueOf()}.
    • - *
    • Starts with {@code [} and contains {@code :} (e.g., {@code [risk: Float]}): - * complex object array — value is passed through as-is.
    • - *
    • Starts with {@code [} without {@code :} (e.g., {@code [String]}): - * multivalued primary type — value is expected to be a List; each element coerced to String. - * A single value is wrapped into a list.
    • - *
    • Contains {@code :} (e.g., {@code risk: Float, factor: String}): - * complex object — value is passed through as-is.
    • - *
    • Any other annotation (e.g., {@code String}, {@code Integer}): - * primary type — value is coerced to String via {@code String.valueOf()}.
    • - *
    - * - * @param path The operation path (used as lookup key in annotations map). - * @param value The raw value from the operation. - * @param pathTypeAnnotations Map from clean path to annotation content (may be empty). - * @return The coerced value. - */ - @SuppressWarnings("unchecked") - public static Object coerceValue(String path, Object value, - Map pathTypeAnnotations) { - - if (value == null) { - return null; - } - - String annotation = pathTypeAnnotations.get(path); - - if (annotation == null) { - // No annotation: coerce to String. - return String.valueOf(value); - } - - // Check for multivalued annotation: starts with [ - if (annotation.startsWith("[")) { - String inner = annotation.substring(1, annotation.length() - 1); - if (inner.contains(":")) { - // Complex object array (e.g., [risk: Float, factor: String]): - // parse JSON string if needed, then pass through. - return tryParseJsonString(value); - } - // Multivalued primary type (e.g., [String], [Integer]): coerce to List. - // Parse JSON string first in case the value arrived as "[\"a\",\"b\"]". - Object resolvedList = tryParseJsonString(value); - if (resolvedList instanceof List) { - List rawList = (List) resolvedList; - List stringList = new ArrayList<>(); - for (Object item : rawList) { - stringList.add(item == null ? null : String.valueOf(item)); - } - return stringList; - } - // Single value — wrap in a list. - List singleList = new ArrayList<>(); - singleList.add(String.valueOf(value)); - return singleList; - } - - // Check for complex object annotation: contains ":" - if (annotation.contains(":")) { - // Complex object (e.g., risk: Float, factor: String): - // parse JSON string if needed, then pass through. - return tryParseJsonString(value); - } - - // Primary type annotation (e.g., String, Integer, Boolean): coerce to String. - return String.valueOf(value); - } - - /** Maximum number of attributes allowed in a complex object annotation. */ - static final int MAX_ATTRIBUTES_PER_OBJECT = 10; - - /** Maximum number of items allowed in an array (primary or complex object array). */ - static final int MAX_ARRAY_ITEMS = 10; - - /** - * Validate that a complex object annotation does not exceed the maximum attribute count. - * Should be called on the raw annotation content (inside braces) before stripping. - * - * @param annotation The annotation content (e.g., {@code "risk: Float, factor: String"} - * or {@code "[risk: Float, factor: String]"}). May be {@code null}. - * @return {@code true} if the annotation is valid (within limits or not a complex annotation). - */ - public static boolean validateAnnotationLimits(String annotation) { - - if (annotation == null || annotation.isEmpty()) { - return true; - } - - String inner = annotation; - // Unwrap array brackets if present. - if (inner.startsWith("[") && inner.endsWith("]")) { - inner = inner.substring(1, inner.length() - 1); - } - - // Only validate complex annotations (those with attribute definitions containing ':'). - if (!inner.contains(":")) { - return true; - } - - return parseAnnotationAttributes(inner).size() <= MAX_ATTRIBUTES_PER_OBJECT; - } - - /** - * Validate a complex object value against its path type annotation schema. - * - *

    Validates:

    - *
      - *
    • Value is a Map with attribute names matching the annotation schema.
    • - *
    • Only one nesting level: attributes must be primary types or primary arrays.
    • - *
    • Attribute count does not exceed {@link #MAX_ATTRIBUTES_PER_OBJECT}.
    • - *
    • Array attributes do not exceed {@link #MAX_ARRAY_ITEMS} items.
    • - *
    - * - *

    For non-complex annotations (primary types, primary arrays), this method returns - * {@code true} without further validation since those are handled by coercion.

    - * - * @param path The operation path. - * @param value The value to validate. - * @param pathTypeAnnotations Map from clean path to annotation content. - * @return {@code true} if the value is valid against the annotation, {@code false} otherwise. - */ - @SuppressWarnings("unchecked") - public static boolean validateValueAgainstAnnotation(String path, Object value, - Map pathTypeAnnotations) { - - String annotation = pathTypeAnnotations.get(path); - if (annotation == null) { - return true; - } - - boolean isArray = annotation.startsWith("["); - String inner = isArray ? annotation.substring(1, annotation.length() - 1) : annotation; - - // Only validate complex annotations (those with attribute definitions). - if (!inner.contains(":")) { - return true; - } - - Map schema = parseAnnotationAttributes(inner); - - // Parse JSON string if the value arrived as serialized JSON - // (e.g., after JWE decryption or when the external service stringifies before encrypting). - Object resolvedValue = tryParseJsonString(value); - - if (isArray) { - // Complex object array: validate each item. - if (!(resolvedValue instanceof List)) { - return false; - } - List items = (List) resolvedValue; - if (items.size() > MAX_ARRAY_ITEMS) { - return false; - } - for (Object item : items) { - if (!validateSingleComplexObject(item, schema)) { - return false; - } - } - return true; - } - - // Single complex object. - return validateSingleComplexObject(resolvedValue, schema); - } - - /** - * Enforce array item limits on a value. Applies to both primary arrays and complex object arrays. - * - * @param path The operation path. - * @param value The value to check. - * @param pathTypeAnnotations Map from clean path to annotation content. - * @return {@code true} if the value is within array item limits, {@code false} otherwise. - */ - @SuppressWarnings("unchecked") - public static boolean enforceArrayItemLimit(String path, Object value, - Map pathTypeAnnotations) { - - String annotation = pathTypeAnnotations.get(path); - if (annotation == null || !annotation.startsWith("[")) { - return true; - } - - if (!(value instanceof List)) { - return true; - } - - return ((List) value).size() <= MAX_ARRAY_ITEMS; - } - - /** - * Attempt to parse a value as JSON if it is a String that starts with {@code [} or {@code {}. - * This handles values that arrive as serialized JSON strings — for example, after JWE - * decryption, or when an external service serialises a complex object/array before encrypting it. - * - *

    If the value is not a String, does not start with {@code [} or {@code {}, or cannot be - * parsed as valid JSON, the original value is returned unchanged.

    - * - * @param value The value to inspect. - * @return The parsed JSON structure (List or Map), or the original value if not applicable. - */ - private static Object tryParseJsonString(Object value) { - - if (!(value instanceof String)) { - return value; - } - String str = ((String) value).trim(); - if (!str.startsWith("[") && !str.startsWith("{")) { - return value; - } - try { - return OBJECT_MAPPER.readValue(str, Object.class); - } catch (IOException ignored) { - return value; - } - } - - /** - * Parse complex annotation attributes into a map of attribute name to type. - * Handles array types indicated by trailing {@code []}. - * - * @param inner The inner annotation content (e.g., {@code "risk: Float, factor: String"}). - * @return Map from attribute name to type string (e.g., {@code "Float"} or {@code "String[]"}). - */ - private static Map parseAnnotationAttributes(String inner) { - - Map attributes = new HashMap<>(); - String[] parts = inner.split(","); - for (String part : parts) { - String trimmed = part.trim(); - if (trimmed.isEmpty()) { - continue; - } - int colonIndex = trimmed.indexOf(':'); - if (colonIndex > 0) { - String name = trimmed.substring(0, colonIndex).trim(); - String type = trimmed.substring(colonIndex + 1).trim(); - attributes.put(name, type); - } - } - return attributes; - } - - /** - * Validate a single complex object value against a schema. - * Ensures value is a Map, keys match schema names, attribute count within limits, - * and nested values are only primary types or primary arrays (single nesting level). - * - * @param value The value to validate (expected to be a Map). - * @param schema The annotation schema (attribute name to type). - * @return {@code true} if valid. - */ - @SuppressWarnings("unchecked") - private static boolean validateSingleComplexObject(Object value, Map schema) { - - if (!isMapAndWithinAttributeLimit(value)) { - return false; - } - - Map map = (Map) value; - - if (!containsOnlySchemaKeys(map, schema)) { - return false; - } - - for (Map.Entry entry : map.entrySet()) { - if (!validateEntryValueAgainstType(entry, schema)) { - return false; - } - } - - return true; - } - - private static boolean isMapAndWithinAttributeLimit(Object value) { - - return value instanceof Map && ((Map) value).size() <= MAX_ATTRIBUTES_PER_OBJECT; - } - - private static boolean containsOnlySchemaKeys(Map valueMap, Map schema) { - - for (String key : valueMap.keySet()) { - if (!schema.containsKey(key)) { - return false; - } - } - return true; - } - - private static boolean validateEntryValueAgainstType(Map.Entry entry, Map schema) { - - String type = schema.get(entry.getKey()); - Object attrValue = entry.getValue(); - - if (type != null && type.endsWith("[]")) { - return validatePrimaryArrayAttribute(attrValue); - } - return !isNestedStructure(attrValue); - } - - @SuppressWarnings("unchecked") - private static boolean validatePrimaryArrayAttribute(Object value) { - - if (!(value instanceof List)) { - return false; - } - - List list = (List) value; - if (list.size() > MAX_ARRAY_ITEMS) { - return false; - } - - for (Object item : list) { - if (isNestedStructure(item)) { - return false; - } - } - return true; - } - - private static boolean isNestedStructure(Object value) { - - return value instanceof Map || value instanceof List; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java deleted file mode 100644 index 77ce34a3e158..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionDataHolder.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.internal; - -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; -import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; -import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; -import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; - -/** - * Data holder for the In-Flow Extension bundle. - * - *

    This singleton is used by DS bind/unbind methods to expose dynamic OSGi references - * to classes outside the component lifecycle methods.

    - */ -public final class InFlowExtensionDataHolder { - - private static final InFlowExtensionDataHolder instance = new InFlowExtensionDataHolder(); - - private ActionExecutorService actionExecutorService; - private ActionManagementService actionManagementService; - private CertificateManagementService certificateManagementService; - private ClaimMetadataManagementService claimMetadataManagementService; - - private InFlowExtensionDataHolder() { - - } - - public static InFlowExtensionDataHolder getInstance() { - - return instance; - } - - public ActionExecutorService getActionExecutorService() { - - return actionExecutorService; - } - - public void setActionExecutorService(ActionExecutorService actionExecutorService) { - - this.actionExecutorService = actionExecutorService; - } - - public ActionManagementService getActionManagementService() { - - return actionManagementService; - } - - public void setActionManagementService(ActionManagementService actionManagementService) { - - this.actionManagementService = actionManagementService; - } - - public CertificateManagementService getCertificateManagementService() { - - return certificateManagementService; - } - - public void setCertificateManagementService(CertificateManagementService certificateManagementService) { - - this.certificateManagementService = certificateManagementService; - } - - public ClaimMetadataManagementService getClaimMetadataManagementService() { - - return claimMetadataManagementService; - } - - public void setClaimMetadataManagementService(ClaimMetadataManagementService claimMetadataManagementService) { - - this.claimMetadataManagementService = claimMetadataManagementService; - } - -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java deleted file mode 100644 index 93a9995e678c..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/internal/InFlowExtensionServiceComponent.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.internal; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.osgi.framework.BundleContext; -import org.osgi.service.component.ComponentContext; -import org.osgi.service.component.annotations.Activate; -import org.osgi.service.component.annotations.Component; -import org.osgi.service.component.annotations.Deactivate; -import org.osgi.service.component.annotations.Reference; -import org.osgi.service.component.annotations.ReferenceCardinality; -import org.osgi.service.component.annotations.ReferencePolicy; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; -import org.wso2.carbon.identity.action.management.api.service.ActionConverter; -import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; -import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; -import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; -import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; -import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; -import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionExecutor; -import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionRequestBuilder; -import org.wso2.carbon.identity.flow.extensions.executor.InFlowExtensionResponseProcessor; -import org.wso2.carbon.identity.flow.extensions.management.InFlowExtensionActionConverter; -import org.wso2.carbon.identity.flow.extensions.management.InFlowExtensionActionDTOModelResolver; - -/** - * OSGi declarative services component which registers the In-Flow Extension services. - */ -@Component( - name = "flow.extensions.component", - immediate = true) -public class InFlowExtensionServiceComponent { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionServiceComponent.class); - - @Activate - protected void activate(ComponentContext context) { - - try { - BundleContext bundleContext = context.getBundleContext(); - - bundleContext.registerService(Executor.class.getName(), new InFlowExtensionExecutor(), null); - bundleContext.registerService(ActionExecutionRequestBuilder.class.getName(), - new InFlowExtensionRequestBuilder(), null); - bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(), - new InFlowExtensionResponseProcessor(), null); - - bundleContext.registerService(ActionConverter.class.getName(), - new InFlowExtensionActionConverter(), null); - bundleContext.registerService(ActionDTOModelResolver.class.getName(), - new InFlowExtensionActionDTOModelResolver( - InFlowExtensionDataHolder.getInstance().getCertificateManagementService()), - null); - - LOG.debug("In-Flow Extension service successfully activated."); - } catch (Throwable e) { - LOG.error("Error while initiating In-Flow Extension service", e); - } - } - - @Deactivate - protected void deactivate(ComponentContext context) { - - LOG.debug("In-Flow Extension service successfully deactivated."); - } - - @Reference( - name = "ActionManagementService", - service = ActionManagementService.class, - cardinality = ReferenceCardinality.MANDATORY, - policy = ReferencePolicy.DYNAMIC, - unbind = "unsetActionManagementService" - ) - protected void setActionManagementService(ActionManagementService actionManagementService) { - - LOG.debug("Setting the ActionManagementService in the In-Flow Extension component."); - InFlowExtensionDataHolder.getInstance().setActionManagementService(actionManagementService); - } - - protected void unsetActionManagementService(ActionManagementService actionManagementService) { - - if (LOG.isDebugEnabled()) { - LOG.debug("Unsetting the ActionManagementService in the In-Flow Extension component. Service: " - + actionManagementService); - } - InFlowExtensionDataHolder.getInstance().setActionManagementService(null); - } - - @Reference( - name = "ActionExecutorService", - service = ActionExecutorService.class, - cardinality = ReferenceCardinality.MANDATORY, - policy = ReferencePolicy.DYNAMIC, - unbind = "unsetActionExecutorService" - ) - protected void setActionExecutorService(ActionExecutorService actionExecutorService) { - - LOG.debug("Setting the ActionExecutorService in the In-Flow Extension component."); - InFlowExtensionDataHolder.getInstance().setActionExecutorService(actionExecutorService); - } - - protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) { - - if (LOG.isDebugEnabled()) { - LOG.debug("Unsetting the ActionExecutorService in the In-Flow Extension component. Service: " - + actionExecutorService); - } - InFlowExtensionDataHolder.getInstance().setActionExecutorService(null); - } - - @Reference( - name = "CertificateManagementService", - service = CertificateManagementService.class, - cardinality = ReferenceCardinality.MANDATORY, - policy = ReferencePolicy.DYNAMIC, - unbind = "unsetCertificateManagementService" - ) - protected void setCertificateManagementService(CertificateManagementService certificateManagementService) { - - LOG.debug("Setting the CertificateManagementService in the In-Flow Extension component."); - InFlowExtensionDataHolder.getInstance() - .setCertificateManagementService(certificateManagementService); - } - - protected void unsetCertificateManagementService( - CertificateManagementService certificateManagementService) { - - if (LOG.isDebugEnabled()) { - LOG.debug("Unsetting the CertificateManagementService in the In-Flow Extension component. Service: " - + certificateManagementService); - } - InFlowExtensionDataHolder.getInstance().setCertificateManagementService(null); - } - - @Reference( - name = "ClaimMetadataManagementService", - service = ClaimMetadataManagementService.class, - cardinality = ReferenceCardinality.OPTIONAL, - policy = ReferencePolicy.DYNAMIC, - unbind = "unsetClaimMetadataManagementService" - ) - protected void setClaimMetadataManagementService( - ClaimMetadataManagementService claimMetadataManagementService) { - - LOG.debug("Setting the ClaimMetadataManagementService in the In-Flow Extension component."); - InFlowExtensionDataHolder.getInstance() - .setClaimMetadataManagementService(claimMetadataManagementService); - } - - protected void unsetClaimMetadataManagementService( - ClaimMetadataManagementService claimMetadataManagementService) { - - if (LOG.isDebugEnabled()) { - LOG.debug("Unsetting the ClaimMetadataManagementService in the In-Flow Extension component. Service: " - + claimMetadataManagementService); - } - InFlowExtensionDataHolder.getInstance().setClaimMetadataManagementService(null); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java deleted file mode 100644 index 26e7db244e07..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionConverter.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.management; - -import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.action.management.api.model.ActionDTO; -import org.wso2.carbon.identity.action.management.api.model.ActionProperty; -import org.wso2.carbon.identity.action.management.api.service.ActionConverter; -import org.wso2.carbon.identity.certificate.management.model.Certificate; -import org.wso2.carbon.identity.flow.extensions.model.AccessConfig; -import org.wso2.carbon.identity.flow.extensions.model.Encryption; -import org.wso2.carbon.identity.flow.extensions.model.ContextPath; -import org.wso2.carbon.identity.flow.extensions.model.InFlowExtensionAction; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; - -/** - * ActionConverter implementation for In-Flow Extension actions. - *

    - * Handles the conversion between {@link InFlowExtensionAction} (domain model) and - * {@link ActionDTO} (data transfer object) by mapping the {@link AccessConfig} fields - * to/from action properties. - *

    - */ -public class InFlowExtensionActionConverter implements ActionConverter { - - @Override - public Action.ActionTypes getSupportedActionType() { - - return Action.ActionTypes.FLOW_EXTENSIONS; - } - - /** - * Converts an {@link InFlowExtensionAction} to an {@link ActionDTO} for persistence. - * Maps the access config fields (expose, modify) and flow type overrides - * into the DTO's properties map using prefixed keys. - * - * @param action The InFlowExtensionAction to convert. - * @return ActionDTO with access config properties. - */ - @Override - public ActionDTO buildActionDTO(Action action) { - - if (!(action instanceof InFlowExtensionAction inFlowExtensionAction)) { - return new ActionDTO.Builder(action).build(); - } - - Map properties = new HashMap<>(); - putDefaultAccessConfigProperties(properties, inFlowExtensionAction.getAccessConfig()); - putEncryptionProperty(properties, inFlowExtensionAction.getEncryption()); - if (inFlowExtensionAction.getIconUrl() != null) { - properties.put(ICON_URL, - new ActionProperty.BuilderForService(inFlowExtensionAction.getIconUrl()).build()); - } - putFlowTypeOverrideProperties(properties, inFlowExtensionAction.getFlowTypeOverrides()); - - return new ActionDTO.Builder(inFlowExtensionAction) - .properties(properties) - .build(); - } - - private void putDefaultAccessConfigProperties(Map properties, AccessConfig accessConfig) { - - if (accessConfig == null) { - return; - } - if (accessConfig.getExpose() != null) { - properties.put(ACCESS_CONFIG_EXPOSE, - new ActionProperty.BuilderForService(accessConfig.getExpose()).build()); - } - if (accessConfig.getModify() != null) { - properties.put(ACCESS_CONFIG_MODIFY, - new ActionProperty.BuilderForService(accessConfig.getModify()).build()); - } - } - - private void putEncryptionProperty(Map properties, Encryption encryption) { - - if (encryption == null) { - return; - } - if (encryption.getCertificate() != null) { - properties.put(CERTIFICATE, - new ActionProperty.BuilderForService(encryption.getCertificate()).build()); - } else { - // Encryption object present but no certificate — signals explicit removal. - properties.put(CERTIFICATE, - new ActionProperty.BuilderForService("").build()); - } - } - - private void putFlowTypeOverrideProperties(Map properties, - Map overrides) { - - if (overrides == null) { - return; - } - for (Map.Entry entry : overrides.entrySet()) { - String flowType = entry.getKey(); - AccessConfig overrideConfig = entry.getValue(); - if (overrideConfig.getExpose() != null) { - properties.put(ACCESS_CONFIG_EXPOSE_PREFIX + flowType, - new ActionProperty.BuilderForService(overrideConfig.getExpose()).build()); - } - if (overrideConfig.getModify() != null) { - properties.put(ACCESS_CONFIG_MODIFY_PREFIX + flowType, - new ActionProperty.BuilderForService(overrideConfig.getModify()).build()); - } - } - } - - /** - * Converts an {@link ActionDTO} back to an {@link InFlowExtensionAction}. - * Reconstructs the default {@link AccessConfig} and per-flow-type overrides from the DTO's properties map. - * - * @param actionDTO The ActionDTO to convert. - * @return InFlowExtensionAction with access config and overrides populated. - */ - @SuppressWarnings("unchecked") - @Override - public Action buildAction(ActionDTO actionDTO) { - - // Default access config. - List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); - List modify = - (List) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); - - AccessConfig accessConfig = null; - if (expose != null || modify != null) { - accessConfig = new AccessConfig(expose, modify); - } - - // Encryption certificate (separate from access config). - Encryption encryption = null; - Object certValue = actionDTO.getPropertyValue(CERTIFICATE); - if (certValue instanceof Certificate certificate) { - encryption = new Encryption(certificate); - } - - // Icon URL. - String iconUrl = null; - Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); - if (iconUrlValue instanceof String iconUrlStr) { - iconUrl = iconUrlStr; - } - - // Reconstruct per-flow-type overrides from prefixed keys. - Map flowTypeOverrides = new HashMap<>(); - if (actionDTO.getProperties() != null) { - for (String propertyKey : actionDTO.getProperties().keySet()) { - if (propertyKey.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { - String flowType = propertyKey.substring(ACCESS_CONFIG_EXPOSE_PREFIX.length()); - AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, - new AccessConfig(null, null)); - flowTypeOverrides.put(flowType, new AccessConfig( - (List) actionDTO.getPropertyValue(propertyKey), - existing.getModify())); - } else if (propertyKey.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { - String flowType = propertyKey.substring(ACCESS_CONFIG_MODIFY_PREFIX.length()); - AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, - new AccessConfig(null, null)); - flowTypeOverrides.put(flowType, new AccessConfig( - existing.getExpose(), - (List) actionDTO.getPropertyValue(propertyKey))); - } - } - } - - return new InFlowExtensionAction.ResponseBuilder() - .id(actionDTO.getId()) - .type(actionDTO.getType()) - .name(actionDTO.getName()) - .description(actionDTO.getDescription()) - .status(actionDTO.getStatus()) - .actionVersion(actionDTO.getActionVersion()) - .createdAt(actionDTO.getCreatedAt()) - .updatedAt(actionDTO.getUpdatedAt()) - .endpoint(actionDTO.getEndpoint()) - .accessConfig(accessConfig) - .encryption(encryption) - .iconUrl(iconUrl) - .flowTypeOverrides(flowTypeOverrides) - .rule(actionDTO.getActionRule()) - .build(); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java deleted file mode 100644 index 2519b1edba66..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/management/InFlowExtensionActionDTOModelResolver.java +++ /dev/null @@ -1,604 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.management; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverClientException; -import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverException; -import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.action.management.api.model.ActionDTO; -import org.wso2.carbon.identity.action.management.api.model.ActionProperty; -import org.wso2.carbon.identity.action.management.api.model.BinaryObject; -import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; -import org.wso2.carbon.identity.certificate.management.exception.CertificateMgtException; -import org.wso2.carbon.identity.certificate.management.model.Certificate; -import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; -import org.wso2.carbon.identity.flow.extensions.model.ContextPath; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static java.util.Collections.emptyList; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.ICON_URL; -import static org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; - -/** - * ActionDTOModelResolver implementation for In-Flow Extension actions. - *

    - * Responsible for validating and transforming access config properties (expose paths and - * modify paths) between the service layer representation and the DAO layer BLOB format. - *

    - * - *
      - *
    • Add operation: Validates expose paths and modify paths, then serializes - * them to JSON {@link BinaryObject}s for BLOB storage in IDN_ACTION_PROPERTIES.
    • - *
    • Get operation: Deserializes BLOBs back to service-layer list objects.
    • - *
    • Update operation: Validates updated values or preserves existing ones (PUT semantics).
    • - *
    • Delete operation: No-op (properties are cascade-deleted with the action).
    • - *
    - */ -public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelResolver { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final TypeReference> CONTEXT_PATH_LIST_TYPE_REF = - new TypeReference>() { }; - - private final CertificateManagementService certificateManagementService; - - public InFlowExtensionActionDTOModelResolver(CertificateManagementService certificateManagementService) { - - this.certificateManagementService = certificateManagementService; - } - - @Override - public Action.ActionTypes getSupportedActionType() { - - return Action.ActionTypes.FLOW_EXTENSIONS; - } - - @Override - public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain) - throws ActionDTOModelResolverException { - - Map properties = new HashMap<>(); - - Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); - // Expose is an optional field. - if (exposeValue != null) { - List validatedExpose = validateExpose(exposeValue); - properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose)); - } - - Object modifyValue = actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); - // Modify is an optional field. - if (modifyValue != null) { - List validatedModify = validateExpose(modifyValue); - properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(validatedModify)); - } - - // Handle certificate: store via CertificateManagementService and replace with ID. - handleCertificateAdd(actionDTO, properties, tenantDomain); - - // Handle icon URL: pass through as a PRIMITIVE string. - Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); - if (iconUrlValue instanceof String iconUrlStr && !iconUrlStr.isEmpty()) { - properties.put(ICON_URL, new ActionProperty.BuilderForDAO(iconUrlStr).build()); - } - - // Handle per-flow-type override properties (prefixed keys). - resolveAddOverrideProperties(actionDTO, properties); - - return new ActionDTO.Builder(actionDTO) - .properties(properties) - .build(); - } - - private void resolveAddOverrideProperties(ActionDTO actionDTO, Map properties) - throws ActionDTOModelResolverException { - - if (actionDTO.getProperties() == null) { - return; - } - for (Map.Entry entry : actionDTO.getProperties().entrySet()) { - String key = entry.getKey(); - if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { - Object overrideExpose = actionDTO.getPropertyValue(key); - if (overrideExpose != null) { - properties.put(key, createBlobProperty(validateExpose(overrideExpose))); - } - } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { - Object overrideModify = actionDTO.getPropertyValue(key); - if (overrideModify != null) { - properties.put(key, createBlobProperty(validateExpose(overrideModify))); - } - } - } - } - - @Override - public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain) - throws ActionDTOModelResolverException { - - Map properties = new HashMap<>(); - - // Default access config properties. - if (actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { - properties.put(ACCESS_CONFIG_EXPOSE, deserializeExposeProperty( - ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)).getJSONString())); - } - - if (actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { - properties.put(ACCESS_CONFIG_MODIFY, deserializeExposeProperty( - ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)).getJSONString())); - } - - // Retrieve certificate by stored ID. - handleCertificateGet(actionDTO, properties, tenantDomain); - - // Icon URL: pass through as-is (already a PRIMITIVE string). - Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); - if (iconUrlValue != null) { - properties.put(ICON_URL, new ActionProperty.BuilderForService(iconUrlValue.toString()).build()); - } - - // Deserialize per-flow-type override properties (prefixed keys). - if (actionDTO.getProperties() != null) { - for (Map.Entry entry : actionDTO.getProperties().entrySet()) { - String key = entry.getKey(); - if ((key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) - && actionDTO.getPropertyValue(key) != null) { - properties.put(key, deserializeExposeProperty( - ((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString())); - } - } - } - - return new ActionDTO.Builder(actionDTO) - .properties(properties) - .build(); - } - - @Override - public List resolveForGetOperation(List actionDTOList, String tenantDomain) - throws ActionDTOModelResolverException { - - List resolvedList = new ArrayList<>(); - for (ActionDTO actionDTO : actionDTOList) { - resolvedList.add(resolveForGetOperation(actionDTO, tenantDomain)); - } - return resolvedList; - } - - /** - * Resolves the actionDTO for the update operation. - * When properties are updated, the existing properties are replaced with the new properties. - * When properties are not updated, the existing properties should be sent to the upstream component. - * - * @param updatingActionDTO ActionDTO that needs to be updated. - * @param existingActionDTO Existing ActionDTO. - * @param tenantDomain Tenant domain. - * @return Resolved ActionDTO. - * @throws ActionDTOModelResolverException ActionDTOModelResolverException. - */ - @Override - public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, - String tenantDomain) throws ActionDTOModelResolverException { - - Map properties = new HashMap<>(); - - // Action Properties updating operation is treated as a PUT in DAO layer. Therefore if no properties are - // updated the existing properties should be sent to the DAO layer. - - // Handle default access config properties. - List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO); - if (!expose.isEmpty()) { - properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose)); - } - - List modify = getResolvedUpdatingModify(updatingActionDTO, existingActionDTO); - if (!modify.isEmpty()) { - properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(modify)); - } - - // Handle certificate update. - handleCertificateUpdate(updatingActionDTO, existingActionDTO, properties, tenantDomain); - resolveUpdateIconUrl(updatingActionDTO, existingActionDTO, properties); - carryForwardExistingOverrides(existingActionDTO, properties); - overlayUpdatedOverrides(updatingActionDTO, properties); - - return new ActionDTO.Builder(updatingActionDTO) - .properties(properties) - .build(); - } - - private void resolveUpdateIconUrl(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, - Map properties) - throws ActionDTOModelResolverException { - - Object updatingIconUrl = updatingActionDTO.getPropertyValue(ICON_URL); - if (updatingIconUrl instanceof String updatingIconUrlStr && !updatingIconUrlStr.isEmpty()) { - properties.put(ICON_URL, new ActionProperty.BuilderForDAO(updatingIconUrlStr).build()); - } else if (existingActionDTO.getPropertyValue(ICON_URL) != null) { - properties.put(ICON_URL, new ActionProperty.BuilderForDAO( - existingActionDTO.getPropertyValue(ICON_URL).toString()).build()); - } - } - - private void carryForwardExistingOverrides(ActionDTO existingActionDTO, - Map properties) - throws ActionDTOModelResolverException { - - if (existingActionDTO.getProperties() == null) { - return; - } - for (Map.Entry entry : existingActionDTO.getProperties().entrySet()) { - String key = entry.getKey(); - if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { - properties.put(key, createBlobProperty(existingActionDTO.getPropertyValue(key))); - } - } - } - - private void overlayUpdatedOverrides(ActionDTO updatingActionDTO, Map properties) - throws ActionDTOModelResolverException { - - if (updatingActionDTO.getProperties() == null) { - return; - } - for (Map.Entry entry : updatingActionDTO.getProperties().entrySet()) { - String key = entry.getKey(); - if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { - Object overrideExpose = updatingActionDTO.getPropertyValue(key); - if (overrideExpose != null) { - properties.put(key, createBlobProperty(validateExpose(overrideExpose))); - } - } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { - Object overrideModify = updatingActionDTO.getPropertyValue(key); - if (overrideModify != null) { - properties.put(key, createBlobProperty(validateExpose(overrideModify))); - } - } - } - } - - @Override - public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenantDomain) - throws ActionDTOModelResolverException { - - // Delete the certificate if one was stored for this action. - handleCertificateDelete(deletingActionDTO, tenantDomain); - } - - // ---- Update helpers ---- - - @SuppressWarnings("unchecked") - private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO) - throws ActionDTOModelResolverException { - - if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { - return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)); - } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { - return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); - } - return emptyList(); - } - - @SuppressWarnings("unchecked") - private List getResolvedUpdatingModify(ActionDTO updatingActionDTO, - ActionDTO existingActionDTO) - throws ActionDTOModelResolverException { - - if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { - return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)); - } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { - return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); - } - return emptyList(); - } - - // ---- Validation ---- - - @SuppressWarnings("unchecked") - private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException { - - if (!(exposeValue instanceof List)) { - throw new ActionDTOModelResolverClientException("Invalid expose format.", - "Expose should be provided as a list."); - } - - List exposeList = (List) exposeValue; - List result = new ArrayList<>(); - - for (Object item : exposeList) { - if (item instanceof Map) { - Map map = (Map) item; - if (!map.containsKey("path") || !(map.get("path") instanceof String)) { - throw new ActionDTOModelResolverClientException("Invalid expose format.", - "Each expose entry must be an object with a 'path' field."); - } - String path = (String) map.get("path"); - boolean encrypted = map.containsKey("encrypted") && toBooleanSafe(map.get("encrypted")); - result.add(new ContextPath(path, encrypted)); - } else if (item instanceof ContextPath contextPath) { - result.add(contextPath); - } else { - throw new ActionDTOModelResolverClientException("Invalid expose format.", - "Each expose entry must be an object with 'path' and optional 'encrypted' fields."); - } - } - - validateExposeCount(result); - validateContextPathFormat(result); - return result; - } - - private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException { - - if (expose.size() > MAX_EXPOSE_PATHS) { - throw new ActionDTOModelResolverClientException("Maximum expose paths limit exceeded.", - String.format("The number of configured expose paths: %d exceeds the maximum allowed limit: %d.", - expose.size(), MAX_EXPOSE_PATHS)); - } - } - - private void validateContextPathFormat(List expose) throws ActionDTOModelResolverClientException { - - Set seen = new HashSet<>(); - for (ContextPath exposePath : expose) { - String path = exposePath.getPath(); - if (path == null || path.trim().isEmpty()) { - throw new ActionDTOModelResolverClientException("Invalid expose path.", - "Expose paths must not be null or empty."); - } - if (!path.startsWith("/")) { - throw new ActionDTOModelResolverClientException("Invalid expose path.", - String.format("Expose path '%s' must start with '/'.", path)); - } - if (path.endsWith("/")) { - throw new ActionDTOModelResolverClientException("Invalid expose path.", - String.format("Expose path '%s' must not end with a trailing '/'.", path)); - } - if (!seen.add(path)) { - throw new ActionDTOModelResolverClientException("Duplicate expose path.", - String.format("The expose path '%s' is duplicated.", path)); - } - } - } - - // ---- Certificate lifecycle helpers ---- - - /** - * Stores the external service's certificate via CertificateManagementService during action creation. - * The certificate PEM is replaced with the stored certificate's ID as a PRIMITIVE property. - */ - private void handleCertificateAdd(ActionDTO actionDTO, Map properties, - String tenantDomain) throws ActionDTOModelResolverException { - - Object certValue = actionDTO.getPropertyValue(CERTIFICATE); - if (certValue == null) { - return; - } - - String certificatePEM = extractCertificatePEM(certValue); - String certName = CERTIFICATE_NAME_PREFIX + actionDTO.getId(); - - try { - String certificateId = certificateManagementService.addCertificate( - new Certificate.Builder() - .name(certName) - .certificateContent(certificatePEM) - .build(), - tenantDomain); - - // Store the certificate ID as a primitive property so DAO persists just the ID. - properties.put(CERTIFICATE, - new ActionProperty.BuilderForDAO(certificateId).build()); - } catch (CertificateMgtException e) { - throw new ActionDTOModelResolverException("Error storing certificate for action: " - + actionDTO.getId(), e); - } - } - - /** - * Retrieves the certificate by its stored ID during action get operations. - * Replaces the stored ID with the full Certificate object as a service-layer property. - */ - private void handleCertificateGet(ActionDTO actionDTO, Map properties, - String tenantDomain) throws ActionDTOModelResolverException { - - Object certIdValue = actionDTO.getPropertyValue(CERTIFICATE); - if (certIdValue == null) { - return; - } - - try { - String certIdStr = certIdValue.toString(); - Certificate certificate = certificateManagementService.getCertificate( - certIdStr, tenantDomain); - properties.put(CERTIFICATE, - new ActionProperty.BuilderForService(certificate).build()); - } catch (CertificateMgtException e) { - throw new ActionDTOModelResolverException("Error retrieving certificate for action: " - + actionDTO.getId(), e); - } - } - - /** - * Handles certificate lifecycle during action update: add new, update existing, delete, or carry forward. - */ - private void handleCertificateUpdate(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, - Map properties, String tenantDomain) - throws ActionDTOModelResolverException { - - Object newCertValue = updatingActionDTO.getPropertyValue(CERTIFICATE); - Object existingCertValue = existingActionDTO.getPropertyValue(CERTIFICATE); - - // Empty string signals explicit certificate removal. - boolean isExplicitRemoval = newCertValue instanceof String s && s.isEmpty(); - - if (isExplicitRemoval && existingCertValue != null) { - // Explicitly clearing the certificate — delete the existing one. - try { - String existingCertId = extractCertificateId(existingCertValue); - certificateManagementService.deleteCertificate(existingCertId, tenantDomain); - } catch (CertificateMgtException e) { - throw new ActionDTOModelResolverException("Error deleting certificate for action: " - + updatingActionDTO.getId(), e); - } - } else if (newCertValue != null && !isExplicitRemoval && existingCertValue != null) { - // Update existing certificate. - String certificatePEM = extractCertificatePEM(newCertValue); - try { - String existingCertId = extractCertificateId(existingCertValue); - certificateManagementService.updateCertificateContent( - existingCertId, certificatePEM, tenantDomain); - // Carry forward the existing certificate ID. - properties.put(CERTIFICATE, - new ActionProperty.BuilderForDAO(existingCertId).build()); - } catch (CertificateMgtException e) { - throw new ActionDTOModelResolverException("Error updating certificate for action: " - + updatingActionDTO.getId(), e); - } - } else if (newCertValue != null && !isExplicitRemoval) { - // Add new certificate (previously had none). - handleCertificateAdd(updatingActionDTO, properties, tenantDomain); - } else if (existingCertValue != null) { - // No new certificate provided — carry forward the existing one (PUT semantics). - properties.put(CERTIFICATE, - new ActionProperty.BuilderForDAO(extractCertificateId(existingCertValue)).build()); - } - // else: both null — no certificate to handle. - } - - /** - * Deletes the certificate from IDN_CERTIFICATE during action deletion. - */ - private void handleCertificateDelete(ActionDTO deletingActionDTO, String tenantDomain) - throws ActionDTOModelResolverException { - - Object certIdValue = deletingActionDTO.getPropertyValue(CERTIFICATE); - if (certIdValue == null) { - return; - } - - try { - String certId = extractCertificateId(certIdValue); - certificateManagementService.deleteCertificate(certId, tenantDomain); - } catch (CertificateMgtException e) { - throw new ActionDTOModelResolverException("Error deleting certificate for action: " - + deletingActionDTO.getId(), e); - } - } - - /** - * Extracts the certificate UUID from a certificate property value. - *

    - * The existing action DTO may come from the GET resolver, which replaces the stored UUID - * with the full {@link Certificate} object. This method handles both cases: - * - {@link Certificate} object: extracts the ID via {@code getId()}. - * - String: assumes it is already the UUID. - *

    - */ - private String extractCertificateId(Object certValue) { - - if (certValue instanceof Certificate certificate) { - return certificate.getId(); - } - return certValue.toString(); - } - - /** - * Extracts the PEM string from a certificate value, which may be a Certificate object, - * a Map, or a plain string. - */ - private String extractCertificatePEM(Object certValue) throws ActionDTOModelResolverClientException { - - if (certValue instanceof Certificate certificate) { - return certificate.getCertificateContent(); - } else if (certValue instanceof Map) { - Map certMap = (Map) certValue; - Object content = certMap.get("certificateContent"); - if (content instanceof String pem) { - return pem; - } - throw new ActionDTOModelResolverClientException("Invalid certificate format.", - "Certificate object must contain a 'certificateContent' field."); - } else if (certValue instanceof String pem) { - return pem; - } - throw new ActionDTOModelResolverClientException("Invalid certificate format.", - "Certificate must be a PEM string, a Certificate object, or a map with 'certificateContent'."); - } - - // ---- Serialization helpers ---- - - private ActionProperty createBlobProperty(Object value) throws ActionDTOModelResolverException { - - try { - BinaryObject binaryObject = BinaryObject.fromJsonString(OBJECT_MAPPER.writeValueAsString(value)); - return new ActionProperty.BuilderForDAO(binaryObject).build(); - } catch (JsonProcessingException e) { - throw new ActionDTOModelResolverException("Failed to serialize access config property to JSON.", e); - } - } - - private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException { - - try { - List expose = OBJECT_MAPPER.readValue(jsonString, CONTEXT_PATH_LIST_TYPE_REF); - return new ActionProperty.BuilderForService(expose).build(); - } catch (IOException e) { - throw new ActionDTOModelResolverException("Error reading expose values from storage.", e); - } - } - - /** - * Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types. - * Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}. - * - * @param value The value to convert. - * @return {@code true} if the value is Boolean TRUE or the string "true" (case-insensitive). - */ - private static boolean toBooleanSafe(Object value) { - - if (value instanceof Boolean b) { - return b; - } - if (value instanceof String s) { - return Boolean.parseBoolean(s); - } - return false; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java deleted file mode 100644 index b5ba0d80e8b7..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilder.java +++ /dev/null @@ -1,295 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Set; - -/** - * Builds the controlled In-Flow Extension context tree returned by the metadata endpoint. - * Consumes the engine-level {@link FlowContextHandoverConfig} flat allow-lists and projects - * them back into the user/flow/properties tree shape the Console UI expects, so the frontend - * requires no change. Nodes are pruned when the corresponding attribute is absent from the - * allow-list. - */ -public class InFlowExtensionContextTreeBuilder { - - // Flow type identifiers — must match the values produced by FlowTypes.getType(). - private static final String FLOW_REGISTRATION = "REGISTRATION"; - private static final String FLOW_INVITED_USER_REGISTRATION = "INVITED_USER_REGISTRATION"; - private static final String FLOW_PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; - - // Node-type sentinels matching the tree component's NodeType enum on the Console side. - private static final String NODE_OBJECT = "OBJECT"; - private static final String NODE_LEAF = "LEAF"; - private static final String NODE_MAP = "MAP"; - private static final String NODE_COMPLEX_MAP = "COMPLEX_MAP"; - - private static final String OP_EXPOSE = "EXPOSE"; - private static final String OP_MODIFY = "MODIFY"; - private static final String DATA_TYPE_STRING = "String"; - - // Flow context attributes that appear under the "flow" branch of the tree. - private static final List FLOW_BRANCH_ATTRS = - Arrays.asList("tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl"); - - private final FlowContextHandoverConfig handoverConfig; - - public InFlowExtensionContextTreeBuilder(FlowContextHandoverConfig handoverConfig) { - - this.handoverConfig = handoverConfig; - } - - /** - * Build the metadata response for the given flow type. - * - * @param flowType the flow type (null → default tree). - * @return a fully populated metadata DTO. - */ - public InFlowExtensionContextTreeMetadata build(String flowType) { - - Set attrs = handoverConfig.getIncludedAttributes(); - Set userAttrs = handoverConfig.isFullUserPassthrough() - ? null // null → all user children shown - : handoverConfig.getIncludedUserAttributes(); - - List tree = new ArrayList<>(); - - // User and properties nodes are always non-null (always construct and return a node). - tree.add(buildUserNode(userAttrs)); - tree.add(buildPropertiesNode(attrs)); - - // Flow node is conditionally non-null (returns null if no flow attributes are configured). - InFlowExtensionContextTreeNode flowNode = buildFlowNode(attrs); - if (flowNode != null) { - tree.add(flowNode); - } - - return new InFlowExtensionContextTreeMetadata( - flowType, - tree, - true, // redirection is unconditionally enabled - resolveAllowReadOnlyClaimsModification(flowType)); - } - - /** - * Whether the Console UI may permit MODIFY on read-only claims for this flow type. - * Hardcoded enumerative mapping so that any future flow type defaults to false until - * explicitly added here. - * - *

    The default tree (null flowType) returns true — matches current behaviour for the - * connection-level access-config editor which doesn't yet know which flow the action - * will be wired into.

    - */ - static boolean resolveAllowReadOnlyClaimsModification(String flowType) { - - if (flowType == null) { - return true; - } - switch (flowType) { - case FLOW_REGISTRATION, FLOW_INVITED_USER_REGISTRATION: - return true; - case FLOW_PASSWORD_RECOVERY: - default: - return false; - } - } - - /** - * Build the "user" subtree. - * - *

    Strategy: - *

      - *
    • Read-only fields (id, username, userStoreDomain): only emitted when the - * attribute is in the allow-list (or full-passthrough is active). They support EXPOSE - * only, so excluding them when restricted is the correct behaviour.
    • - *
    • Modifiable fields (claims, credentials): always emitted because modifications - * bypass the context handover — they travel through the executor response and are applied - * by the task execution node. EXPOSE is added only when the attribute is configured.
    • - *
    - * - *

    {@code userAttrs == null} signals full-passthrough (show and expose everything). - */ - private InFlowExtensionContextTreeNode buildUserNode(Set userAttrs) { - - // userAttrs == null → full passthrough set by the caller (build()). - boolean fullPassthrough = (userAttrs == null); - - List children = new ArrayList<>(); - - // ── Read-only fields: emit only when exposed ──────────────────────────────────────── - if (fullPassthrough || userAttrs.contains("id")) { - children.add(InFlowExtensionContextTreeNode.builder() - .key("id") - .title("User ID") - .path("/user/id") - .dataType(DATA_TYPE_STRING) - .nodeType(NODE_LEAF) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .replaceable(false) - .build()); - } - if (fullPassthrough || userAttrs.contains("username")) { - children.add(InFlowExtensionContextTreeNode.builder() - .key("username") - .title("Username") - .path("/user/username") - .dataType(DATA_TYPE_STRING) - .nodeType(NODE_LEAF) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .replaceable(false) - .build()); - } - if (fullPassthrough || userAttrs.contains("userStoreDomain")) { - children.add(InFlowExtensionContextTreeNode.builder() - .key("userStoreDomain") - .title("User Store Domain") - .path("/user/userStoreDomain") - .dataType(DATA_TYPE_STRING) - .nodeType(NODE_LEAF) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .replaceable(false) - .build()); - } - - // ── Modifiable fields: always present, restrict EXPOSE when not configured ────────── - List claimsOps = (fullPassthrough || userAttrs.contains("claims")) - ? Arrays.asList(OP_EXPOSE, OP_MODIFY) - : Collections.singletonList(OP_MODIFY); - children.add(InFlowExtensionContextTreeNode.builder() - .key("claims") - .title("Claims") - .path("/user/claims/") - .dataType("Map") - .nodeType(NODE_MAP) - .allowedOperations(claimsOps) - .dynamicEntryAllowed(true) - .dynamicEntryType("String") - .children(Collections.emptyList()) - .build()); - - List credOps = (fullPassthrough || userAttrs.contains("userCredentials")) - ? Arrays.asList(OP_EXPOSE, OP_MODIFY) - : Collections.singletonList(OP_MODIFY); - children.add(InFlowExtensionContextTreeNode.builder() - .key("credentials") - .title("Credentials") - .path("/user/credentials/") - .dataType("Map") - .nodeType(NODE_MAP) - .allowedOperations(credOps) - .dynamicEntryAllowed(true) - .dynamicEntryType("char[]") - .children(Collections.emptyList()) - .build()); - - // User node is always returned (claims + credentials are always present). - return InFlowExtensionContextTreeNode.builder() - .key("user") - .title("User") - .path("/user/") - .dataType("") - .nodeType(NODE_OBJECT) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .children(children) - .build(); - } - - /** - * Build the "flow" subtree from top-level context attributes that map to the flow branch. - */ - private InFlowExtensionContextTreeNode buildFlowNode(Set attrs) { - - List children = new ArrayList<>(); - for (String attr : FLOW_BRANCH_ATTRS) { - if (attrs.contains(attr)) { - String title = attrTitle(attr); - children.add(flowLeaf(attr, title, "/flow/" + attr)); - } - } - if (children.isEmpty()) { - return null; - } - return InFlowExtensionContextTreeNode.builder() - .key("flow") - .title("Flow") - .path("/flow/") - .dataType("") - .nodeType(NODE_OBJECT) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .readOnly(true) - .children(children) - .build(); - } - - private InFlowExtensionContextTreeNode flowLeaf(String key, String title, String path) { - - return InFlowExtensionContextTreeNode.builder() - .key(key) - .title(title) - .path(path) - .dataType(DATA_TYPE_STRING) - .nodeType(NODE_LEAF) - .allowedOperations(Collections.singletonList(OP_EXPOSE)) - .readOnly(true) - .build(); - } - - /** - * Build the "properties" node. - * - *

    Properties is always emitted because modifications bypass the context handover (they - * travel through the executor response and are applied by the task execution node). - * EXPOSE is included only when {@code "properties"} is in the allow-list. - */ - private InFlowExtensionContextTreeNode buildPropertiesNode(Set attrs) { - - List ops = attrs.contains("properties") - ? Arrays.asList(OP_EXPOSE, OP_MODIFY) - : Collections.singletonList(OP_MODIFY); - return InFlowExtensionContextTreeNode.builder() - .key("properties") - .title("Properties") - .path("/properties/") - .dataType("Map") - .nodeType(NODE_COMPLEX_MAP) - .allowedOperations(ops) - .dynamicEntryAllowed(true) - .dynamicEntryType("Object") - .children(Collections.emptyList()) - .build(); - } - - private static String attrTitle(String attr) { - - switch (attr) { - case "tenantDomain": return "Tenant Domain"; - case "applicationId": return "Application ID"; - case "flowType": return "Flow Type"; - case "callbackUrl": return "Callback URL"; - case "portalUrl": return "Portal URL"; - default: return attr; - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java deleted file mode 100644 index 4ff222c0b3bb..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeMetadata.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import java.util.Collections; -import java.util.List; - -/** - * Metadata response served by the {@code GET /flow/in-flow-extension/context-tree} endpoint. - * Carries the controlled context tree (filtered by {@code FlowContextHandoverPolicy}) plus - * per-flow-type policy flags that the Console UI uses to gate the access-config editor. - */ -public class InFlowExtensionContextTreeMetadata { - - private final String flowType; - private final List contextTree; - private final boolean redirectionEnabled; - private final boolean allowReadOnlyClaimsModification; - - public InFlowExtensionContextTreeMetadata(String flowType, - List contextTree, - boolean redirectionEnabled, - boolean allowReadOnlyClaimsModification) { - - this.flowType = flowType; - this.contextTree = contextTree != null ? Collections.unmodifiableList(contextTree) - : Collections.emptyList(); - this.redirectionEnabled = redirectionEnabled; - this.allowReadOnlyClaimsModification = allowReadOnlyClaimsModification; - } - - /** - * @return The flow type this metadata applies to. {@code null} indicates the default tree. - */ - public String getFlowType() { - - return flowType; - } - - public List getContextTree() { - - return contextTree; - } - - public boolean isRedirectionEnabled() { - - return redirectionEnabled; - } - - public boolean isAllowReadOnlyClaimsModification() { - - return allowReadOnlyClaimsModification; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java deleted file mode 100644 index b657b6ea8370..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeNode.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import java.util.Collections; -import java.util.List; - -/** - * Single node in the In-Flow Extension context tree returned by the metadata service. - * Mirrors the shape of {@code default-flow-context-tree.json} on the Console side so the - * existing tree component can render this without translation. - */ -public class InFlowExtensionContextTreeNode { - - private final String key; - private final String title; - private final String path; - private final String dataType; - private final String nodeType; - private final List allowedOperations; - private final boolean readOnly; - private final boolean replaceable; - private final boolean dynamicEntryAllowed; - private final String dynamicEntryType; - private final List children; - - private InFlowExtensionContextTreeNode(Builder b) { - - this.key = b.key; - this.title = b.title; - this.path = b.path; - this.dataType = b.dataType; - this.nodeType = b.nodeType; - this.allowedOperations = b.allowedOperations != null - ? Collections.unmodifiableList(b.allowedOperations) : Collections.emptyList(); - this.readOnly = b.readOnly; - this.replaceable = b.replaceable; - this.dynamicEntryAllowed = b.dynamicEntryAllowed; - this.dynamicEntryType = b.dynamicEntryType; - this.children = b.children != null - ? Collections.unmodifiableList(b.children) : Collections.emptyList(); - } - - public String getKey() { - - return key; - } - - public String getTitle() { - - return title; - } - - public String getPath() { - - return path; - } - - public String getDataType() { - - return dataType; - } - - public String getNodeType() { - - return nodeType; - } - - public List getAllowedOperations() { - - return allowedOperations; - } - - public boolean isReadOnly() { - - return readOnly; - } - - public boolean isReplaceable() { - - return replaceable; - } - - public boolean isDynamicEntryAllowed() { - - return dynamicEntryAllowed; - } - - public String getDynamicEntryType() { - - return dynamicEntryType; - } - - public List getChildren() { - - return children; - } - - public static Builder builder() { - - return new Builder(); - } - - public static final class Builder { - - private String key; - private String title; - private String path; - private String dataType = ""; - private String nodeType; - private List allowedOperations; - private boolean readOnly; - private boolean replaceable; - private boolean dynamicEntryAllowed; - private String dynamicEntryType; - private List children; - - public Builder key(String v) { - - this.key = v; - return this; - } - - public Builder title(String v) { - - this.title = v; - return this; - } - - public Builder path(String v) { - - this.path = v; - return this; - } - - public Builder dataType(String v) { - - this.dataType = v; - return this; - } - - public Builder nodeType(String v) { - - this.nodeType = v; - return this; - } - - public Builder allowedOperations(List v) { - - this.allowedOperations = v; - return this; - } - - public Builder readOnly(boolean v) { - - this.readOnly = v; - return this; - } - - public Builder replaceable(boolean v) { - - this.replaceable = v; - return this; - } - - public Builder dynamicEntryAllowed(boolean v) { - - this.dynamicEntryAllowed = v; - return this; - } - - public Builder dynamicEntryType(String v) { - - this.dynamicEntryType = v; - return this; - } - - public Builder children(List v) { - - this.children = v; - return this; - } - - public InFlowExtensionContextTreeNode build() { - - return new InFlowExtensionContextTreeNode(this); - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java deleted file mode 100644 index 92d2f46c5653..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeService.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; - -/** - * Public-API entry point for retrieving the controlled In-Flow Extension context tree. - * Lives in the {@code metadata} package (which is exported by the engine OSGi bundle), so - * external bundles such as the flow-management API server can call it without depending on - * the engine's {@code internal} package. - * - *

    Delegates config lookup to {@link FlowExecutionEngineDataHolder}, which owns the - * engine-level {@link FlowContextHandoverConfig} singleton.

    - */ -public final class InFlowExtensionContextTreeService { - - private static final InFlowExtensionContextTreeService INSTANCE = new InFlowExtensionContextTreeService(); - - private InFlowExtensionContextTreeService() { - - } - - public static InFlowExtensionContextTreeService getInstance() { - - return INSTANCE; - } - - /** - * Build the controlled context tree for the given flow type. - * - * @param flowType the flow type, or null for the default tree. - * @return the metadata DTO carrying the pruned tree + per-flow-type policy flags. - */ - public InFlowExtensionContextTreeMetadata buildContextTree(String flowType) { - - return new InFlowExtensionContextTreeBuilder( - FlowContextHandoverConfig.defaultPolicy()).build(flowType); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java deleted file mode 100644 index 0b34091a3d72..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfig.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.flow.extensions.executor.PathTypeAnnotationUtil; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Access Configuration for In-Flow Extension actions. - *

    - * Defines which parts of the flow context are exposed to the external service - * and which paths the service is allowed to modify. - *

    - * - *
      - *
    • {@code expose} – structured list of {@link ContextPath} entries, each with a hierarchical - * path prefix and an {@code encrypted} flag controlling outbound JWE encryption.
    • - *
    • {@code modify} – structured list of {@link ContextPath} entries defining which paths the - * external service can change. All modifications map to REPLACE operations internally. - * The {@code encrypted} flag on modify paths controls inbound JWE encryption (the external - * service encrypts values, IS decrypts with its private key).
    • - *
    - * - *

    Expose and modify are independent: expose controls what data is sent to the external service, - * while modify controls what data the external service is allowed to change. A path can appear - * in both lists with independent encryption flags.

    - * - *

    Note: The external service's certificate for outbound encryption is held in the - * separate {@link Encryption} model, not in AccessConfig.

    - */ -public class AccessConfig { - - private final List expose; - private final List modify; - - /** - * Constructs an AccessConfig with the given expose and modify paths. - * - * @param expose List of expose path entries. May be {@code null}. - * @param modify List of modify path entries. May be {@code null}. - */ - public AccessConfig(List expose, List modify) { - - this.expose = expose != null ? Collections.unmodifiableList(expose) : null; - this.modify = modify != null ? Collections.unmodifiableList(modify) : null; - } - - /** - * Returns the list of expose path entries. - * - * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. - */ - public List getExpose() { - - return expose; - } - - /** - * Returns the flat list of expose path strings (without encryption metadata). - * Convenience method for components that only need path prefixes. - * - * @return List of path strings, or an empty list if expose is not configured. - */ - public List getExposePaths() { - - if (expose == null) { - return Collections.emptyList(); - } - return expose.stream().map(ContextPath::getPath).collect(Collectors.toList()); - } - - /** - * Returns the list of modify path entries. - * - * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. - */ - public List getModify() { - - return modify; - } - - /** - * Returns the flat list of modify path strings (without encryption metadata). - * Convenience method for components that only need path strings. - * - * @return List of path strings, or an empty list if modify is not configured. - */ - public List getModifyPaths() { - - if (modify == null) { - return Collections.emptyList(); - } - return modify.stream().map(ContextPath::getPath).collect(Collectors.toList()); - } - - /** - * Check if a given expose path has outbound encryption enabled. - * With leaf-only expose paths, this performs an exact match lookup. - * - * @param path The expose path to check. - * @return {@code true} if the matching expose entry has {@code encrypted = true}. - */ - public boolean isExposePathEncrypted(String path) { - - if (expose == null) { - return false; - } - return expose.stream() - .filter(ep -> ep.getPath().equals(path)) - .findFirst() - .map(ContextPath::isEncrypted) - .orElse(false); - } - - /** - * Check if a given modify path has inbound encryption enabled. - * Matches the modify entry whose path (after stripping type annotations) exactly equals the given path. - * - * @param path The terminal path to check (clean, without annotations). - * @return {@code true} if the matching modify entry has {@code encrypted = true}. - */ - public boolean isModifyPathEncrypted(String path) { - - if (modify == null) { - return false; - } - return modify.stream() - .filter(mp -> path.equals(PathTypeAnnotationUtil.stripAnnotation(mp.getPath())[0])) - .findFirst() - .map(ContextPath::isEncrypted) - .orElse(false); - } - -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java deleted file mode 100644 index e9ccb9eec7ef..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/ContextPath.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Domain model for a context path with an optional encryption flag. - * Used for both expose and modify path entries in {@link AccessConfig}. - *

    - * When {@code encrypted} is {@code true}, the value at this path prefix is JWE-encrypted: - * for expose paths, outbound values are encrypted with the external service's certificate; - * for modify paths, inbound values from the external service are encrypted with IS's key. - *

    - */ -public class ContextPath { - - private final String path; - private final boolean encrypted; - - @JsonCreator - public ContextPath(@JsonProperty("path") String path, - @JsonProperty("encrypted") boolean encrypted) { - - this.path = path; - this.encrypted = encrypted; - } - - /** - * Returns the hierarchical path prefix (e.g. {@code "/user/credentials/"}). - * - * @return The path string. - */ - public String getPath() { - - return path; - } - - /** - * Returns whether values at this path should be JWE-encrypted before sending - * to the external service. - * - * @return {@code true} if encryption is enabled for this path. - */ - public boolean isEncrypted() { - - return encrypted; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java deleted file mode 100644 index c93057302c8d..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/Encryption.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.certificate.management.model.Certificate; - -/** - * Encryption configuration for In-Flow Extension actions. - *

    - * Holds the external service's X.509 public certificate used for outbound JWE encryption. - * This model is separate from {@link AccessConfig} — following the same pattern as - * {@code PasswordSharing} in the PreUpdatePassword action type. - *

    - *

    - * The IS uses this certificate to encrypt expose path values marked {@code encrypted: true} - * before sending them to the external service. For inbound encryption (Extension → IS), - * the external service must obtain the IS's public key out-of-band. - *

    - */ -public class Encryption { - - private final Certificate certificate; - - /** - * Constructs an Encryption configuration with the given certificate. - * - * @param certificate The external service's X.509 public certificate. - * May be {@code null} if encryption is not configured. - */ - public Encryption(Certificate certificate) { - - this.certificate = certificate; - } - - /** - * Returns the external service's X.509 public certificate. - * - * @return The certificate, or {@code null} if not configured. - */ - public Certificate getCertificate() { - - return certificate; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java deleted file mode 100644 index ae1828d2d84f..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/FlowContextHandoverConfig.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; - -import java.util.Collections; -import java.util.Set; - -/** - * Immutable snapshot of which {@code FlowExecutionContext} attributes are allowed to be - * handed over to the action framework during an in-flow extension execution. - * - *

    The default policy is sourced from compile-time constants in - * {@link InFlowExtensionConstants.HandoverPolicy}. When the dynamic toml-based - * configuration PR is merged, these constants will serve as the documented defaults.

    - */ -public final class FlowContextHandoverConfig { - - private final Set includedAttributes; - private final Set includedUserAttributes; - private final boolean fullUserPassthrough; - - private FlowContextHandoverConfig(Set includedAttributes, - Set includedUserAttributes, - boolean fullUserPassthrough) { - - this.includedAttributes = Collections.unmodifiableSet(includedAttributes); - this.includedUserAttributes = Collections.unmodifiableSet(includedUserAttributes); - this.fullUserPassthrough = fullUserPassthrough; - } - - /** - * Construct a config from explicit attribute sets. - * {@code fullUserPassthrough} is true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} - * is present in {@code attrs}, meaning the entire FlowUser passes through without per-field filtering. - * - * @param attrs top-level context attributes to include; null is treated as empty. - * @param userAttrs FlowUser attributes to include; null is treated as empty. - * @return a new immutable {@link FlowContextHandoverConfig}. - */ - public static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { - - Set resolvedAttrs = (attrs != null) ? attrs : Collections.emptySet(); - Set resolvedUserAttrs = (userAttrs != null) ? userAttrs : Collections.emptySet(); - boolean fullPassthrough = resolvedAttrs.contains( - InFlowExtensionConstants.HandoverPolicy.ATTR_FLOW_USER); - return new FlowContextHandoverConfig(resolvedAttrs, resolvedUserAttrs, fullPassthrough); - } - - /** - * Returns the default handover policy built from compile-time constants defined in - * {@link InFlowExtensionConstants.HandoverPolicy}. - * - *

    This is the factory method called at runtime by the executor and the context tree - * service. To change the effective policy, update the constants in - * {@link InFlowExtensionConstants.HandoverPolicy}.

    - * - * @return a new {@link FlowContextHandoverConfig} reflecting the default policy. - */ - public static FlowContextHandoverConfig defaultPolicy() { - - return of( - InFlowExtensionConstants.HandoverPolicy.INCLUDED_ATTRIBUTES, - InFlowExtensionConstants.HandoverPolicy.INCLUDED_USER_ATTRIBUTES - ); - } - - /** - * Returns the set of top-level {@code FlowExecutionContext} attribute names that may be - * handed over to the action framework. - * - * @return unmodifiable set of allowed attribute names. - */ - public Set getIncludedAttributes() { - - return includedAttributes; - } - - /** - * Returns the set of {@code FlowUser} attribute names that may be handed over when - * {@link #isFullUserPassthrough()} is false. - * - * @return unmodifiable set of allowed user attribute names. - */ - public Set getIncludedUserAttributes() { - - return includedUserAttributes; - } - - /** - * Returns true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} is - * present in {@link #getIncludedAttributes()}, meaning the entire {@code FlowUser} object - * is passed through without per-field inspection. - * - * @return true if the full FlowUser should pass through; false if per-field filtering applies. - */ - public boolean isFullUserPassthrough() { - - return fullUserPassthrough; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java deleted file mode 100644 index b6369eda1166..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionAction.java +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.action.management.api.model.EndpointConfig; - -import java.sql.Timestamp; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * In-Flow Extension Action. - *

    - * Extends the base {@link Action} with an {@link AccessConfig} that defines what flow context data - * is exposed to the external service and which operations are allowed. - *

    - */ -public class InFlowExtensionAction extends Action { - - private final AccessConfig accessConfig; - private final Encryption encryption; - private final Map flowTypeOverrides; - private final String iconUrl; - - public InFlowExtensionAction(ResponseBuilder responseBuilder) { - - super(responseBuilder); - this.accessConfig = responseBuilder.accessConfig; - this.encryption = responseBuilder.encryption; - this.flowTypeOverrides = responseBuilder.flowTypeOverrides != null - ? Collections.unmodifiableMap(new HashMap<>(responseBuilder.flowTypeOverrides)) - : Collections.emptyMap(); - this.iconUrl = responseBuilder.iconUrl; - } - - public InFlowExtensionAction(RequestBuilder requestBuilder) { - - super(requestBuilder); - this.accessConfig = requestBuilder.accessConfig; - this.encryption = requestBuilder.encryption; - this.flowTypeOverrides = requestBuilder.flowTypeOverrides != null - ? Collections.unmodifiableMap(new HashMap<>(requestBuilder.flowTypeOverrides)) - : Collections.emptyMap(); - this.iconUrl = requestBuilder.iconUrl; - } - - /** - * Returns the default access configuration for this In-Flow Extension action. - * - * @return The access config, or {@code null} if not configured. - */ - public AccessConfig getAccessConfig() { - - return accessConfig; - } - - /** - * Returns the encryption configuration holding the external service's certificate. - * - * @return The encryption config, or {@code null} if not configured. - */ - public Encryption getEncryption() { - - return encryption; - } - - /** - * Returns the icon URL for this In-Flow Extension action. - * - * @return The icon URL, or {@code null} if not configured. - */ - public String getIconUrl() { - - return iconUrl; - } - - /** - * Returns the per-flow-type access config overrides. - * Keys are flow type strings (e.g., "REGISTRATION", "LOGIN"). - * - * @return Unmodifiable map of flow type to AccessConfig overrides. - */ - public Map getFlowTypeOverrides() { - - return flowTypeOverrides; - } - - /** - * Resolves the effective access config for the given flow type. - * Returns the flow-type-specific override if present, otherwise falls back to the default access config. - * - * @param flowType The flow type (e.g., "REGISTRATION"). - * @return The resolved AccessConfig, or {@code null} if neither override nor default is configured. - */ - public AccessConfig resolveAccessConfig(String flowType) { - - if (flowType != null && flowTypeOverrides.containsKey(flowType)) { - return flowTypeOverrides.get(flowType); - } - return accessConfig; - } - - /** - * Response Builder for InFlowExtensionAction. - * Used when building from persisted data (DAO → service layer). - */ - public static class ResponseBuilder extends ActionResponseBuilder { - - private AccessConfig accessConfig; - private Encryption encryption; - private Map flowTypeOverrides; - private String iconUrl; - - @Override - public ResponseBuilder id(String id) { - - super.id(id); - return this; - } - - @Override - public ResponseBuilder type(ActionTypes type) { - - super.type(type); - return this; - } - - @Override - public ResponseBuilder name(String name) { - - super.name(name); - return this; - } - - @Override - public ResponseBuilder description(String description) { - - super.description(description); - return this; - } - - @Override - public ResponseBuilder status(Status status) { - - super.status(status); - return this; - } - - @Override - public ResponseBuilder actionVersion(String actionVersion) { - - super.actionVersion(actionVersion); - return this; - } - - @Override - public ResponseBuilder createdAt(Timestamp createdAt) { - - super.createdAt(createdAt); - return this; - } - - @Override - public ResponseBuilder updatedAt(Timestamp updatedAt) { - - super.updatedAt(updatedAt); - return this; - } - - @Override - public ResponseBuilder endpoint(EndpointConfig endpoint) { - - super.endpoint(endpoint); - return this; - } - - @Override - public ResponseBuilder rule(org.wso2.carbon.identity.action.management.api.model.ActionRule rule) { - - super.rule(rule); - return this; - } - - public ResponseBuilder accessConfig(AccessConfig accessConfig) { - - this.accessConfig = accessConfig; - return this; - } - - public ResponseBuilder encryption(Encryption encryption) { - - this.encryption = encryption; - return this; - } - - public ResponseBuilder flowTypeOverrides(Map flowTypeOverrides) { - - this.flowTypeOverrides = flowTypeOverrides; - return this; - } - - public ResponseBuilder iconUrl(String iconUrl) { - - this.iconUrl = iconUrl; - return this; - } - - @Override - public InFlowExtensionAction build() { - - return new InFlowExtensionAction(this); - } - } - - /** - * Request Builder for InFlowExtensionAction. - * Used when building from REST API request (API → service layer). - */ - public static class RequestBuilder extends ActionRequestBuilder { - - private AccessConfig accessConfig; - private Encryption encryption; - private Map flowTypeOverrides; - private String iconUrl; - - public RequestBuilder(Action action) { - - name(action.getName()); - description(action.getDescription()); - actionVersion(action.getActionVersion()); - endpoint(action.getEndpoint()); - rule(action.getActionRule()); - } - - @Override - public RequestBuilder name(String name) { - - super.name(name); - return this; - } - - @Override - public RequestBuilder description(String description) { - - super.description(description); - return this; - } - - @Override - public RequestBuilder endpoint(EndpointConfig endpoint) { - - super.endpoint(endpoint); - return this; - } - - public RequestBuilder accessConfig(AccessConfig accessConfig) { - - this.accessConfig = accessConfig; - return this; - } - - public RequestBuilder encryption(Encryption encryption) { - - this.encryption = encryption; - return this; - } - - public RequestBuilder flowTypeOverrides(Map flowTypeOverrides) { - - this.flowTypeOverrides = flowTypeOverrides; - return this; - } - - public RequestBuilder iconUrl(String iconUrl) { - - this.iconUrl = iconUrl; - return this; - } - - @Override - public InFlowExtensionAction build() { - - return new InFlowExtensionAction(this); - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java deleted file mode 100644 index 0ead5b5c4b9e..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEvent.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import com.fasterxml.jackson.annotation.JsonInclude; -import org.wso2.carbon.identity.action.execution.api.model.Application; -import org.wso2.carbon.identity.action.execution.api.model.Event; -import org.wso2.carbon.identity.action.execution.api.model.Organization; -import org.wso2.carbon.identity.action.execution.api.model.Tenant; -import org.wso2.carbon.identity.action.execution.api.model.UserStore; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * This class models the In-Flow Extension Event. - * It represents the event sent to the In-Flow Extension action over the Action Execution Request. - */ -public class InFlowExtensionEvent extends Event { - - private final InFlowExtensionFlow flow; - private final String callbackUrl; - private final String portalUrl; - private final Map flowProperties; - - private InFlowExtensionEvent(Builder builder) { - - this.tenant = builder.tenant; - this.organization = builder.organization; - this.userStore = builder.userStore; - this.application = builder.application; - this.flow = builder.flow; - this.callbackUrl = builder.callbackUrl; - this.portalUrl = builder.portalUrl; - this.flowProperties = builder.flowProperties != null ? - Collections.unmodifiableMap(new HashMap<>(builder.flowProperties)) : Collections.emptyMap(); - } - - /** - * Get the flow context (type, ID, and user). - * - * @return The flow context object, or {@code null} if not set. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public InFlowExtensionFlow getFlow() { - - return flow; - } - - /** - * Get the callback URL for the flow, if exposed. - * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed callbackUrl with no - * context value is serialized as {@code ""} rather than omitted. - * - * @return The callback URL, or {@code null} if not exposed. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public String getCallbackUrl() { - - return callbackUrl; - } - - /** - * Get the portal URL for the flow, if exposed. - * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed portalUrl with no - * context value is serialized as {@code ""} rather than omitted. - * - * @return The portal URL, or {@code null} if not exposed. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public String getPortalUrl() { - - return portalUrl; - } - - /** - * Get the flow properties/context data. - * - * @return Unmodifiable map of flow properties. - */ - public Map getFlowProperties() { - - return flowProperties; - } - - /** - * Builder for the InFlowExtensionEvent. - */ - public static class Builder { - - private InFlowExtensionFlow flow; - private Tenant tenant; - private Organization organization; - private UserStore userStore; - private Application application; - private String callbackUrl; - private String portalUrl; - private Map flowProperties; - - public Builder flow(InFlowExtensionFlow flow) { - - this.flow = flow; - return this; - } - - public Builder tenant(Tenant tenant) { - - this.tenant = tenant; - return this; - } - - public Builder organization(Organization organization) { - - this.organization = organization; - return this; - } - - public Builder userStore(UserStore userStore) { - - this.userStore = userStore; - return this; - } - - public Builder application(Application application) { - - this.application = application; - return this; - } - - public Builder callbackUrl(String callbackUrl) { - - this.callbackUrl = callbackUrl; - return this; - } - - public Builder portalUrl(String portalUrl) { - - this.portalUrl = portalUrl; - return this; - } - - public Builder flowProperties(Map flowProperties) { - - this.flowProperties = flowProperties != null ? new HashMap<>(flowProperties) : null; - return this; - } - - public InFlowExtensionEvent build() { - - return new InFlowExtensionEvent(this); - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java deleted file mode 100644 index cf555573a469..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionFlow.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import com.fasterxml.jackson.annotation.JsonInclude; -import org.wso2.carbon.identity.action.execution.api.model.User; - -/** - * Models the {@code flow} object nested inside the In-Flow Extension Event. - * Groups flow-scoped context: the flow type, flow identifier, and the acting user. - */ -public class InFlowExtensionFlow { - - private final String flowType; - private final String flowId; - private final User user; - - private InFlowExtensionFlow(Builder builder) { - - this.flowType = builder.flowType; - this.flowId = builder.flowId; - this.user = builder.user; - } - - /** - * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed flowType with no - * context value is serialized as {@code ""} rather than omitted. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public String getFlowType() { - - return flowType; - } - - public String getFlowId() { - - return flowId; - } - - @JsonInclude(JsonInclude.Include.NON_NULL) - public User getUser() { - - return user; - } - - public static class Builder { - - private String flowType; - private String flowId; - private User user; - - public Builder flowType(String flowType) { - - this.flowType = flowType; - return this; - } - - public Builder flowId(String flowId) { - - this.flowId = flowId; - return this; - } - - public Builder user(User user) { - - this.user = user; - return this; - } - - public InFlowExtensionFlow build() { - - return new InFlowExtensionFlow(this); - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java deleted file mode 100644 index 6ff67856fc67..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionRequest.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.action.execution.api.model.Request; - -/** - * Request payload carried inside an {@link InFlowExtensionEvent}. Holds the inbound HTTP - * request's additional headers and parameters (filtered downstream by the action framework - * against the action's allowed-headers / allowed-parameters configuration). - */ -public class InFlowExtensionRequest extends Request { - - public InFlowExtensionRequest() { - - super(); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java deleted file mode 100644 index 6c07b714dd8d..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResult.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; - -/** - * This class represents the result of the execution of an operation. - * It contains the operation that was executed, the status of the execution and a message. - * This is used to summarize the operations performed based on action response. - */ -public class OperationExecutionResult { - - private final PerformableOperation operation; - private final Status status; - private final String message; - - public OperationExecutionResult(PerformableOperation operation, Status status, String message) { - - this.operation = operation; - this.status = status; - this.message = message; - } - - public PerformableOperation getOperation() { - - return operation; - } - - public Status getStatus() { - - return status; - } - - public String getMessage() { - - return message; - } - - /** - * Enum to represent the status of the operation execution. - */ - public enum Status { - SUCCESS, FAILURE - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java deleted file mode 100644 index cc18940b10de..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionContextFilterUtil.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.util; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants.HandoverPolicy; -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; - -import java.beans.IntrospectionException; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; - -/** - * Builds a filtered defensive copy of a {@link FlowExecutionContext} containing only the - * attributes permitted by the supplied {@link FlowContextHandoverConfig}. - * - *

    This class mirrors the engine's {@code FlowExecutionContextFilter} but lives in - * the inflow module to avoid a cross-bundle dependency on {@code engine.config.*}. When the - * toml-based dynamic configuration PR is merged, this class can be removed and the engine's - * filter used directly.

    - * - *

    Implementation reflects over the JavaBean property descriptors of - * {@link FlowExecutionContext} and {@link FlowUser}. Descriptors are cached on class load. - * The original context is never mutated; non-permitted attributes are left null/empty on - * the copy.

    - * - *

    Map fields receive a defensive shallow {@link HashMap} copy. The - * {@code userCredentials} field receives a per-entry {@code char[]} clone so that the - * request builder's post-extraction wipe zeroes the copy, not the source.

    - */ -public final class InFlowExtensionContextFilterUtil { - - private static final Log LOG = LogFactory.getLog(InFlowExtensionContextFilterUtil.class); - - private static final Map CONTEXT_PROPERTIES; - private static final Map USER_PROPERTIES; - - static { - CONTEXT_PROPERTIES = Collections.unmodifiableMap(introspect(FlowExecutionContext.class)); - USER_PROPERTIES = Collections.unmodifiableMap(introspect(FlowUser.class)); - } - - private InFlowExtensionContextFilterUtil() { - - } - - /** - * Build a filtered copy of {@code original} according to {@code config}. - * - * @param original the source context (untouched). - * @param config the handover policy. - * @return a new {@link FlowExecutionContext} carrying only whitelisted attributes, - * or {@code null} if {@code original} is {@code null}. - */ - public static FlowExecutionContext filter(FlowExecutionContext original, - FlowContextHandoverConfig config) { - - if (original == null) { - return null; - } - - FlowExecutionContext copy = new FlowExecutionContext(); - - // contextIdentifier is engine-internal and always propagated regardless of config. - copy.setContextIdentifier(original.getContextIdentifier()); - - // Top-level attributes (flowUser and contextIdentifier are handled separately). - for (String name : config.getIncludedAttributes()) { - if (HandoverPolicy.ATTR_FLOW_USER.equals(name) - || HandoverPolicy.ATTR_CONTEXT_IDENTIFIER.equals(name)) { - continue; - } - copyProperty(CONTEXT_PROPERTIES, name, original, copy); - } - - // User attributes — a fresh non-null FlowUser is always set on the copy so that - // request builders / response processors don't need to null-guard the user object. - FlowUser dstUser = new FlowUser(); - copy.setFlowUser(dstUser); - FlowUser srcUser = original.getFlowUser(); - if (srcUser != null) { - Set userAttrs = config.isFullUserPassthrough() - ? USER_PROPERTIES.keySet() - : config.getIncludedUserAttributes(); - for (String name : userAttrs) { - copyProperty(USER_PROPERTIES, name, srcUser, dstUser); - } - } - - return copy; - } - - private static void copyProperty(Map descriptors, String name, - T source, T destination) { - - PropertyDescriptor pd = descriptors.get(name); - if (pd == null) { - if (LOG.isDebugEnabled()) { - LOG.debug("Skipping unknown handover attribute: " + name); - } - return; - } - Method reader = pd.getReadMethod(); - Method writer = pd.getWriteMethod(); - if (reader == null || writer == null) { - if (LOG.isDebugEnabled()) { - LOG.debug("Skipping handover attribute without readable+writable accessors: " + name); - } - return; - } - try { - Object value = reader.invoke(source); - value = defensivelyCopy(name, value); - writer.invoke(destination, value); - } catch (IllegalAccessException | InvocationTargetException e) { - LOG.warn("Failed to copy handover attribute '" + name + "': " + e.getMessage()); - } - } - - @SuppressWarnings("unchecked") - private static Object defensivelyCopy(String name, Object value) { - - if (!(value instanceof Map)) { - return value; - } - if (HandoverPolicy.ATTR_USER_CREDENTIALS.equals(name)) { - Map src = (Map) value; - Map out = new LinkedHashMap<>(); - for (Map.Entry entry : src.entrySet()) { - char[] v = entry.getValue(); - out.put(entry.getKey(), v == null ? null : v.clone()); - } - return out; - } - return new HashMap<>((Map) value); - } - - private static Map introspect(Class beanClass) { - - Map result = new HashMap<>(); - try { - for (PropertyDescriptor pd : - Introspector.getBeanInfo(beanClass, Object.class).getPropertyDescriptors()) { - if (pd.getReadMethod() != null && pd.getWriteMethod() != null) { - result.put(pd.getName(), pd); - } - } - } catch (IntrospectionException e) { - LOG.error("Failed to introspect " + beanClass.getName() + " for handover filtering.", e); - } - return result; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java deleted file mode 100644 index 2801917a68ca..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/main/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtil.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.util; - -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; - -import java.util.List; - -/** - * Path-matching utilities for In-Flow Extension access control. - */ -public final class InFlowExtensionPathUtil { - - private InFlowExtensionPathUtil() { - - } - - /** - * Returns {@code true} if the path is in the read-only {@code /flow/} area. - */ - public static boolean isReadOnly(String path) { - - if (path == null) { - return false; - } - return path.startsWith(InFlowExtensionConstants.FLOW_PREFIX); - } - - /** - * Returns {@code true} if at least one leaf path in {@code leafPaths} starts with - * {@code areaPrefix}. Used as an area-gate before iterating a data block. - */ - public static boolean anyExposedUnder(String areaPrefix, List leafPaths) { - - if (areaPrefix == null || leafPaths == null || leafPaths.isEmpty()) { - return false; - } - for (String path : leafPaths) { - if (path != null && path.startsWith(areaPrefix)) { - return true; - } - } - return false; - } - - /** - * Returns {@code true} if {@code leafPath} is present in {@code leafPaths}. - */ - public static boolean isExposedPath(String leafPath, List leafPaths) { - - if (leafPath == null || leafPaths == null || leafPaths.isEmpty()) { - return false; - } - return leafPaths.contains(leafPath); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java deleted file mode 100644 index ae67302b62c2..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/InFlowExtensionTestUtils.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions; - -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -/** - * Test-only helper that builds {@link FlowContextHandoverConfig} instances using the - * inflow module's own factory method — no reflection, no engine class dependencies. - */ -public final class InFlowExtensionTestUtils { - - /** - * Commonly used attribute sets that cover all fields exposed by the filter. - */ - public static final Set ALL_CONTEXT_ATTRS = new HashSet<>(Arrays.asList( - "tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl", - "properties", "contextIdentifier")); - - public static final Set ALL_USER_ATTRS = new HashSet<>(Arrays.asList( - "username", "id", "userStoreDomain", "claims", "userCredentials")); - - private InFlowExtensionTestUtils() { - - } - - /** - * Construct a permissive {@link FlowContextHandoverConfig} covering all known fields. - */ - public static FlowContextHandoverConfig permissiveConfig() { - - return configOf(ALL_CONTEXT_ATTRS, ALL_USER_ATTRS); - } - - /** - * Construct a {@link FlowContextHandoverConfig} with explicit allow-lists. - */ - public static FlowContextHandoverConfig configOf(Set attrs, Set userAttrs) { - - return FlowContextHandoverConfig.of(attrs, userAttrs); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java deleted file mode 100644 index bafe798517b3..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionExecutorTest.java +++ /dev/null @@ -1,616 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.MockitoAnnotations; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.Error; -import org.wso2.carbon.identity.action.execution.api.model.Failure; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Success; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; -import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; -import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; - -import java.util.HashMap; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link InFlowExtensionExecutor}. - */ -public class InFlowExtensionExecutorTest { - - private InFlowExtensionExecutor executor; - - @Mock - private ActionExecutorService actionExecutorService; - - private AutoCloseable mocks; - private MockedStatic holderMock; - private MockedStatic loggerUtilsMock; - - @BeforeMethod - public void setUp() { - - mocks = MockitoAnnotations.openMocks(this); - executor = new InFlowExtensionExecutor(); - - // Stub InFlowExtensionDataHolder for action executor service. - InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); - when(holderInstance.getActionExecutorService()).thenReturn(actionExecutorService); - holderMock = mockStatic(InFlowExtensionDataHolder.class); - holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); - - loggerUtilsMock = mockStatic(LoggerUtils.class); - loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); - } - - @AfterMethod - public void tearDown() throws Exception { - - loggerUtilsMock.close(); - holderMock.close(); - mocks.close(); - } - - // ========================= getName ========================= - - @Test - public void testGetName() { - - assertEquals(executor.getName(), "InFlowExtensionExecutor"); - } - - // ========================= getInitiationData ========================= - - @Test - public void testGetInitiationData() { - - assertNotNull(executor.getInitiationData()); - assertTrue(executor.getInitiationData().isEmpty()); - } - - // ========================= rollback ========================= - - @Test - public void testRollback() { - - assertNull(executor.rollback(new FlowExecutionContext())); - } - - // ========================= execute — no actionId ========================= - - @Test - public void testExecuteNoActionId() throws Exception { - - FlowExecutionContext context = createContextWithMetadata(new HashMap<>()); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - verify(actionExecutorService, never()) - .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); - } - - @Test - public void testExecuteEmptyActionId() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", ""); - FlowExecutionContext context = createContextWithMetadata(metadata); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - } - - // ========================= execute — execution disabled ========================= - - @Test - public void testExecuteDisabledExecution() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(false); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - verify(actionExecutorService, never()) - .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); - } - - // ========================= execute — SUCCESS ========================= - - @Test - @SuppressWarnings("unchecked") - public void testExecuteSuccess() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - ActionExecutionStatus successStatus = mock(ActionExecutionStatus.class); - when(successStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.SUCCESS); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(successStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_COMPLETE); - } - - // ========================= execute — FAILED ========================= - - @Test - @SuppressWarnings("unchecked") - public void testExecuteFailed() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Failure failure = new Failure("risk_detected", "Risk score exceeds threshold"); - ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); - when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); - when(failedStatus.getResponse()).thenReturn(failure); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(failedStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); - assertEquals(response.getErrorMessage(), "Risk score exceeds threshold"); - // Verify failureType metadata is set for RETRY. - assertNotNull(response.getAdditionalInfo()); - assertEquals(response.getAdditionalInfo().get("failureType"), "IN_FLOW_EXTENSION_FAILURE"); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteFailedNoDescription() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Failure failure = new Failure("risk_detected", null); - ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); - when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); - when(failedStatus.getResponse()).thenReturn(failure); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(failedStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); - // Falls back to reason when description is null. - assertEquals(response.getErrorMessage(), "risk_detected"); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteFailedBothNull() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Failure failure = new Failure(null, null); - ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); - when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); - when(failedStatus.getResponse()).thenReturn(failure); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(failedStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); - assertEquals(response.getErrorMessage(), - "The operation could not be completed due to an external service failure."); - } - - // ========================= execute — ERROR ========================= - - @Test - @SuppressWarnings("unchecked") - public void testExecuteError() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Error error = new Error("internal_error", "DB connection failed"); - ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); - when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); - when(errorStatus.getResponse()).thenReturn(error); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(errorStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorCode(), - Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - // errorMessage carries the Error reason/code field; errorDescription carries the human-readable text. - assertEquals(response.getErrorMessage(), "internal_error"); - assertEquals(response.getErrorDescription(), "DB connection failed"); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteErrorNoDescription() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Error error = new Error("internal_error", null); - ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); - when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); - when(errorStatus.getResponse()).thenReturn(error); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(errorStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorCode(), - Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - assertEquals(response.getErrorMessage(), "internal_error"); - assertNull(response.getErrorDescription()); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteErrorBothNull() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - Error error = new Error(null, null); - ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); - when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); - when(errorStatus.getResponse()).thenReturn(error); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(errorStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorCode(), - Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); - // Both fields null → errorMessage and errorDescription remain null; errorCode alone triggers FE-65033 routing. - assertNull(response.getErrorMessage()); - assertNull(response.getErrorDescription()); - } - - // ========================= execute — INCOMPLETE ========================= - - @Test - @SuppressWarnings("unchecked") - public void testExecuteIncompleteWithoutRedirectUrlReturnsError() throws Exception { - - // INCOMPLETE without a stashed redirect URL is a contract violation — - // the response processor should normally have thrown, but the executor - // defends against it as well by returning STATUS_ERROR with a clear message. - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); - when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(incompleteStatus); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorMessage(), - "Extension returned INCOMPLETE without a redirect URL."); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteIncompleteWithRedirectUrlReturnsExternalRedirection() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - // Set a context identifier so the OTFI collision-guard has something to compare against. - context.setContextIdentifier("original-flow-id"); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); - when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); - - // Simulate the response processor stashing the redirect URL into the FlowContext - // during the actionExecutorService.execute call. - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenAnswer(invocation -> { - FlowContext fc = invocation.getArgument(2); - fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, - "https://example.com/step-up"); - return incompleteStatus; - }); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); - - // OTFI must be set on contextProperties so FlowExecutionService can swap caches on resume. - Map ctxProps = response.getContextProperties(); - assertNotNull(ctxProps); - Object otfi = ctxProps.get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); - assertNotNull(otfi); - assertTrue(otfi instanceof String); - assertFalse(((String) otfi).isEmpty()); - // OTFI must not collide with the original context identifier. - assertFalse("original-flow-id".equals(otfi)); - - // Redirect URL must carry the OTFI as a flowId query parameter. - Map additionalInfo = response.getAdditionalInfo(); - assertNotNull(additionalInfo); - String redirectUrl = additionalInfo.get( - org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); - assertNotNull(redirectUrl); - assertEquals(redirectUrl, "https://example.com/step-up?flowId=" + otfi); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteIncompleteRedirectAppendsFlowIdWithAmpersandWhenUrlHasQuery() - throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - context.setContextIdentifier("original-flow-id"); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); - when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); - - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenAnswer(invocation -> { - FlowContext fc = invocation.getArgument(2); - // URL already has a query string — the executor must use & not ?. - fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, - "https://example.com/step-up?ref=abc"); - return incompleteStatus; - }); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); - String otfi = (String) response.getContextProperties() - .get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); - String redirectUrl = response.getAdditionalInfo() - .get(org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); - assertEquals(redirectUrl, "https://example.com/step-up?ref=abc&flowId=" + otfi); - } - - @Test - @SuppressWarnings("unchecked") - public void testExecuteIncompleteRedirectEmptyUrlReturnsError() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - - ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); - when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); - - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenAnswer(invocation -> { - FlowContext fc = invocation.getArgument(2); - fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, ""); - return incompleteStatus; - }); - - ExecutorResponse response = executor.execute(context); - - // Empty URL is treated the same as missing — defensive STATUS_ERROR. - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorMessage(), - "Extension returned INCOMPLETE without a redirect URL."); - } - - // ========================= execute — null status ========================= - - @Test - public void testExecuteNullStatus() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenReturn(null); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - } - - // ========================= execute — exception ========================= - - @Test - public void testExecuteActionException() throws Exception { - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSIONS)).thenReturn(true); - when(actionExecutorService.execute( - eq(ActionType.FLOW_EXTENSIONS), eq("test-action-001"), - any(FlowContext.class), eq("carbon.super"))) - .thenThrow(new ActionExecutionException("Connection timeout")); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - assertEquals(response.getErrorMessage(), - "An error occurred while processing the extension. Please try again."); - } - - // ========================= execute — service unavailable ========================= - - @Test(expectedExceptions = org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException.class) - public void testExecuteServiceUnavailable() throws Exception { - - // Override holder mock to return null service. - holderMock.close(); - - InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); - when(holderInstance.getActionExecutorService()).thenReturn(null); - - holderMock = mockStatic(InFlowExtensionDataHolder.class); - holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); - - Map metadata = new HashMap<>(); - metadata.put("actionId", "test-action-001"); - FlowExecutionContext context = createContextWithMetadata(metadata); - - // Should throw FlowEngineException since service is unavailable. - executor.execute(context); - } - - // ========================= execute — no node config ========================= - - @Test - public void testExecuteNoNodeConfig() throws Exception { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - // No current node set → getMetadataValue returns null. - - ExecutorResponse response = executor.execute(context); - - // actionId is null → missing configuration → ERROR. - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - } - - @Test - public void testExecuteNoExecutorDTO() throws Exception { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - - NodeConfig nodeConfig = new NodeConfig.Builder().build(); - context.setCurrentNode(nodeConfig); - - ExecutorResponse response = executor.execute(context); - - assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); - } - - // ========================= Helper methods ========================= - - private FlowExecutionContext createContextWithMetadata(Map metadata) { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - - ExecutorDTO executorDTO = new ExecutorDTO("InFlowExtensionExecutor", metadata); - NodeConfig nodeConfig = new NodeConfig.Builder() - .executorConfig(executorDTO) - .build(); - context.setCurrentNode(nodeConfig); - - return context; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java deleted file mode 100644 index 622270620fca..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionRequestBuilderTest.java +++ /dev/null @@ -1,1047 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.mockito.MockedStatic; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Operation; -import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.core.util.IdentityTenantUtil; -import org.wso2.carbon.identity.flow.extensions.model.*; -import org.wso2.carbon.identity.certificate.management.model.Certificate; -import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; -import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; -import static org.testng.Assert.fail; - -/** - * Unit tests for {@link InFlowExtensionRequestBuilder}. - */ -public class InFlowExtensionRequestBuilderTest { - - private InFlowExtensionRequestBuilder requestBuilder; - private MockedStatic identityTenantUtilMock; - private MockedStatic loggerUtilsMock; - - @BeforeMethod - public void setUp() { - - requestBuilder = new InFlowExtensionRequestBuilder(); - identityTenantUtilMock = mockStatic(IdentityTenantUtil.class); - identityTenantUtilMock.when(() -> IdentityTenantUtil.getTenantId(anyString())).thenReturn(1); - loggerUtilsMock = mockStatic(LoggerUtils.class); - loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); - } - - @AfterMethod - public void tearDown() { - - loggerUtilsMock.close(); - identityTenantUtilMock.close(); - } - - // ========================= getSupportedActionType ========================= - - @Test - public void testGetSupportedActionType() { - - assertEquals(requestBuilder.getSupportedActionType(), ActionType.FLOW_EXTENSIONS); - } - - // ========================= buildActionExecutionRequest — basics ========================= - - @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) - public void testBuildRequestThrowsWhenFlowExecutionContextMissing() - throws ActionExecutionRequestBuilderException { - - FlowContext flowContext = FlowContext.create(); - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - } - - @Test - public void testBuildRequestWithMinimalContext() throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - - assertNotNull(request); - assertEquals(request.getActionType(), ActionType.FLOW_EXTENSIONS); - assertNotNull(request.getEvent()); - } - - @Test - public void testBuildRequestUsesEmptyExposeWhenExposeIsNull() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - - // With empty expose, no context areas should be included in the event. - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event); - assertNull(event.getFlow().getFlowType()); - // flowProperties defaults to emptyMap() in the builder — verify it is empty. - assertTrue(event.getFlowProperties().isEmpty()); - } - - // ========================= buildAllowedOperations (from modify) ========================= - - @Test - public void testBuildRequestWithValidModifyPaths() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, - Arrays.asList(new ContextPath("/properties/riskScore", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - List ops = request.getAllowedOperations(); - assertNotNull(ops); - // REPLACE + REDIRECT (REDIRECT is always advertised). - assertEquals(ops.size(), 2); - AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); - assertNotNull(replaceOp, "REPLACE should be present when modify paths are configured"); - assertTrue(replaceOp.getPaths().contains("/properties/riskScore")); - assertNotNull(findOperation(ops, Operation.REDIRECT), - "REDIRECT should always be present"); - } - - @Test - public void testBuildRequestWithNoAccessConfig() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - // No action → no access config → no modify paths → only REDIRECT (always present). - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - - List ops = request.getAllowedOperations(); - assertNotNull(ops); - assertEquals(ops.size(), 1); - assertEquals(ops.get(0).getOp(), Operation.REDIRECT); - } - - @Test - public void testBuildRequestWithNonInFlowActionFallsBackToMinimalPayload() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - when(reqCtx.getAction()).thenReturn(mock(Action.class)); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - - assertNotNull(request); - assertEquals(request.getActionType(), ActionType.FLOW_EXTENSIONS); - assertEquals(request.getAllowedOperations().size(), 1); - assertEquals(request.getAllowedOperations().get(0).getOp(), Operation.REDIRECT); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event); - assertNotNull(event.getFlow()); - assertEquals(event.getFlow().getFlowId(), execCtx.getContextIdentifier()); - assertNull(event.getFlow().getUser()); - assertNull(event.getFlow().getFlowType()); - } - - @Test - public void testBuildRequestWithEmptyModifyPaths() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, Arrays.asList()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - List ops = request.getAllowedOperations(); - assertNotNull(ops); - // No modify paths → only REDIRECT. - assertEquals(ops.size(), 1); - assertEquals(ops.get(0).getOp(), Operation.REDIRECT); - } - - // ========================= REDIRECT always advertised ========================= - - @Test - public void testRedirectIsAdvertisedAlongsideReplace() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, - Arrays.asList(new ContextPath("/properties/riskScore", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); - assertNotNull(redirectOp); - // REDIRECT does not target a path — paths must be null or empty. - assertTrue(redirectOp.getPaths() == null || redirectOp.getPaths().isEmpty(), - "REDIRECT must not carry any paths"); - } - - @Test - public void testRedirectIsAdvertisedWhenNoAccessConfig() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); - - // REDIRECT must be available even without any modify config so extensions can always - // signal mid-flow redirects. - AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); - assertNotNull(redirectOp); - } - - // ========================= Path annotation stripping ========================= - - @Test - @SuppressWarnings("unchecked") - public void testPathAnnotationStrippingSimpleArray() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, - Arrays.asList(new ContextPath("/properties/riskFactors{[String]}", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - // AllowedOperation should have clean path (without {[String]}). - AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); - assertNotNull(op); - assertTrue(op.getPaths().contains("/properties/riskFactors")); - assertFalse(op.getPaths().contains("/properties/riskFactors{[String]}")); - - // Annotations should be stored in FlowContext. - Map annotations = flowContext.getValue( - InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); - assertNotNull(annotations); - assertEquals(annotations.get("/properties/riskFactors"), "[String]"); - } - - @Test - @SuppressWarnings("unchecked") - public void testPathAnnotationStrippingSchemaAnnotation() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, - Arrays.asList(new ContextPath("/properties/items{name: String, count: Integer}", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); - assertNotNull(op); - assertTrue(op.getPaths().contains("/properties/items")); - assertFalse(op.getPaths().contains("/properties/items{name: String, count: Integer}")); - - Map annotations = flowContext.getValue( - InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); - assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); - } - - @Test - @SuppressWarnings("unchecked") - public void testPathWithoutAnnotationNotStoredInAnnotationsMap() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, - Arrays.asList(new ContextPath("/properties/riskScore", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, null)); - - // No annotations should be stored when paths have no annotations. - Map annotations = flowContext.getValue( - InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); - assertNull(annotations); - } - - @Test - @SuppressWarnings("unchecked") - public void testMultipleAnnotatedPaths() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( - new ContextPath("/properties/riskScore", false), - new ContextPath("/properties/riskFactors{[String]}", false), - new ContextPath("/properties/items{name: String, count: Integer}", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); - assertNotNull(op); - assertEquals(op.getPaths().size(), 3); - assertTrue(op.getPaths().contains("/properties/riskScore")); - assertTrue(op.getPaths().contains("/properties/riskFactors")); - assertTrue(op.getPaths().contains("/properties/items")); - - Map annotations = flowContext.getValue( - InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); - assertEquals(annotations.size(), 2); - assertEquals(annotations.get("/properties/riskFactors"), "[String]"); - assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); - } - - // ========================= Expose and modify independence ========================= - - @Test - public void testModifyPathsDoNotAffectExpose() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Expose only /input/ and /flow/ — /properties/ is NOT exposed. - // Modify path targets /properties/riskScore — but this should NOT auto-expose it. - AccessConfig accessConfig = new AccessConfig( - Arrays.asList( - new ContextPath("/input/", false), - new ContextPath("/flow/", false)), - Arrays.asList(new ContextPath("/properties/riskScore", false))); - - execCtx.setProperty("riskScore", "50"); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - // Modify paths should produce a REPLACE allowed op alongside the always-present REDIRECT. - List ops = request.getAllowedOperations(); - assertEquals(ops.size(), 2); - assertNotNull(findOperation(ops, Operation.REPLACE)); - assertNotNull(findOperation(ops, Operation.REDIRECT)); - - // Properties should NOT be in event — expose and modify are independent. - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertTrue(event.getFlowProperties() == null || event.getFlowProperties().isEmpty()); - } - - @Test - public void testMultipleModifyPathsProduceSingleReplaceOperation() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( - new ContextPath("/properties/riskScore", false), - new ContextPath("/properties/riskLevel", false), - new ContextPath("/user/claims/http://wso2.org/claims/email", false))); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - // All modify paths grouped into a single REPLACE op (REDIRECT is always added too). - List ops = request.getAllowedOperations(); - assertEquals(ops.size(), 2); - AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); - assertNotNull(replaceOp); - assertEquals(replaceOp.getPaths().size(), 3); - assertNotNull(findOperation(ops, Operation.REDIRECT)); - } - - // ========================= Expose filtering ========================= - - @Test - public void testExposeFilteringOnlyExposedAreaIncluded() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Only expose /flow/ — no user, no properties, no input. - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/tenantDomain", false), - new ContextPath("/flow/flowType", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - // User should NOT be in the event since /user/ is not exposed. - assertNull(event.getFlow().getUser()); - // Flow type should be present. - assertNotNull(event.getFlow().getFlowType()); - // Tenant should be present. - assertNotNull(event.getTenant()); - } - - @Test - public void testFlowPortalUrlExposed() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/portalUrl", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertEquals(event.getPortalUrl(), "https://localhost:9443/accounts/recovery"); - } - - @Test - public void testFlowPortalUrlNotExposedYieldsNull() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); - - // /flow/portalUrl NOT in expose list — must be omitted from the event even when - // the context has a value, mirroring the rest of the expose-gated paths. - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/flowType", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNull(event.getPortalUrl()); - } - - @Test - public void testFlowCallbackUrlExposed() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setCallbackUrl("https://example.com/callback"); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/callbackUrl", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertEquals(event.getCallbackUrl(), "https://example.com/callback"); - } - - @Test - public void testExposeFilteringSpecificClaim() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Only expose a specific claim. - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/claims/http://wso2.org/claims/email", false), - new ContextPath("/user/id", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser()); - // Only the email claim should be present, not the country claim. - List claims = event.getFlow().getUser().getClaims(); - assertEquals(claims.size(), 1); - } - - // ========================= Null-to-empty-string: consistent contract ========================= - - @Test - public void testExposedCallbackUrlNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // callbackUrl is NOT set — context returns null. - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/callbackUrl", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertEquals(event.getCallbackUrl(), "", - "Exposed callbackUrl must be '' when context value is null"); - } - - @Test - public void testExposedPortalUrlNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // portalUrl is NOT set — context returns null. - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/portalUrl", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertEquals(event.getPortalUrl(), "", - "Exposed portalUrl must be '' when context value is null"); - } - - @Test - public void testExposedFlowTypeNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - // flowType is not set in the minimal context. - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/flowType", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertEquals(event.getFlow().getFlowType(), "", - "Exposed flowType must be '' when context value is null"); - } - - @Test - public void testExposedTenantDomainNullYieldsEmptyStrings() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - execCtx.setTenantDomain(null); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/tenantDomain", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getTenant(), - "Tenant must be present when /flow/tenantDomain is exposed"); - assertEquals(event.getTenant().getId(), "", - "Tenant id must be '' when tenantDomain is null"); - assertEquals(event.getTenant().getName(), "", - "Tenant name must be '' when tenantDomain is null"); - } - - @Test - public void testExposedApplicationIdNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); - // applicationId is not set in the minimal context. - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/flow/applicationId", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getApplication(), - "Application must be present when /flow/applicationId is exposed"); - assertEquals(event.getApplication().getId(), "", - "Application id must be '' when applicationId is null"); - } - - @Test - public void testExposedUserIdNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.getFlowUser().setId(null); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/id", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser()); - assertEquals(event.getFlow().getUser().getId(), "", - "User id must be '' when id is null and path is exposed"); - } - - @Test - public void testExposedUserStoreDomainNullYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.getFlowUser().setUserStoreDomain(null); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/userStoreDomain", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser(), "User must be present when /user/userStoreDomain is exposed"); - assertNotNull(event.getFlow().getUser().getUserStoreDomain(), - "UserStore must be present when /user/userStoreDomain is exposed"); - assertEquals(event.getFlow().getUser().getUserStoreDomain().getName(), "", - "UserStore name must be '' when userStoreDomain is null"); - } - - @Test - public void testExposedClaimAbsentFromClaimsMapYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Expose a claim URI that is NOT present in the flowUser's claims map. - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/claims/http://wso2.org/claims/mobile", false), - new ContextPath("/user/id", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser()); - List claims = event.getFlow().getUser().getClaims(); - assertEquals(claims.size(), 1); - org.wso2.carbon.identity.action.execution.api.model.UserClaim mobileClaim = - (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); - assertEquals(mobileClaim.getUri(), "http://wso2.org/claims/mobile"); - assertEquals(mobileClaim.getValue(), "", - "Exposed claim absent from claims map must yield ''"); - } - - @Test - public void testExposedPropertyAbsentFromPropertiesMapYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Expose a property key that is NOT present in the context properties map. - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/properties/riskScore", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlowProperties()); - assertTrue(event.getFlowProperties().containsKey("riskScore"), - "Exposed property absent from properties map must still appear"); - assertEquals(event.getFlowProperties().get("riskScore"), "", - "Exposed property absent from properties map must yield ''"); - } - - @Test - public void testExposedClaimWithNullValueYieldsEmptyString() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - // Overwrite existing claim with null value. - execCtx.getFlowUser().getClaims().put("http://wso2.org/claims/email", null); - - AccessConfig accessConfig = new AccessConfig(Arrays.asList( - new ContextPath("/user/claims/http://wso2.org/claims/email", false)), null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - List claims = event.getFlow().getUser().getClaims(); - assertEquals(claims.size(), 1); - org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = - (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); - assertEquals(emailClaim.getValue(), "", - "Claim value must be '' when source value is null"); - } - - // ========================= Outbound encryption of properties and inputs ========================= - - @Test - public void testPropertiesEncryptedWhenExposePathMarkedEncrypted() - throws ActionExecutionRequestBuilderException { - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) - .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setProperty("riskScore", "85"); - - // riskScore is expose-encrypted; existingProp is exposed plaintext. - AccessConfig accessConfig = new AccessConfig( - Arrays.asList( - new ContextPath("/properties/riskScore", true), - new ContextPath("/properties/existingProp", false)), - null); - - Encryption encryption = new Encryption( - new Certificate.Builder().id("cert-1").name("test") - .certificateContent("test-cert-pem").build()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, encryption)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlowProperties()); - // riskScore should be encrypted. - Object riskScoreValue = event.getFlowProperties().get("riskScore"); - assertNotNull(riskScoreValue); - assertTrue(riskScoreValue.toString().startsWith("encrypted."), - "Property value should be encrypted when expose path is marked encrypted"); - // existingProp is NOT marked encrypted — should remain plaintext. - assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); - } - } - - @Test - public void testClaimEncryptedWhenExposePathMarkedEncrypted() - throws ActionExecutionRequestBuilderException { - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) - .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - - // email claim is expose-encrypted; country claim is exposed plaintext. - AccessConfig accessConfig = new AccessConfig( - Arrays.asList( - new ContextPath("/user/claims/http://wso2.org/claims/email", true), - new ContextPath("/user/claims/http://wso2.org/claims/country", false)), - null); - - Encryption encryption = new Encryption( - new Certificate.Builder().id("cert-1").name("test") - .certificateContent("test-cert-pem").build()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, encryption)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser()); - List claims = event.getFlow().getUser().getClaims(); - assertEquals(claims.size(), 2); - - org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = null; - org.wso2.carbon.identity.action.execution.api.model.UserClaim countryClaim = null; - for (Object c : claims) { - org.wso2.carbon.identity.action.execution.api.model.UserClaim uc = - (org.wso2.carbon.identity.action.execution.api.model.UserClaim) c; - if ("http://wso2.org/claims/email".equals(uc.getUri())) { - emailClaim = uc; - } else if ("http://wso2.org/claims/country".equals(uc.getUri())) { - countryClaim = uc; - } - } - - assertNotNull(emailClaim, "email claim should be present"); - assertTrue(emailClaim.getValue().toString().startsWith("encrypted."), - "email claim should be encrypted when expose path is marked encrypted"); - - assertNotNull(countryClaim, "country claim should be present"); - assertEquals(countryClaim.getValue().toString(), "US", - "country claim should remain plaintext when expose path is not marked encrypted"); - } - } - - @Test - public void testCredentialEncryptedWhenExposePathMarkedEncrypted() - throws ActionExecutionRequestBuilderException { - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) - .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - Map creds = new java.util.HashMap<>(); - creds.put("password", "secret123".toCharArray()); - execCtx.getFlowUser().setUserCredentials(creds); - - // password credential is expose-encrypted. - AccessConfig accessConfig = new AccessConfig( - Arrays.asList(new ContextPath("/user/credentials/password", true)), - null); - - Encryption encryption = new Encryption( - new Certificate.Builder().id("cert-1").name("test") - .certificateContent("test-cert-pem").build()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, encryption)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlow().getUser()); - Map eventCreds = event.getFlow().getUser().getUserCredentials(); - assertNotNull(eventCreds); - assertTrue(eventCreds.containsKey("password")); - String credValue = new String(eventCreds.get("password")); - assertTrue(credValue.startsWith("encrypted."), - "credential should be encrypted when expose path is marked encrypted"); - } - } - - @Test - public void testEncryptedExposePathIsOmittedWhenCertificateIsMissing() - throws ActionExecutionRequestBuilderException { - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setProperty("riskScore", "85"); - - AccessConfig accessConfig = new AccessConfig( - Arrays.asList( - new ContextPath("/properties/riskScore", true), - new ContextPath("/properties/existingProp", false)), - null); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( - flowContext, mockReqCtx(accessConfig, null)); - - InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); - assertNotNull(event.getFlowProperties()); - assertFalse(event.getFlowProperties().containsKey("riskScore"), - "Encrypted expose path should be omitted when outbound certificate is missing."); - assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); - } - - // ========================= Outbound encryption failure ========================= - - @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) - public void testEncryptionFailureThrowsException() - throws ActionExecutionRequestBuilderException { - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) - .thenThrow(new RuntimeException("Encryption failed")); - - FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.setProperty("riskScore", "85"); - - AccessConfig accessConfig = new AccessConfig( - Arrays.asList(new ContextPath("/properties/riskScore", true)), - null); - - Encryption encryption = new Encryption( - new Certificate.Builder().id("cert-1").name("test") - .certificateContent("test-cert-pem").build()); - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, encryption)); - } - } - - // ========================= Helper methods ========================= - - /** - * Locate the first AllowedOperation in {@code ops} matching {@code op}, or null if absent. - */ - private AllowedOperation findOperation(List ops, Operation op) { - - if (ops == null) { - return null; - } - for (AllowedOperation candidate : ops) { - if (candidate.getOp() == op) { - return candidate; - } - } - return null; - } - - /** - * Create a mock ActionExecutionRequestContext whose getAction() returns an InFlowExtensionAction - * configured with the given access config and encryption. - */ - private ActionExecutionRequestContext mockReqCtx(AccessConfig accessConfig, Encryption encryption) { - - InFlowExtensionAction action = mock(InFlowExtensionAction.class); - when(action.resolveAccessConfig(any())).thenReturn(accessConfig); - when(action.getEncryption()).thenReturn(encryption); - when(action.getName()).thenReturn("Test Action"); - ActionExecutionRequestContext ctx = mock(ActionExecutionRequestContext.class); - when(ctx.getAction()).thenReturn(action); - return ctx; - } - - private FlowExecutionContext createMinimalFlowExecutionContext() { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - context.setContextIdentifier("test-correlation-id"); - - NodeConfig node = new NodeConfig.Builder() - .id("node1") - .type("EXECUTION") - .build(); - context.setCurrentNode(node); - - return context; - } - - private FlowExecutionContext createFullFlowExecutionContext() { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - context.setContextIdentifier("test-correlation-id"); - context.setApplicationId("app-123"); - context.setFlowType("REGISTRATION"); - - NodeConfig node = new NodeConfig.Builder() - .id("execution_node_1") - .type("EXECUTION") - .build(); - context.setCurrentNode(node); - - FlowUser flowUser = new FlowUser(); - flowUser.setId("user-456"); - flowUser.setUsername("testuser"); - flowUser.setUserStoreDomain("PRIMARY"); - flowUser.addClaim("http://wso2.org/claims/email", "test@example.com"); - flowUser.addClaim("http://wso2.org/claims/country", "US"); - context.setFlowUser(flowUser); - - context.addUserInputData("username", "testuser"); - context.addUserInputData("consent", "true"); - - context.setProperty("existingProp", "existingValue"); - - return context; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java deleted file mode 100644 index 1860406a7fb4..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/InFlowExtensionResponseProcessorTest.java +++ /dev/null @@ -1,999 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.mockito.MockedStatic; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; -import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; -import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; -import org.wso2.carbon.identity.action.execution.api.model.ActionType; -import org.wso2.carbon.identity.action.execution.api.model.Error; -import org.wso2.carbon.identity.action.execution.api.model.Failure; -import org.wso2.carbon.identity.action.execution.api.model.FlowContext; -import org.wso2.carbon.identity.action.execution.api.model.Incomplete; -import org.wso2.carbon.identity.action.execution.api.model.Operation; -import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; -import org.wso2.carbon.identity.action.execution.api.model.Success; -import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; -import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; -import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; -import org.wso2.carbon.identity.flow.execution.engine.Constants; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionConstants; -import org.wso2.carbon.identity.flow.extensions.internal.InFlowExtensionDataHolder; -import org.wso2.carbon.identity.flow.extensions.model.ContextPath; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; -import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link InFlowExtensionResponseProcessor}. - */ -public class InFlowExtensionResponseProcessorTest { - - private InFlowExtensionResponseProcessor responseProcessor; - private MockedStatic loggerUtilsMock; - private MockedStatic holderMock; - private InFlowExtensionDataHolder holderInstance; - private FlowContext capturedFlowContext; - - @BeforeMethod - public void setUp() throws Exception { - - responseProcessor = new InFlowExtensionResponseProcessor(); - loggerUtilsMock = mockStatic(LoggerUtils.class); - loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); - - holderInstance = mock(InFlowExtensionDataHolder.class); - holderMock = mockStatic(InFlowExtensionDataHolder.class); - holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); - } - - @AfterMethod - public void tearDown() { - - holderMock.close(); - loggerUtilsMock.close(); - capturedFlowContext = null; - } - - // ========================= getSupportedActionType ========================= - - @Test - public void testGetSupportedActionType() { - - assertEquals(responseProcessor.getSupportedActionType(), ActionType.FLOW_EXTENSIONS); - } - - // ========================= processSuccessResponse — Property REPLACE ========================= - - @Test - public void testPropertyReplaceFlatExists() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.setProperty("riskScore", "50"); - - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - assertEquals(pendingProps.get("riskScore"), "80"); - } - - @Test - public void testPropertyReplaceCreatesIfMissing() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - // riskScore not set — REPLACE should auto-create it. - - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - assertEquals(pendingProps.get("riskScore"), "80"); - } - - @Test - public void testPropertyReplaceCoercesToStringByDefault() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Integer value with no annotation → coerced to String. - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", 75); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, replaceOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - assertEquals(pendingProps.get("riskScore"), "75"); - } - - @Test - public void testPropertyReplaceWithMultivaluedAnnotation() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/riskFactors", "[String]"); - - List factors = Arrays.asList("ip_mismatch", "new_device"); - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskFactors", factors); - ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - Object stored = pendingProps.get("riskFactors"); - assertTrue(stored instanceof List); - assertEquals(((List) stored).size(), 2); - } - - @Test - public void testPropertyReplaceMultivaluedSingleValueWrapped() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - // Single value with [String] annotation → wrapped in a list. - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/tags", "singleTag"); - executeSuccessResponse(execCtx, replaceOp, annotations); - - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - Object stored = pendingProps.get("tags"); - assertTrue(stored instanceof List); - assertEquals(((List) stored).size(), 1); - assertEquals(((List) stored).get(0), "singleTag"); - } - - @Test - public void testPropertyReplaceWithComplexAnnotation() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/item", "name: String, count: Integer"); - - Map item = new HashMap<>(); - item.put("name", "item1"); - item.put("count", 5); - - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/item", item); - ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - // Complex annotation → value passed through as-is. - Object stored = pendingProps.get("item"); - assertTrue(stored instanceof Map); - } - - @Test - public void testPropertyReplaceWithPrimaryTypeAnnotation() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - - // Integer annotation → coerced to String. - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", 95); - executeSuccessResponse(execCtx, replaceOp, annotations); - - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - assertEquals(pendingProps.get("score"), "95"); - } - - @Test - public void testPropertyReplaceNullValue() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.setProperty("score", "50"); - - PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", null); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, replaceOp, Collections.emptyMap()); - - // Null value should fail for REPLACE. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Original value should remain. - assertEquals(execCtx.getProperties().get("score"), "50"); - } - - @Test - public void testPropertyReplaceEmptyPropertyName() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/", "value"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - // Empty property name should fail. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - @Test - public void testPropertyReplaceComplexObjectInvalidSchema() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/data", "risk: Float, factor: String"); - - // Value has an unknown attribute not in the schema. - Map value = new HashMap<>(); - value.put("risk", 0.85); - value.put("unknown", "bad"); - - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", value); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); - - // Should succeed overall (logged as failure for this operation) but property not set. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertNull(execCtx.getProperties().get("data")); - } - - @Test - public void testPropertyReplaceArrayExceedsItemLimit() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - // Create a list with 11 items (exceeds max 10). - List bigList = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - bigList.add("item" + i); - } - - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/tags", bigList); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); - - // Should succeed overall but property not set due to array limit. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertNull(execCtx.getProperties().get("tags")); - } - - @Test - public void testPropertyReplaceComplexArrayExceedsItemLimit() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float]"); - - List> items = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - Map item = new HashMap<>(); - item.put("risk", (float) i); - items.add(item); - } - - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/risks", items); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); - - // Should succeed overall but property not set due to item limit on complex array. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertNull(execCtx.getProperties().get("risks")); - } - - // ========================= processSuccessResponse — User claim REPLACE ========================= - - @Test - public void testUserClaimReplace() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.getFlowUser().addClaim("http://wso2.org/claims/email", "old@example.com"); - - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "new@example.com"); - executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); - - Map pendingClaims = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); - assertNotNull(pendingClaims); - assertEquals(pendingClaims.get("http://wso2.org/claims/email"), "new@example.com"); - } - - @Test - public void testUserClaimReplaceCreatesNewClaim() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", "US"); - executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); - - Map pendingClaims = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); - assertNotNull(pendingClaims); - assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "US"); - } - - @Test - public void testUserClaimReplaceStringifiesValue() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Numeric value should be stringified. - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", 42); - executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); - - Map pendingClaims = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); - assertNotNull(pendingClaims); - assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "42"); - } - - @Test - public void testUserClaimReplaceIdentityClaimRejected() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Identity claim should be rejected by the identity-claim prefix guard. - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/identity/accountLocked]", "true"); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Claim must NOT be staged in the pending map. - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); - } - - @Test - public void testUserClaimReplaceNonExistentClaimRejected() - throws ActionExecutionResponseProcessorException, ClaimMetadataException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Mock ClaimMetadataManagementService to return null for an unknown claim URI. - ClaimMetadataManagementService claimService = mock(ClaimMetadataManagementService.class); - when(holderInstance.getClaimMetadataManagementService()).thenReturn(claimService); - when(claimService.getLocalClaim("http://wso2.org/claims/nonexistent", "carbon.super")) - .thenReturn(java.util.Optional.empty()); - - // Claim not registered in the system should be rejected. - PerformableOperation claimOp = createOperation( - Operation.REPLACE, - "/user/claims[uri=http://wso2.org/claims/nonexistent]", "value"); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Claim must NOT be staged in the pending map. - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); - } - - @Test - public void testUserClaimReplaceNonLocalDialectRejected() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Non-local dialect claim should be rejected by the local-dialect prefix guard. - PerformableOperation claimOp = createOperation( - Operation.REPLACE, - "/user/claims[uri=urn:ietf:params:scim:schemas:core:2.0:User:name.givenName]", "John"); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Claim must NOT be staged in the pending map. - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); - } - - @Test - public void testUserClaimReplaceNullValue() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", null); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - // Operation should fail — null value. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertNull(execCtx.getFlowUser().getClaims().get("http://wso2.org/claims/email")); - } - - @Test - public void testUserClaimReplaceEmptyClaimUri() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation claimOp = createOperation(Operation.REPLACE, "/user/claims[uri=]", "value"); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - // Should fail — empty claim URI. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - @Test - public void testUserClaimReplaceNoFlowUser() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.setFlowUser(null); - - PerformableOperation claimOp = createOperation( - Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "test@email.com"); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, claimOp, Collections.emptyMap()); - - // Should fail — no FlowUser. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - // ========================= processSuccessResponse — User input REPLACE ========================= - - @Test - public void testUserInputReplace() throws ActionExecutionResponseProcessorException { - - // /input/ paths are no longer a modify target; operations on them are silently ignored. - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.addUserInputData("consent", "false"); - - PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); - - // Operation succeeds overall but /input/ is not a modify target — input data unchanged. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertEquals(execCtx.getUserInputData().get("consent"), "false"); - } - - @Test - public void testUserInputReplaceCreatesNew() throws ActionExecutionResponseProcessorException { - - // /input/ paths are no longer a modify target; operations on them are silently ignored. - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); - - // Operation succeeds overall but /input/ is not a modify target — value is not created. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - assertNull(execCtx.getUserInputData().get("consent")); - } - - @Test - public void testUserInputReplaceNullValue() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.addUserInputData("consent", "true"); - - PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", null); - ActionExecutionStatus status = executeSuccessResponse( - execCtx, inputOp, Collections.emptyMap()); - - // Null value should fail. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Original value should remain. - assertEquals(execCtx.getUserInputData().get("consent"), "true"); - } - - // ========================= processSuccessResponse — Read-only paths ========================= - - @Test - public void testReadOnlyFlowPath() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation op = createOperation(Operation.REPLACE, "/flow/tenantDomain", "newValue"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - // Operation should fail but overall status is SUCCESS. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - @Test - public void testReadOnlyGraphPath() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation op = createOperation(Operation.REPLACE, "/graph/currentNode/id", "newId"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - // ========================= processSuccessResponse — Unknown path ========================= - - @Test - public void testUnknownPathPrefix() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - PerformableOperation op = createOperation(Operation.REPLACE, "/unknown/path", "value"); - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - // Unknown path → operation fails, but overall status is SUCCESS. - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - @Test - public void testNullPathOperationFails() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // Operation with null path must fail gracefully without NPE. - PerformableOperation op = new PerformableOperation(); - op.setOp(Operation.REPLACE); - // path is intentionally not set (null). - - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Nothing should be staged in any pending map. - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); - } - - @Test - public void testUnsupportedOperationTypeOnValidPathFails() - throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - // ADD (non-REPLACE) on a valid properties path must be rejected before routing. - PerformableOperation op = new PerformableOperation(); - op.setOp(Operation.ADD); - op.setPath("/properties/riskScore"); - op.setValue("90"); - - ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - // Property must NOT be staged. - assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); - } - - // ========================= processSuccessResponse — Multiple operations ========================= - - @Test - public void testMultipleOperationsMixedResults() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - execCtx.setProperty("existingProp", "old"); - - List operations = new ArrayList<>(); - operations.add(createOperation(Operation.REPLACE, "/properties/newProp", "newValue")); - operations.add(createOperation(Operation.REPLACE, "/properties/existingProp", "updated")); - operations.add(createOperation(Operation.REPLACE, "/flow/readonly", "fail")); // This should fail. - - ActionExecutionStatus status = executeSuccessResponse(execCtx, operations, Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - Map pendingProps = - capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); - assertNotNull(pendingProps); - assertEquals(pendingProps.get("newProp"), "newValue"); - assertEquals(pendingProps.get("existingProp"), "updated"); - } - - @Test - public void testEmptyOperations() throws ActionExecutionResponseProcessorException { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - - ActionExecutionStatus status = executeSuccessResponse( - execCtx, Collections.emptyList(), Collections.emptyMap()); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); - } - - // ========================= processFailureResponse ========================= - - @Test - public void testProcessFailureResponse() throws ActionExecutionResponseProcessorException { - - ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); - when(failureResponse.getFailureReason()).thenReturn("high_risk_detected"); - when(failureResponse.getFailureDescription()).thenReturn("Risk score exceeds threshold"); - - @SuppressWarnings("unchecked") - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); - - FlowContext flowContext = FlowContext.create(); - - ActionExecutionStatus status = responseProcessor.processFailureResponse( - flowContext, responseContext); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.FAILED); - assertNotNull(status.getResponse()); - assertEquals(status.getResponse().getFailureReason(), "high_risk_detected"); - assertEquals(status.getResponse().getFailureDescription(), "Risk score exceeds threshold"); - } - - // ========================= processFailureResponse — full-key pass-through ========================= - - @Test - public void testProcessFailureResponseWithFullKeyPassesThrough() - throws ActionExecutionResponseProcessorException { - - // Extension now sends the full i18n key directly — processor must pass it through unchanged. - ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); - when(failureResponse.getFailureReason()).thenReturn("high_risk"); - when(failureResponse.getFailureDescription()) - .thenReturn("inflow.extension.risk.assessment.extension.user.access.denied"); - - @SuppressWarnings("unchecked") - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); - - FlowContext flowContext = FlowContext.create(); - - ActionExecutionStatus status = responseProcessor.processFailureResponse( - flowContext, responseContext); - - // Full key passes through unchanged — no wrapping or prefixing. - assertEquals(status.getResponse().getFailureDescription(), - "inflow.extension.risk.assessment.extension.user.access.denied"); - assertEquals(status.getResponse().getFailureReason(), "high_risk"); - } - - @Test - public void testProcessFailureResponseWithShortKeyPassesThrough() - throws ActionExecutionResponseProcessorException { - - // Short dot-separated key also passes through as-is — IS no longer adds a prefix. - ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); - when(failureResponse.getFailureReason()).thenReturn("high_risk"); - when(failureResponse.getFailureDescription()).thenReturn("user.access.denied"); - - @SuppressWarnings("unchecked") - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); - - FlowContext flowContext = FlowContext.create(); - - ActionExecutionStatus status = responseProcessor.processFailureResponse( - flowContext, responseContext); - - assertEquals(status.getResponse().getFailureDescription(), "user.access.denied"); - assertEquals(status.getResponse().getFailureReason(), "high_risk"); - } - - // ========================= processErrorResponse ========================= - - @Test - public void testProcessErrorResponse() throws ActionExecutionResponseProcessorException { - - ActionInvocationErrorResponse errorResponse = mock(ActionInvocationErrorResponse.class); - when(errorResponse.getErrorMessage()).thenReturn("internal_error"); - when(errorResponse.getErrorDescription()).thenReturn("Database connection failed"); - - @SuppressWarnings("unchecked") - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(errorResponse); - - FlowContext flowContext = FlowContext.create(); - - ActionExecutionStatus status = responseProcessor.processErrorResponse( - flowContext, responseContext); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.ERROR); - assertNotNull(status.getResponse()); - assertEquals(status.getResponse().getErrorMessage(), "internal_error"); - assertEquals(status.getResponse().getErrorDescription(), "Database connection failed"); - } - - // ========================= processIncompleteResponse ========================= - - @Test - public void testProcessIncompleteResponseWithRedirectStashesUrlAndReturnsIncomplete() - throws ActionExecutionResponseProcessorException { - - PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); - - FlowContext flowContext = FlowContext.create(); - ActionExecutionStatus status = executeIncompleteResponse( - flowContext, Collections.singletonList(redirect)); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); - // URL must be stashed under the key the executor reads. - assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), - "https://example.com/step-up"); - } - - @Test - public void testProcessIncompleteResponseIgnoresNonRedirectOperations() - throws ActionExecutionResponseProcessorException { - - // Contract: REPLACE ops sent alongside REDIRECT on an INCOMPLETE response are dropped - // — the extension is expected to resend them on the resume call. - PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); - PerformableOperation replace = createOperation( - Operation.REPLACE, "/properties/riskScore", "42"); - - FlowContext flowContext = FlowContext.create(); - ActionExecutionStatus status = executeIncompleteResponse( - flowContext, Arrays.asList(redirect, replace)); - - assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); - // Redirect URL is captured, but the REPLACE must NOT have produced any pending props. - assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), - "https://example.com/step-up"); - assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); - assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); - assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); - } - - @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) - public void testProcessIncompleteResponseWithoutRedirectThrows() - throws ActionExecutionResponseProcessorException { - - // INCOMPLETE without a REDIRECT op is a contract violation. - PerformableOperation replace = createOperation( - Operation.REPLACE, "/properties/riskScore", "42"); - - executeIncompleteResponse(FlowContext.create(), Collections.singletonList(replace)); - } - - @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) - public void testProcessIncompleteResponseWithEmptyOperationsThrows() - throws ActionExecutionResponseProcessorException { - - executeIncompleteResponse(FlowContext.create(), Collections.emptyList()); - } - - @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) - public void testProcessIncompleteResponseWithNullOperationsThrows() - throws ActionExecutionResponseProcessorException { - - executeIncompleteResponse(FlowContext.create(), null); - } - - @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) - public void testProcessIncompleteResponseWithEmptyRedirectUrlThrows() - throws ActionExecutionResponseProcessorException { - - PerformableOperation emptyRedirect = createRedirectOperation(""); - - executeIncompleteResponse(FlowContext.create(), Collections.singletonList(emptyRedirect)); - } - - @SuppressWarnings("unchecked") - private ActionExecutionStatus executeIncompleteResponse( - FlowContext flowContext, List operations) - throws ActionExecutionResponseProcessorException { - - ActionInvocationIncompleteResponse incompleteResponse = - mock(ActionInvocationIncompleteResponse.class); - when(incompleteResponse.getOperations()).thenReturn(operations); - - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(incompleteResponse); - - return responseProcessor.processIncompleteResponse(flowContext, responseContext); - } - - // ========================= Helper methods ========================= - - private FlowExecutionContext createFlowExecutionContext() { - - FlowExecutionContext context = new FlowExecutionContext(); - context.setTenantDomain("carbon.super"); - context.setContextIdentifier("test-id"); - - FlowUser flowUser = new FlowUser(); - flowUser.setId("user-1"); - flowUser.setUsername("testuser"); - context.setFlowUser(flowUser); - - return context; - } - - private PerformableOperation createOperation(Operation op, String path, Object value) { - - PerformableOperation operation = new PerformableOperation(); - operation.setOp(op); - operation.setPath(path); - if (value != null) { - operation.setValue(value); - } - return operation; - } - - private PerformableOperation createRedirectOperation(String url) { - - // PerformableOperation rejects setPath/setValue for REDIRECT and rejects setUrl for - // every other op — REDIRECT carries its target in the dedicated `url` field. - PerformableOperation operation = new PerformableOperation(); - operation.setOp(Operation.REDIRECT); - operation.setUrl(url); - return operation; - } - - private ActionExecutionStatus executeSuccessResponse( - FlowExecutionContext execCtx, PerformableOperation operation, - Map pathTypeAnnotations) - throws ActionExecutionResponseProcessorException { - - return executeSuccessResponse(execCtx, Collections.singletonList(operation), pathTypeAnnotations); - } - - @SuppressWarnings("unchecked") - private ActionExecutionStatus executeSuccessResponse( - FlowExecutionContext execCtx, List operations, - Map pathTypeAnnotations) - throws ActionExecutionResponseProcessorException { - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); - } - - capturedFlowContext = flowContext; - - ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); - when(successResponse.getOperations()).thenReturn(operations); - - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); - - return responseProcessor.processSuccessResponse(flowContext, responseContext); - } - - @SuppressWarnings("unchecked") - private ActionExecutionStatus executeSuccessResponseWithModifyPaths( - FlowExecutionContext execCtx, List operations, - Map pathTypeAnnotations, List modifyPaths) - throws ActionExecutionResponseProcessorException { - - FlowContext flowContext = FlowContext.create() - .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); - - if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { - flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); - } - if (modifyPaths != null) { - flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); - } - - capturedFlowContext = flowContext; - - ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); - when(successResponse.getOperations()).thenReturn(operations); - - ActionExecutionResponseContext responseContext = - mock(ActionExecutionResponseContext.class); - when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); - - return responseProcessor.processSuccessResponse(flowContext, responseContext); - } - - // ========================= Inbound decryption failure ========================= - - @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) - public void testDecryptionFailureThrowsException() - throws ActionExecutionResponseProcessorException { - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(true); - jweUtilMock.when(() -> JWEEncryptionUtil.decrypt(anyString(), anyString())) - .thenThrow(new RuntimeException("Decryption failed")); - - FlowExecutionContext execCtx = createFlowExecutionContext(); - List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); - - // Value looks like JWE (5 dot-separated parts). - PerformableOperation op = createOperation( - Operation.REPLACE, "/properties/secret", "a.b.c.d.e"); - - executeSuccessResponseWithModifyPaths(execCtx, Collections.singletonList(op), - Collections.emptyMap(), modifyPaths); - } - } - - @Test - public void testNonStringValueForEncryptedPathThrowsException() { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - List modifyPaths = Arrays.asList(new ContextPath("/properties/data", true)); - - // Non-string value (Integer) for an encrypted path must now be rejected with an exception - // because accepting non-JWE values would make the encryption flag advisory. - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", 42); - - try { - executeSuccessResponseWithModifyPaths( - execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); - throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); - } catch (ActionExecutionResponseProcessorException e) { - assertTrue(e.getMessage().contains("/properties/data"), - "Exception message should reference the offending path."); - } - } - - @Test - public void testPlaintextStringForEncryptedPathThrowsException() { - - FlowExecutionContext execCtx = createFlowExecutionContext(); - List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); - - // Plain-text string (not JWE-formatted) for an encrypted modify path must be rejected. - PerformableOperation op = createOperation(Operation.REPLACE, "/properties/secret", "plaintext-value"); - - try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { - jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(false); - try { - executeSuccessResponseWithModifyPaths( - execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); - throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); - } catch (ActionExecutionResponseProcessorException e) { - assertTrue(e.getMessage().contains("/properties/secret"), - "Exception message should reference the offending path."); - } - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java deleted file mode 100644 index eb4f3a06588b..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/executor/PathTypeAnnotationUtilTest.java +++ /dev/null @@ -1,618 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.executor; - -import org.testng.annotations.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link PathTypeAnnotationUtil}. - */ -public class PathTypeAnnotationUtilTest { - - // ========================= stripAnnotation ========================= - - @Test - public void testStripAnnotationPrimaryType() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactor{String}"); - assertEquals(result[0], "/properties/riskFactor"); - assertEquals(result[1], "String"); - } - - @Test - public void testStripAnnotationMultivaluedPrimary() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactors{[String]}"); - assertEquals(result[0], "/properties/riskFactors"); - assertEquals(result[1], "[String]"); - } - - @Test - public void testStripAnnotationComplexObject() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation( - "/properties/risk{risk: Float, factor: String}"); - assertEquals(result[0], "/properties/risk"); - assertEquals(result[1], "risk: Float, factor: String"); - } - - @Test - public void testStripAnnotationMultivaluedComplex() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation( - "/properties/risks{[risk: Float, factor: String]}"); - assertEquals(result[0], "/properties/risks"); - assertEquals(result[1], "[risk: Float, factor: String]"); - } - - @Test - public void testStripAnnotationNoAnnotation() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskScore"); - assertEquals(result[0], "/properties/riskScore"); - assertNull(result[1]); - } - - @Test - public void testStripAnnotationNullInput() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation(null); - assertNull(result[0]); - assertNull(result[1]); - } - - @Test - public void testStripAnnotationEmptyBraces() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/field{}"); - assertEquals(result[0], "/properties/field"); - assertEquals(result[1], ""); - } - - @Test - public void testStripAnnotationIntegerType() { - - String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/count{Integer}"); - assertEquals(result[0], "/properties/count"); - assertEquals(result[1], "Integer"); - } - - // ========================= coerceValue ========================= - - @Test - public void testCoerceValueNoAnnotation() { - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/score", 42, Collections.emptyMap()); - assertEquals(result, "42"); - } - - @Test - public void testCoerceValuePrimaryTypeAnnotation() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", 95, annotations); - assertEquals(result, "95"); - } - - @Test - public void testCoerceValueMultivaluedPrimaryList() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - List input = Arrays.asList("tag1", "tag2", "tag3"); - Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); - - assertTrue(result instanceof List); - @SuppressWarnings("unchecked") - List list = (List) result; - assertEquals(list.size(), 3); - assertEquals(list.get(0), "tag1"); - } - - @Test - public void testCoerceValueMultivaluedPrimarySingleValue() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", "singleTag", annotations); - - assertTrue(result instanceof List); - @SuppressWarnings("unchecked") - List list = (List) result; - assertEquals(list.size(), 1); - assertEquals(list.get(0), "singleTag"); - } - - @Test - public void testCoerceValueComplexObjectAnnotation() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - Map complexValue = new HashMap<>(); - complexValue.put("risk", 0.85); - complexValue.put("factor", "ip_mismatch"); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/risk", complexValue, annotations); - // Complex annotation — passed through as-is. - assertTrue(result instanceof Map); - assertEquals(result, complexValue); - } - - @Test - public void testCoerceValueComplexArrayAnnotation() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float, factor: String]"); - - List> items = Arrays.asList( - new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, - new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} - ); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/risks", items, annotations); - // Complex array annotation — passed through as-is. - assertTrue(result instanceof List); - assertEquals(result, items); - } - - @Test - public void testCoerceValueBooleanPrimaryType() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/active", "Boolean"); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/active", true, annotations); - assertEquals(result, "true"); - } - - @Test - public void testCoerceValueStringValue() { - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/name", "test", Collections.emptyMap()); - assertEquals(result, "test"); - } - - @Test - public void testCoerceValueNoAnnotationNullValue() { - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/score", null, Collections.emptyMap()); - assertNull(result); - } - - @Test - public void testCoerceValuePrimaryTypeAnnotationNullValue() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - - Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", null, annotations); - assertNull(result); - } - - @Test - public void testCoerceValueMultivaluedPrimaryPreservesNullElements() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - List input = Arrays.asList("tag1", null, "tag3"); - Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); - - assertTrue(result instanceof List); - @SuppressWarnings("unchecked") - List list = (List) result; - assertEquals(list.size(), 3); - assertEquals(list.get(0), "tag1"); - assertNull(list.get(1)); - assertEquals(list.get(2), "tag3"); - } - - // ========================= validateAnnotationLimits ========================= - - @Test - public void testValidateAnnotationLimitsNull() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(null)); - } - - @Test - public void testValidateAnnotationLimitsEmpty() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("")); - } - - @Test - public void testValidateAnnotationLimitsPrimaryType() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("String")); - } - - @Test - public void testValidateAnnotationLimitsPrimaryArray() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[String]")); - } - - @Test - public void testValidateAnnotationLimitsComplexWithinLimit() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("risk: Float, factor: String")); - } - - @Test - public void testValidateAnnotationLimitsComplexArrayWithinLimit() { - - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[risk: Float, factor: String]")); - } - - @Test - public void testValidateAnnotationLimitsExceedsMax() { - - // Build an annotation with 11 attributes (exceeds MAX_ATTRIBUTES_PER_OBJECT = 10). - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 11; i++) { - if (i > 0) sb.append(", "); - sb.append("attr").append(i).append(": String"); - } - assertFalse(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); - } - - @Test - public void testValidateAnnotationLimitsExactlyAtMax() { - - // Build an annotation with exactly 10 attributes. - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 10; i++) { - if (i > 0) sb.append(", "); - sb.append("attr").append(i).append(": String"); - } - assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); - } - - // ========================= validateValueAgainstAnnotation ========================= - - @Test - public void testValidateValueNoAnnotation() { - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/score", 42, Collections.emptyMap())); - } - - @Test - public void testValidateValuePrimaryAnnotation() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/score", 42, annotations)); - } - - @Test - public void testValidateValueComplexObjectValid() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - Map value = new HashMap<>(); - value.put("risk", 0.85); - value.put("factor", "ip_mismatch"); - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risk", value, annotations)); - } - - @Test - public void testValidateValueComplexObjectUnknownAttribute() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - Map value = new HashMap<>(); - value.put("risk", 0.85); - value.put("unknown", "bad"); - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risk", value, annotations)); - } - - @Test - public void testValidateValueComplexObjectNotAMap() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risk", "not a map", annotations)); - } - - @Test - public void testValidateValueComplexObjectNestedMap() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - Map nested = new HashMap<>(); - nested.put("deep", "value"); - - Map value = new HashMap<>(); - value.put("risk", 0.85); - value.put("factor", nested); - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risk", value, annotations)); - } - - @Test - public void testValidateValueComplexArrayValid() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float, factor: String]"); - - List> items = Arrays.asList( - new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, - new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} - ); - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risks", items, annotations)); - } - - @Test - public void testValidateValueComplexArrayExceedsItemLimit() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float]"); - - List> items = new java.util.ArrayList<>(); - for (int i = 0; i < 11; i++) { - Map item = new HashMap<>(); - item.put("risk", (float) i); - items.add(item); - } - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risks", items, annotations)); - } - - @Test - public void testValidateValueComplexObjectWithArrayAttribute() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/data", "tags: String[], score: Float"); - - Map value = new HashMap<>(); - value.put("tags", Arrays.asList("a", "b", "c")); - value.put("score", 0.9); - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/data", value, annotations)); - } - - @Test - public void testValidateValueComplexObjectArrayAttrExceedsLimit() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/data", "tags: String[]"); - - List bigList = new java.util.ArrayList<>(); - for (int i = 0; i < 11; i++) { - bigList.add("item" + i); - } - - Map value = new HashMap<>(); - value.put("tags", bigList); - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/data", value, annotations)); - } - - // ========================= enforceArrayItemLimit ========================= - - @Test - public void testEnforceArrayItemLimitNoAnnotation() { - - assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( - "/properties/score", Arrays.asList(1, 2, 3), Collections.emptyMap())); - } - - @Test - public void testEnforceArrayItemLimitWithinLimit() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( - "/properties/tags", Arrays.asList("a", "b", "c"), annotations)); - } - - @Test - public void testEnforceArrayItemLimitExceedsLimit() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - List bigList = new java.util.ArrayList<>(); - for (int i = 0; i < 11; i++) { - bigList.add("item" + i); - } - - assertFalse(PathTypeAnnotationUtil.enforceArrayItemLimit( - "/properties/tags", bigList, annotations)); - } - - @Test - public void testEnforceArrayItemLimitNonArrayAnnotation() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - - assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( - "/properties/score", 42, annotations)); - } - - @Test - public void testEnforceArrayItemLimitNonListValue() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( - "/properties/tags", "singleValue", annotations)); - } - - // ========================= JSON string parsing ========================= - - @Test - public void testValidateValueComplexArrayFromJsonString() - throws Exception { - - // This is the exact failing case: after JWE decryption the value is a JSON string. - Map annotations = new HashMap<>(); - annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); - - String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/riskFactors", jsonString, annotations)); - } - - @Test - public void testValidateValueComplexArrayFromJsonStringMultipleItems() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float, factor: String]"); - - String jsonString = "[{\"risk\":0.5,\"factor\":\"ip_mismatch\"}" + - ",{\"risk\":0.8,\"factor\":\"high_risk_email\"}]"; - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risks", jsonString, annotations)); - } - - @Test - public void testValidateValueComplexObjectFromJsonString() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; - - assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risk", jsonString, annotations)); - } - - @Test - public void testValidateValueComplexArrayFromJsonStringUnknownAttribute() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risks", "[risk: Float, factor: String]"); - - // JSON string with an attribute not in the schema. - String jsonString = "[{\"risk\":0.5,\"unknown\":\"bad\"}]"; - - assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( - "/properties/risks", jsonString, annotations)); - } - - @Test - public void testCoerceValueComplexArrayFromJsonString() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); - - String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/riskFactors", jsonString, annotations); - - // Should be parsed into a List, not returned as a plain string. - assertTrue(result instanceof List); - @SuppressWarnings("unchecked") - List list = (List) result; - assertEquals(list.size(), 1); - assertTrue(list.get(0) instanceof Map); - } - - @Test - public void testCoerceValueMultivaluedPrimaryFromJsonString() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/tags", "[String]"); - - String jsonString = "[\"tag1\",\"tag2\",\"tag3\"]"; - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/tags", jsonString, annotations); - - assertTrue(result instanceof List); - @SuppressWarnings("unchecked") - List list = (List) result; - assertEquals(list.size(), 3); - assertEquals(list.get(0), "tag1"); - } - - @Test - public void testCoerceValueComplexObjectFromJsonString() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/risk", "risk: Float, factor: String"); - - String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; - - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/risk", jsonString, annotations); - - // Should be parsed into a Map, not returned as a plain string. - assertTrue(result instanceof Map); - @SuppressWarnings("unchecked") - Map map = (Map) result; - assertEquals(map.get("factor"), "ip_mismatch"); - } - - @Test - public void testCoerceValueNonJsonStringIsNotParsed() { - - Map annotations = new HashMap<>(); - annotations.put("/properties/score", "Integer"); - - // A plain string with no JSON structure should be coerced to String normally. - Object result = PathTypeAnnotationUtil.coerceValue( - "/properties/score", "42", annotations); - assertEquals(result, "42"); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java deleted file mode 100644 index b2b48f3c238f..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/FlowContextHandoverConfigTestHelper.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; -import org.wso2.carbon.identity.flow.extensions.InFlowExtensionTestUtils; - -import java.util.Set; - -/** - * Test-only helper that delegates to {@link InFlowExtensionTestUtils#configOf} to - * instantiate {@link FlowContextHandoverConfig} with explicit allow-lists. - */ -final class FlowContextHandoverConfigTestHelper { - - private FlowContextHandoverConfigTestHelper() { - - } - - static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { - - return InFlowExtensionTestUtils.configOf(attrs, userAttrs); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java deleted file mode 100644 index 4e137e658bf2..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/metadata/InFlowExtensionContextTreeBuilderTest.java +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.metadata; - -import org.testng.annotations.Test; -import org.wso2.carbon.identity.flow.extensions.model.FlowContextHandoverConfig; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link InFlowExtensionContextTreeBuilder}. - * - *

    Uses {@link FlowContextHandoverConfigTestHelper} to construct configs with explicit - * allow-lists without touching IdentityConfigParser.

    - */ -public class InFlowExtensionContextTreeBuilderTest { - - // ========================= redirection always enabled ========================= - - @Test - public void testRedirectionAlwaysEnabled() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("tenantDomain")), - new HashSet<>(), null); - - assertTrue(meta.isRedirectionEnabled(), - "Redirection must always be true regardless of config"); - } - - // ========================= allowReadOnlyClaimsModification ========================= - - @Test - public void testAllowReadOnlyClaimsModificationForRegistration() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), "REGISTRATION"); - assertTrue(meta.isAllowReadOnlyClaimsModification()); - } - - @Test - public void testAllowReadOnlyClaimsModificationForInvitedUserRegistration() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), "INVITED_USER_REGISTRATION"); - assertTrue(meta.isAllowReadOnlyClaimsModification()); - } - - @Test - public void testAllowReadOnlyClaimsModificationFalseForPasswordRecovery() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), "PASSWORD_RECOVERY"); - assertFalse(meta.isAllowReadOnlyClaimsModification()); - } - - @Test - public void testAllowReadOnlyClaimsModificationFalseForUnknownFlowType() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), "SOME_FUTURE_FLOW"); - assertFalse(meta.isAllowReadOnlyClaimsModification()); - } - - @Test - public void testAllowReadOnlyClaimsModificationTrueForNullFlowType() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), null); - assertTrue(meta.isAllowReadOnlyClaimsModification()); - } - - // ========================= flow branch ========================= - - /** - * With an empty allow-list the modifiable fields (claims, credentials, properties) are still - * present but carry only MODIFY — no EXPOSE. Read-only fields (flow branch, user read-only - * scalar) are absent because they have no modify path. - */ - @Test - public void testEmptyAllowListRestrictsExposeOnly() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), null); - - // Modifiable nodes always present. - assertNotNull(findNode(meta, "user"), "user node must be present (has modifiable children)"); - assertNotNull(findNode(meta, "properties"), "properties node must be present (modifiable)"); - - // Read-only-only node absent when nothing configured. - assertNull(findNode(meta, "flow"), "flow node must be absent when no flow attrs configured"); - - // Properties has only MODIFY. - InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); - assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), - "properties must not have EXPOSE when not in allow-list"); - assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), - "properties must have MODIFY regardless of allow-list"); - - // Claims and credentials carry only MODIFY. - List userChildren = findNode(meta, "user").getChildren(); - InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); - assertNotNull(claimsNode, "claims always present"); - assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE"), - "claims must not have EXPOSE when not in allow-list"); - assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); - - InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); - assertNotNull(credNode, "credentials always present"); - assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), - "credentials must not have EXPOSE when not in allow-list"); - assertTrue(credNode.getAllowedOperations().contains("MODIFY")); - } - - @Test - public void testFlowNodeAppearsWhenFlowAttrsPresent() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("tenantDomain", "flowType")), - new HashSet<>(), null); - - InFlowExtensionContextTreeNode flowNode = findNode(meta, "flow"); - assertNotNull(flowNode, "flow node should be present"); - - // Only the allowed attrs should appear as children. - List children = flowNode.getChildren(); - assertTrue(hasChildKey(children, "tenantDomain"), "tenantDomain expected"); - assertTrue(hasChildKey(children, "flowType"), "flowType expected"); - assertFalse(hasChildKey(children, "applicationId"), "applicationId not in allow-list"); - assertFalse(hasChildKey(children, "callbackUrl"), "callbackUrl not in allow-list"); - assertFalse(hasChildKey(children, "portalUrl"), "portalUrl not in allow-list"); - } - - @Test - public void testFlowNodeAbsentWhenNoFlowAttrsIncluded() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("username")), // only user attr - new HashSet<>(Arrays.asList("username")), null); - - assertNull(findNode(meta, "flow"), "flow node must not appear if no flow attrs present"); - } - - // ========================= user branch ========================= - - @Test - public void testReadOnlyUserFieldsAbsentWhenNoUserAttrsIncluded() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("tenantDomain")), - new HashSet<>(), null); - - // User node is always present (claims + credentials always included). - InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); - assertNotNull(userNode, "user node must be present"); - - List children = userNode.getChildren(); - - // Read-only user fields absent when not configured. - assertFalse(hasChildKey(children, "id"), "userId must not appear"); - assertFalse(hasChildKey(children, "username"), "username must not appear"); - assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain must not appear"); - - // Modifiable fields still present. - assertTrue(hasChildKey(children, "claims"), "claims must always be present"); - assertTrue(hasChildKey(children, "credentials"), "credentials must always be present"); - } - - @Test - public void testUserNodeAppearsWithSelectedUserAttrs() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("tenantDomain")), // no flowUser - new HashSet<>(Arrays.asList("username", "claims")), null); - - InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); - assertNotNull(userNode, "user node should be present"); - - List children = userNode.getChildren(); - - // Configured read-only field is exposed. - assertTrue(hasChildKey(children, "username"), "username expected"); - - // Claims configured → EXPOSE+MODIFY. - InFlowExtensionContextTreeNode claimsNode = findChildNode(children, "claims"); - assertNotNull(claimsNode, "claims must be present"); - assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE"), "claims must have EXPOSE"); - assertTrue(claimsNode.getAllowedOperations().contains("MODIFY"), "claims must have MODIFY"); - - // Read-only fields not configured → absent. - assertFalse(hasChildKey(children, "id"), "userId not in allow-list"); - assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain not in allow-list"); - - // credentials not configured but always present → MODIFY only, no EXPOSE. - InFlowExtensionContextTreeNode credNode = findChildNode(children, "credentials"); - assertNotNull(credNode, "credentials always present"); - assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), - "credentials must not have EXPOSE when not in allow-list"); - assertTrue(credNode.getAllowedOperations().contains("MODIFY"), - "credentials must have MODIFY regardless"); - } - - @Test - public void testFullUserPassthroughShowsAllUserChildren() { - - // "flowUser" in context attrs → fullUserPassthrough → all user children present. - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("flowUser")), - new HashSet<>(), null); // includedUserAttributes empty — ignored in passthrough - - InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); - assertNotNull(userNode, "user node should be present with full passthrough"); - - List children = userNode.getChildren(); - assertTrue(hasChildKey(children, "id"), "userId must appear in passthrough"); - assertTrue(hasChildKey(children, "username"), "username must appear in passthrough"); - assertTrue(hasChildKey(children, "userStoreDomain"),"userStoreDomain must appear in passthrough"); - assertTrue(hasChildKey(children, "claims"), "claims must appear in passthrough"); - assertTrue(hasChildKey(children, "credentials"), "credentials must appear in passthrough"); - } - - // ========================= properties branch ========================= - - @Test - public void testPropertiesHasOnlyModifyWhenNotExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("tenantDomain")), // properties not in allow-list - new HashSet<>(), null); - - InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); - assertNotNull(propsNode, "properties node must always be present"); - assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), - "properties must not expose when not in allow-list"); - assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), - "properties must always allow MODIFY"); - } - - @Test - public void testPropertiesHasExposeAndModifyWhenExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("properties")), - new HashSet<>(), null); - - InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); - assertNotNull(propsNode, "properties node should be present"); - assertTrue(propsNode.getAllowedOperations().contains("EXPOSE"), - "properties must have EXPOSE when in allow-list"); - assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), - "properties must always have MODIFY"); - } - - @Test - public void testClaimsHasOnlyModifyWhenNotExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), - new HashSet<>(), null); // "claims" not in userAttrs - - List userChildren = findNode(meta, "user").getChildren(); - InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); - assertNotNull(claimsNode); - assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE")); - assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); - } - - @Test - public void testClaimsHasExposeAndModifyWhenExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), - new HashSet<>(Arrays.asList("claims")), null); - - List userChildren = findNode(meta, "user").getChildren(); - InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); - assertNotNull(claimsNode); - assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE")); - assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); - } - - @Test - public void testCredentialsHasOnlyModifyWhenNotExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), - new HashSet<>(), null); // "userCredentials" not in userAttrs - - List userChildren = findNode(meta, "user").getChildren(); - InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); - assertNotNull(credNode); - assertFalse(credNode.getAllowedOperations().contains("EXPOSE")); - assertTrue(credNode.getAllowedOperations().contains("MODIFY")); - } - - @Test - public void testCredentialsHasExposeAndModifyWhenExposed() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), - new HashSet<>(Arrays.asList("userCredentials")), null); - - List userChildren = findNode(meta, "user").getChildren(); - InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); - assertNotNull(credNode); - assertTrue(credNode.getAllowedOperations().contains("EXPOSE")); - assertTrue(credNode.getAllowedOperations().contains("MODIFY")); - } - - @Test - public void testFullPassthroughGivesExposeOnClaimsAndCredentials() { - - // flowUser in attrs → full passthrough → all user fields exposed. - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(Arrays.asList("flowUser")), - new HashSet<>(), null); - - List userChildren = findNode(meta, "user").getChildren(); - assertTrue(findChildNode(userChildren, "claims").getAllowedOperations().contains("EXPOSE")); - assertTrue(findChildNode(userChildren, "credentials").getAllowedOperations().contains("EXPOSE")); - } - - // ========================= flowType metadata field ========================= - - @Test - public void testFlowTypeFieldPreserved() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), "REGISTRATION"); - - assertEquals(meta.getFlowType(), "REGISTRATION"); - } - - @Test - public void testFlowTypeNullPreserved() { - - InFlowExtensionContextTreeMetadata meta = buildWith( - new HashSet<>(), new HashSet<>(), null); - - assertNull(meta.getFlowType()); - } - - // ========================= resolveAllowReadOnlyClaimsModification (static) ========================= - - @Test - public void testResolveAllowReadOnlyClaimsModificationDirectly() { - - assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification(null)); - assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification("REGISTRATION")); - assertTrue(InFlowExtensionContextTreeBuilder - .resolveAllowReadOnlyClaimsModification("INVITED_USER_REGISTRATION")); - assertFalse(InFlowExtensionContextTreeBuilder - .resolveAllowReadOnlyClaimsModification("PASSWORD_RECOVERY")); - assertFalse(InFlowExtensionContextTreeBuilder - .resolveAllowReadOnlyClaimsModification("UNKNOWN_TYPE")); - } - - // ========================= helpers ========================= - - private InFlowExtensionContextTreeMetadata buildWith(Set attrs, - Set userAttrs, - String flowType) { - - FlowContextHandoverConfig cfg = FlowContextHandoverConfigTestHelper.of(attrs, userAttrs); - return new InFlowExtensionContextTreeBuilder(cfg).build(flowType); - } - - private InFlowExtensionContextTreeNode findNode(InFlowExtensionContextTreeMetadata meta, - String key) { - - if (meta.getContextTree() == null) { - return null; - } - for (InFlowExtensionContextTreeNode node : meta.getContextTree()) { - if (key.equals(node.getKey())) { - return node; - } - } - return null; - } - - private boolean hasChildKey(List children, String key) { - - if (children == null) { - return false; - } - for (InFlowExtensionContextTreeNode child : children) { - if (key.equals(child.getKey())) { - return true; - } - } - return false; - } - - private InFlowExtensionContextTreeNode findChildNode(List children, - String key) { - - if (children == null) { - return null; - } - for (InFlowExtensionContextTreeNode child : children) { - if (key.equals(child.getKey())) { - return child; - } - } - return null; - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java deleted file mode 100644 index c763c07e29dd..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/AccessConfigTest.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.testng.annotations.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link AccessConfig} and {@link ContextPath}. - */ -public class AccessConfigTest { - - @Test - public void testAccessConfigWithNullLists() { - - AccessConfig config = new AccessConfig(null, null); - assertNull(config.getExpose()); - assertNull(config.getModify()); - assertNotNull(config.getExposePaths()); - assertTrue(config.getExposePaths().isEmpty()); - assertNotNull(config.getModifyPaths()); - assertTrue(config.getModifyPaths().isEmpty()); - } - - @Test - public void testAccessConfigWithEmptyLists() { - - AccessConfig config = new AccessConfig(Collections.emptyList(), Collections.emptyList()); - assertNotNull(config.getExpose()); - assertTrue(config.getExpose().isEmpty()); - assertNotNull(config.getModify()); - assertTrue(config.getModify().isEmpty()); - assertNotNull(config.getExposePaths()); - assertTrue(config.getExposePaths().isEmpty()); - assertNotNull(config.getModifyPaths()); - assertTrue(config.getModifyPaths().isEmpty()); - } - - @Test - public void testGetExposePaths() { - - List exposePaths = Arrays.asList( - new ContextPath("/user/claims/", true), - new ContextPath("/user/credentials/", false) - ); - AccessConfig config = new AccessConfig(exposePaths, null); - - List paths = config.getExposePaths(); - assertEquals(paths.size(), 2); - assertEquals(paths.get(0), "/user/claims/"); - assertEquals(paths.get(1), "/user/credentials/"); - } - - @Test - public void testGetModifyPaths() { - - List modifyPaths = Arrays.asList( - new ContextPath("/properties/riskScore", false), - new ContextPath("/user/claims/", true) - ); - AccessConfig config = new AccessConfig(null, modifyPaths); - - List paths = config.getModifyPaths(); - assertEquals(paths.size(), 2); - assertEquals(paths.get(0), "/properties/riskScore"); - assertEquals(paths.get(1), "/user/claims/"); - } - - @Test - public void testIsExposePathEncryptedMatchesLongestPrefix() { - - List exposePaths = Arrays.asList( - new ContextPath("/user/claims/email", true), - new ContextPath("/user/username", false) - ); - AccessConfig config = new AccessConfig(exposePaths, null); - - assertTrue(config.isExposePathEncrypted("/user/claims/email")); - assertFalse(config.isExposePathEncrypted("/user/username")); - } - - @Test - public void testIsExposePathEncryptedNoMatch() { - - List exposePaths = Collections.singletonList( - new ContextPath("/user/claims/email", true) - ); - AccessConfig config = new AccessConfig(exposePaths, null); - - assertFalse(config.isExposePathEncrypted("/properties/riskScore")); - } - - @Test - public void testIsExposePathEncryptedWithNullExpose() { - - AccessConfig config = new AccessConfig(null, null); - assertFalse(config.isExposePathEncrypted("/user/claims/email")); - } - - @Test - public void testIsModifyPathEncryptedMatchesExactPath() { - - List modifyPaths = Arrays.asList( - new ContextPath("/user/username", false), - new ContextPath("/user/credentials/password", true) - ); - AccessConfig config = new AccessConfig(null, modifyPaths); - - assertTrue(config.isModifyPathEncrypted("/user/credentials/password")); - assertFalse(config.isModifyPathEncrypted("/user/username")); - assertFalse(config.isModifyPathEncrypted("/user/credentials/other")); - } - - @Test - public void testIsModifyPathEncryptedWithNullModify() { - - AccessConfig config = new AccessConfig(null, null); - assertFalse(config.isModifyPathEncrypted("/user/credentials/password")); - } - - @Test - public void testIsModifyPathEncryptedWithAnnotatedPaths() { - - List modifyPaths = Arrays.asList( - new ContextPath("/properties/risk{risk: Float, factor: String}", true), - new ContextPath("/properties/tags{[String]}", false) - ); - AccessConfig config = new AccessConfig(null, modifyPaths); - - // Clean operation path should match annotated modify path after stripping. - assertTrue(config.isModifyPathEncrypted("/properties/risk")); - assertFalse(config.isModifyPathEncrypted("/properties/tags")); - assertFalse(config.isModifyPathEncrypted("/properties/unknown")); - } - - @Test - public void testContextPathGetters() { - - ContextPath path = new ContextPath("/user/claims/email", true); - assertEquals(path.getPath(), "/user/claims/email"); - assertTrue(path.isEncrypted()); - - ContextPath unencrypted = new ContextPath("/properties/", false); - assertEquals(unencrypted.getPath(), "/properties/"); - assertFalse(unencrypted.isEncrypted()); - } - - @Test - public void testExposeListIsUnmodifiable() { - - List exposePaths = Arrays.asList( - new ContextPath("/user/claims/", true) - ); - AccessConfig config = new AccessConfig(exposePaths, null); - - try { - config.getExpose().add(new ContextPath("/hack/", false)); - // If no exception thrown, fail the test - assertTrue(false, "Expected UnsupportedOperationException"); - } catch (UnsupportedOperationException e) { - // Expected - } - } - - @Test - public void testModifyListIsUnmodifiable() { - - List modifyPaths = Arrays.asList( - new ContextPath("/user/claims/", true) - ); - AccessConfig config = new AccessConfig(null, modifyPaths); - - try { - config.getModify().add(new ContextPath("/hack/", false)); - assertTrue(false, "Expected UnsupportedOperationException"); - } catch (UnsupportedOperationException e) { - // Expected - } - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java deleted file mode 100644 index 2827cd16feb6..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/InFlowExtensionEventTest.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.testng.annotations.Test; - -import java.util.HashMap; -import java.util.Map; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link InFlowExtensionEvent}. - */ -public class InFlowExtensionEventTest { - - @Test - public void testBuilderWithAllFields() { - - Map flowProperties = new HashMap<>(); - flowProperties.put("riskScore", 85); - - InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() - .flowType("REGISTRATION") - .flowId("flow-id-123") - .build(); - - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flow(flow) - .callbackUrl("https://example.com/callback") - .portalUrl("https://example.com/portal") - .flowProperties(flowProperties) - .build(); - - assertNotNull(event.getFlow()); - assertEquals(event.getFlow().getFlowType(), "REGISTRATION"); - assertEquals(event.getFlow().getFlowId(), "flow-id-123"); - assertEquals(event.getCallbackUrl(), "https://example.com/callback"); - assertEquals(event.getPortalUrl(), "https://example.com/portal"); - assertEquals(event.getFlowProperties().get("riskScore"), 85); - } - - @Test - public void testOptionalFieldsDefaultToNull() { - - InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() - .flowType("LOGIN") - .flowId("flow-id-456") - .build(); - - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flow(flow) - .flowProperties(null) - .build(); - - assertNotNull(event.getFlow()); - assertEquals(event.getFlow().getFlowType(), "LOGIN"); - assertEquals(event.getFlow().getFlowId(), "flow-id-456"); - assertNull(event.getCallbackUrl()); - assertNull(event.getPortalUrl()); - assertNotNull(event.getFlowProperties()); - assertTrue(event.getFlowProperties().isEmpty()); - } - - @Test - public void testFlowPropertiesAreUnmodifiable() { - - Map flowProperties = new HashMap<>(); - flowProperties.put("key", "value"); - - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flowProperties(flowProperties) - .build(); - - try { - event.getFlowProperties().put("hack", "value"); - assertTrue(false, "Expected UnsupportedOperationException"); - } catch (UnsupportedOperationException e) { - // Expected — map is unmodifiable - } - } - - @Test - public void testBuilderDoesNotShareMapReferences() { - - Map flowProperties = new HashMap<>(); - flowProperties.put("score", "original"); - - InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() - .flowProperties(flowProperties) - .build(); - - // Mutating the original map should not affect the event - flowProperties.put("score", "modified"); - assertEquals(event.getFlowProperties().get("score"), "original"); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java deleted file mode 100644 index ba4fa2992d3e..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/model/OperationExecutionResultTest.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.model; - -import org.testng.annotations.Test; -import org.wso2.carbon.identity.action.execution.api.model.Operation; -import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; - -/** - * Unit tests for {@link OperationExecutionResult}. - */ -public class OperationExecutionResultTest { - - private PerformableOperation createOperation(Operation op, String path, Object value) { - - PerformableOperation operation = new PerformableOperation(); - operation.setOp(op); - operation.setPath(path); - operation.setValue(value); - return operation; - } - - @Test - public void testSuccessResult() { - - PerformableOperation operation = createOperation( - Operation.REPLACE, "/user/claims/email", "test@example.com"); - OperationExecutionResult result = new OperationExecutionResult( - operation, OperationExecutionResult.Status.SUCCESS, "Claim updated"); - - assertEquals(result.getOperation(), operation); - assertEquals(result.getStatus(), OperationExecutionResult.Status.SUCCESS); - assertEquals(result.getMessage(), "Claim updated"); - } - - @Test - public void testFailureResult() { - - PerformableOperation operation = createOperation( - Operation.REPLACE, "/user/credentials/password", "newPass"); - OperationExecutionResult result = new OperationExecutionResult( - operation, OperationExecutionResult.Status.FAILURE, "Path not allowed"); - - assertEquals(result.getStatus(), OperationExecutionResult.Status.FAILURE); - assertEquals(result.getMessage(), "Path not allowed"); - assertNotNull(result.getOperation()); - } - -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java deleted file mode 100644 index e920a6035d1b..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/java/org/wso2/carbon/identity/flow/extensions/util/InFlowExtensionPathUtilTest.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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 org.wso2.carbon.identity.flow.extensions.util; - -import org.testng.annotations.DataProvider; -import org.testng.annotations.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertTrue; - -/** - * Unit tests for {@link InFlowExtensionPathUtil}. - */ -public class InFlowExtensionPathUtilTest { - - // ========================= isReadOnly ========================= - - @DataProvider(name = "readOnlyPaths") - public Object[][] readOnlyPaths() { - - return new Object[][] { - { "/flow/tenantDomain", true }, - { "/flow/applicationId", true }, - { "/flow/", true }, - { "/properties/riskScore", false }, - { "/user/claims/email", false }, - { null, false }, - }; - } - - @Test(dataProvider = "readOnlyPaths") - public void testIsReadOnly(String path, boolean expected) { - - assertEquals(InFlowExtensionPathUtil.isReadOnly(path), expected); - } - - // ========================= anyExposedUnder ========================= - - @Test - public void testAnyExposedUnderMatchesLeafUnderPrefix() { - - List leafPaths = Arrays.asList( - "/user/claims/http://wso2.org/claims/email", - "/properties/riskScore"); - assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); - assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); - } - - @Test - public void testAnyExposedUnderNoMatch() { - - List leafPaths = Arrays.asList("/flow/tenantDomain", "/flow/applicationId"); - assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); - assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); - } - - @Test - public void testAnyExposedUnderNullPrefix() { - - assertFalse(InFlowExtensionPathUtil.anyExposedUnder(null, - Arrays.asList("/user/claims/email"))); - } - - @Test - public void testAnyExposedUnderNullList() { - - assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", null)); - } - - @Test - public void testAnyExposedUnderEmptyList() { - - assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", - Collections.emptyList())); - } - - @Test - public void testAnyExposedUnderDoesNotMatchShortPath() { - - List leafPaths = Collections.singletonList("/user/userId"); - assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); - } - - @Test - public void testAnyExposedUnderMultipleLeafsOneMatches() { - - List leafPaths = Arrays.asList( - "/flow/tenantDomain", - "/user/credentials/password"); - assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/credentials/", leafPaths)); - } - - // ========================= isExposedPath ========================= - - @Test - public void testIsExposedPathExactMatch() { - - List leafPaths = Arrays.asList( - "/user/claims/http://wso2.org/claims/email", - "/flow/tenantDomain", - "/user/userId"); - assertTrue(InFlowExtensionPathUtil.isExposedPath( - "/user/claims/http://wso2.org/claims/email", leafPaths)); - assertTrue(InFlowExtensionPathUtil.isExposedPath("/flow/tenantDomain", leafPaths)); - assertTrue(InFlowExtensionPathUtil.isExposedPath("/user/userId", leafPaths)); - } - - @Test - public void testIsExposedPathNoMatch() { - - List leafPaths = Arrays.asList("/flow/tenantDomain", "/user/userId"); - assertFalse(InFlowExtensionPathUtil.isExposedPath( - "/user/claims/http://wso2.org/claims/email", leafPaths)); - } - - @Test - public void testIsExposedPathNullPath() { - - assertFalse(InFlowExtensionPathUtil.isExposedPath(null, - Arrays.asList("/user/userId"))); - } - - @Test - public void testIsExposedPathNullList() { - - assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", null)); - } - - @Test - public void testIsExposedPathEmptyList() { - - assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", - Collections.emptyList())); - } - - @Test - public void testIsExposedPathPrefixNotSufficient() { - - List leafPaths = Collections.singletonList("/user/claims/http://wso2.org/claims/email"); - assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/claims/", leafPaths)); - } -} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml deleted file mode 100755 index 17a93f2ff2ad..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/repository/conf/carbon.xml +++ /dev/null @@ -1,687 +0,0 @@ - - - - - - - - WSO2 Identity Server - - - IS - - - 5.3.0 - - - localhost - - - localhost - - - local:/${carbon.context}/services/ - - - - - - - IdentityServer - - - - - - - org.wso2.carbon - - - / - - - - - - - - - 15 - - - - - - - - - 0 - - - - - 9999 - - 11111 - - - - - - 10389 - - 8000 - - - - - - 10500 - - - - - - - - - org.wso2.carbon.tomcat.jndi.CarbonJavaURLContextFactory - - - - - - - - - - java - - - - - - - - - - false - - - false - - - 600 - - - - false - - - - - - - - 30 - - - - - - - - - 15 - - - - - - ${carbon.home}/repository/deployment/server/ - - - 15 - - - ${carbon.home}/repository/conf/axis2/axis2.xml - - - 30000 - - - ${carbon.home}/repository/deployment/client/ - - ${carbon.home}/repository/conf/axis2/axis2_client.xml - - true - - - - - - - - - - admin - Default Administrator Role - - - user - Default User Role - - - - - - - - - - - - ${carbon.home}/repository/resources/security/wso2carbon.jks - - JKS - - wso2carbon - - wso2carbon - - wso2carbon - - - - - - ${carbon.home}/repository/resources/security/client-truststore.jks - - JKS - - wso2carbon - - - - - - - - - - - - - - - - - - - UserManager - - - false - - org.wso2.carbon.identity.provider.AttributeCallbackHandler - - - org.wso2.carbon.identity.sts.store.DBTokenStore - - - true - allow - - - - - - - claim_mgt_menu - identity_mgt_emailtemplate_menu - identity_security_questions_menu - - - - ${carbon.home}/tmp/work - - - - - - true - - - 10 - - - 30 - - - - - - 100 - - - - keystore - certificate - * - - org.wso2.carbon.ui.transports.fileupload.AnyFileUploadExecutor - - - - - jarZip - - org.wso2.carbon.ui.transports.fileupload.JarZipUploadExecutor - - - - dbs - - org.wso2.carbon.ui.transports.fileupload.DBSFileUploadExecutor - - - - tools - - org.wso2.carbon.ui.transports.fileupload.ToolsFileUploadExecutor - - - - toolsAny - - org.wso2.carbon.ui.transports.fileupload.ToolsAnyFileUploadExecutor - - - - - - - - - - info - org.wso2.carbon.core.transports.util.InfoProcessor - - - wsdl - org.wso2.carbon.core.transports.util.Wsdl11Processor - - - wsdl2 - org.wso2.carbon.core.transports.util.Wsdl20Processor - - - xsd - org.wso2.carbon.core.transports.util.XsdProcessor - - - - - - false - false - true - svn - http://svnrepo.example.com/repos/ - username - password - true - - - - - - - - - - - - - - - ${require.carbon.servlet} - - - - - true - - - - - - - default repository - http://product-dist.wso2.com/p2/carbon/releases/wilkes/ - - - - - - - - true - - - - - - true - - diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml deleted file mode 100644 index dcba439c641d..000000000000 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions/src/test/resources/testng.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/components/flow-orchestration-framework/pom.xml b/components/flow-orchestration-framework/pom.xml index 1cc64f59b836..78079bf12046 100644 --- a/components/flow-orchestration-framework/pom.xml +++ b/components/flow-orchestration-framework/pom.xml @@ -37,7 +37,7 @@ org.wso2.carbon.identity.flow.mgt org.wso2.carbon.identity.flow.execution.engine - org.wso2.carbon.identity.flow.extensions + org.wso2.carbon.identity.flow.extension diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml deleted file mode 100644 index 4f84e1d51a60..000000000000 --- a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extensions.server.feature/pom.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - org.wso2.carbon.identity.framework - flow-orchestration-framework-feature - 7.11.94-SNAPSHOT - ../pom.xml - - - 4.0.0 - org.wso2.carbon.identity.flow.extensions.server.feature - pom - Flow InFlow Extensions Feature - https://wso2.com - This feature contains the bundles required for in-flow extension support in the flow engine. - - - - org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.extensions - - - - - - - org.wso2.maven - carbon-p2-plugin - ${carbon.p2.plugin.version} - - - 4-p2-feature-generation - package - - p2-feature-gen - - - org.wso2.carbon.identity.flow.extensions.server - ../../etc/feature.properties - - - org.wso2.carbon.p2.category.type:server - - - - - org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extensions - - - - - - - - - diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml index 95e531b7c45d..16d4a6052e86 100644 --- a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml +++ b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.orchestration.framework.feature/pom.xml @@ -45,7 +45,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.extensions.server.feature + org.wso2.carbon.identity.flow.extension.server.feature zip @@ -73,7 +73,7 @@ org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.execution.engine.server.feature - org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extensions.server.feature + org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extension.server.feature diff --git a/features/flow-orchestration-framework/pom.xml b/features/flow-orchestration-framework/pom.xml index 1ebf9f0ae13d..6b397286fb2a 100644 --- a/features/flow-orchestration-framework/pom.xml +++ b/features/flow-orchestration-framework/pom.xml @@ -35,7 +35,7 @@ org.wso2.carbon.identity.flow.mgt.server.feature org.wso2.carbon.identity.flow.orchestration.framework.feature org.wso2.carbon.identity.flow.execution.engine.server.feature - org.wso2.carbon.identity.flow.extensions.server.feature + org.wso2.carbon.identity.flow.extension.server.feature diff --git a/pom.xml b/pom.xml index f4111d633583..848a14b83ccb 100644 --- a/pom.xml +++ b/pom.xml @@ -856,7 +856,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.extensions.server.feature + org.wso2.carbon.identity.flow.extension.server.feature zip ${project.version} @@ -1949,7 +1949,7 @@ org.wso2.carbon.identity.framework - org.wso2.carbon.identity.flow.extensions + org.wso2.carbon.identity.flow.extension ${project.version} From 4444b23ca62ec14c7a764a60615c5dfbd2b19c13 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Sun, 24 May 2026 11:23:39 +0530 Subject: [PATCH 05/17] Rename module and action type from flow.extensions to flow.extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename component org.wso2.carbon.identity.flow.extensions → org.wso2.carbon.identity.flow.extension - Rename feature org.wso2.carbon.identity.flow.extensions.server.feature → org.wso2.carbon.identity.flow.extension.server.feature - Update all Java package declarations and imports accordingly - Change action type constant FLOW_EXTENSIONS → FLOW_EXTENSION --- .../pom.xml | 304 +++++ .../extension/InFlowExtensionConstants.java | 179 +++ .../executor/InFlowExtensionExecutor.java | 477 ++++++++ .../InFlowExtensionRequestBuilder.java | 800 +++++++++++++ .../InFlowExtensionResponseProcessor.java | 750 ++++++++++++ .../extension/executor/JWEEncryptionUtil.java | 262 +++++ .../executor/PathTypeAnnotationUtil.java | 418 +++++++ .../internal/InFlowExtensionDataHolder.java | 90 ++ .../InFlowExtensionServiceComponent.java | 179 +++ .../InFlowExtensionActionConverter.java | 211 ++++ ...InFlowExtensionActionDTOModelResolver.java | 604 ++++++++++ .../InFlowExtensionContextTreeBuilder.java | 295 +++++ .../InFlowExtensionContextTreeMetadata.java | 70 ++ .../InFlowExtensionContextTreeNode.java | 205 ++++ .../InFlowExtensionContextTreeService.java | 56 + .../flow/extension/model/AccessConfig.java | 153 +++ .../flow/extension/model/ContextPath.java | 66 ++ .../flow/extension/model/Encryption.java | 60 + .../model/FlowContextHandoverConfig.java | 118 ++ .../model/InFlowExtensionAction.java | 304 +++++ .../extension/model/InFlowExtensionEvent.java | 170 +++ .../extension/model/InFlowExtensionFlow.java | 91 ++ .../model/InFlowExtensionRequest.java | 34 + .../model/OperationExecutionResult.java | 62 + .../InFlowExtensionContextFilterUtil.java | 179 +++ .../util/InFlowExtensionPathUtil.java | 72 ++ .../extension/InFlowExtensionTestUtils.java | 62 + .../executor/InFlowExtensionExecutorTest.java | 616 ++++++++++ .../InFlowExtensionRequestBuilderTest.java | 1047 +++++++++++++++++ .../InFlowExtensionResponseProcessorTest.java | 999 ++++++++++++++++ .../executor/PathTypeAnnotationUtilTest.java | 618 ++++++++++ .../FlowContextHandoverConfigTestHelper.java | 40 + ...InFlowExtensionContextTreeBuilderTest.java | 433 +++++++ .../extension/model/AccessConfigTest.java | 205 ++++ .../model/InFlowExtensionEventTest.java | 116 ++ .../model/OperationExecutionResultTest.java | 68 ++ .../util/InFlowExtensionPathUtilTest.java | 163 +++ .../test/resources/repository/conf/carbon.xml | 687 +++++++++++ .../src/test/resources/testng.xml | 43 + .../pom.xml | 73 ++ 40 files changed, 11379 insertions(+) create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/pom.xml create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessor.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/JWEEncryptionUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionDataHolder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionConverter.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionDTOModelResolver.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilder.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeMetadata.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeNode.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeService.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/AccessConfig.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/ContextPath.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/Encryption.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowContextHandoverConfig.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionAction.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEvent.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionFlow.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionRequest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResult.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionContextFilterUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtil.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionTestUtils.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtilTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowContextHandoverConfigTestHelper.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilderTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/AccessConfigTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEventTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResultTest.java create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtilTest.java create mode 100755 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/repository/conf/carbon.xml create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/testng.xml create mode 100644 features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension.server.feature/pom.xml diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/pom.xml new file mode 100644 index 000000000000..e04b2b56188a --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/pom.xml @@ -0,0 +1,304 @@ + + + + + org.wso2.carbon.identity.framework + identity-framework + 7.11.94-SNAPSHOT + ../../../pom.xml + + + 4.0.0 + org.wso2.carbon.identity.flow.extension + bundle + WSO2 Carbon - Identity Flow In-Flow Extensions + WSO2 flow engine in-flow extensions + http://www.wso2.com + + + + org.wso2.carbon + org.wso2.carbon.core + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.base + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.core + + + org.ops4j.pax.logging + pax-logging-api + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.core + jackson-core + provided + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.mgt + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.execution.engine + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.action.execution + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.action.management + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.claim.metadata.mgt + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.central.log.mgt + + + org.testng + testng + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-testng + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.application.authentication.framework + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.testutil + test + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.user.action + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.certificate.management + + + org.wso2.orbit.com.nimbusds + nimbus-jose-jwt + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + ${project.artifactId} + ${project.artifactId} + + org.wso2.carbon.identity.flow.extension.internal, + org.wso2.carbon.identity.flow.extension.util + + + javax.xml.parsers; version="${javax.xml.parsers.import.pkg.version}", + org.xml.sax, + org.w3c.dom, + org.osgi.framework; version="${osgi.framework.imp.pkg.version.range}", + org.apache.axiom.om; version="${axiom.osgi.version.range}", + org.apache.xerces.util; resolution:=optional, + org.apache.commons.logging; version="${import.package.version.commons.logging}", + org.apache.commons.collections; version="${commons-collections.wso2.osgi.version.range}", + org.osgi.service.component; version="${osgi.service.component.imp.pkg.version.range}", + org.wso2.carbon.context; version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.user.core.*;version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.utils.multitenancy;version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.user.api; version="${carbon.user.api.imp.pkg.version.range}", + org.wso2.carbon.identity.base; version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.core.*; version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.application.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.claim.metadata.mgt.*; + version="${carbon.identity.package.import.version.range}", + javax.servlet.http; version="${imp.pkg.version.javax.servlet}", + org.wso2.carbon.identity.flow.execution.engine; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.graph; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.execution.engine.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.flow.mgt.exception; + version="${carbon.identity.package.import.version.range}", + com.nimbusds.jose.*; version="${nimbusds.osgi.version.range}", + com.nimbusds.jwt; version="${nimbusds.osgi.version.range}", + org.wso2.carbon.identity.user.action.api.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.core.util; version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.identity.action.execution.api.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.action.management.api.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.central.log.mgt.utils; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.exception; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.model; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.certificate.management.service; + version="${carbon.identity.package.import.version.range}", + com.fasterxml.jackson.core.*; version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.databind.*; + version="${com.fasterxml.jackson.annotation.version.range}", + com.fasterxml.jackson.annotation.*; + version="${com.fasterxml.jackson.annotation.version.range}", + org.wso2.carbon.utils; version="${carbon.kernel.package.import.version.range}", + org.slf4j; version="${org.slf4j.imp.pkg.version.range}" + + + !org.wso2.carbon.identity.flow.extension.internal, + org.wso2.carbon.identity.flow.extension, + org.wso2.carbon.identity.flow.extension.executor, + org.wso2.carbon.identity.flow.extension.model, + org.wso2.carbon.identity.flow.extension.management, + org.wso2.carbon.identity.flow.extension.metadata; + version="${carbon.identity.package.export.version}" + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + src/test/resources/testng.xml + + + ${project.build.testOutputDirectory} + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + true + + + + org.codehaus.mojo + findbugs-maven-plugin + + true + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + org/wso2/carbon/identity/flow/extensions/internal/** + + + + + default-prepare-agent + + prepare-agent + + + + default-prepare-agent-integration + + prepare-agent-integration + + + + default-report + + report + + + + default-report-integration + + report-integration + + + + default-check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.45 + + + + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 21 + + + + + + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java new file mode 100644 index 000000000000..3fe16c7f98eb --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Constants for the In-Flow Extension executor pipeline. + * + *

    Keys are shared across the executor, request builder, and response processor + * via the {@link org.wso2.carbon.identity.action.execution.api.model.FlowContext} + * handoff mechanism. Path prefixes drive operation routing in the response processor.

    + */ +public class InFlowExtensionConstants { + + private InFlowExtensionConstants() { + + } + + // ---- FlowContext pipeline keys ---- + public static final String FLOW_EXECUTION_CONTEXT_KEY = "flowExecutionContext"; + public static final String PATH_TYPE_ANNOTATIONS_KEY = "pathTypeAnnotations"; + public static final String MODIFY_PATHS_KEY = "modifyPaths"; + public static final String PENDING_CLAIMS_KEY = "pendingClaims"; + public static final String PENDING_CREDENTIALS_KEY = "pendingCredentials"; + public static final String PENDING_PROPERTIES_KEY = "pendingProperties"; + public static final String PENDING_REDIRECT_URL_KEY = "pendingRedirectUrl"; + + // ---- Response info keys (FAILED path) ---- + public static final String FAILURE_TYPE_KEY = "failureType"; + public static final String IN_FLOW_EXTENSION_FAILURE_TYPE = "IN_FLOW_EXTENSION_FAILURE"; + public static final String FAILURE_MESSAGE_KEY = "failureMessage"; + public static final String FAILURE_DESCRIPTION_KEY = "failureDescription"; + + // ---- Context path prefixes ---- + public static final String USER_PREFIX = "/user/"; + public static final String USER_ID_PATH = "/user/id"; + public static final String USER_STORE_DOMAIN_PATH = "/user/userStoreDomain"; + public static final String USER_CLAIMS_PATH_PREFIX = "/user/claims/"; + public static final String USER_CLAIMS_SELECTOR_PREFIX = "/user/claims[uri="; + public static final String USER_CLAIMS_SELECTOR_SUFFIX = "]"; + public static final String USER_CREDENTIALS_PATH_PREFIX = "/user/credentials/"; + public static final String PROPERTIES_PATH_PREFIX = "/properties/"; + public static final String FLOW_PREFIX = "/flow/"; + public static final String FLOW_TENANT_PATH = "/flow/tenantDomain"; + public static final String FLOW_APP_ID_PATH = "/flow/applicationId"; + public static final String FLOW_TYPE_PATH = "/flow/flowType"; + public static final String FLOW_CALLBACK_URL_PATH = "/flow/callbackUrl"; + public static final String FLOW_PORTAL_URL_PATH = "/flow/portalUrl"; + public static final String ORGANIZATION_PREFIX = "/organization/"; + public static final String ORGANIZATION_ID_PATH = "/organization/id"; + public static final String ORGANIZATION_NAME_PATH = "/organization/name"; + public static final String ORGANIZATION_HANDLE_PATH = "/organization/orgHandle"; + public static final String ORGANIZATION_DEPTH_PATH = "/organization/depth"; + + // ---- Miscellaneous ---- + public static final String ACTION_ID_METADATA_KEY = "actionId"; + + /** + * Constants for In-Flow Extension action management (action properties stored in + * IDN_ACTION_PROPERTIES, certificate naming, and expose-path limits). + */ + public static final class ActionManagement { + + public static final String ACCESS_CONFIG_EXPOSE = "ACCESS_CONFIG_EXPOSE"; + public static final String ACCESS_CONFIG_MODIFY = "ACCESS_CONFIG_MODIFY"; + public static final String OVERRIDE_KEY_SEPARATOR = ":"; + public static final String ACCESS_CONFIG_EXPOSE_PREFIX = ACCESS_CONFIG_EXPOSE + OVERRIDE_KEY_SEPARATOR; + public static final String ACCESS_CONFIG_MODIFY_PREFIX = ACCESS_CONFIG_MODIFY + OVERRIDE_KEY_SEPARATOR; + public static final int MAX_EXPOSE_PATHS = 50; + public static final String ICON_URL = "ICON_URL"; + public static final String CERTIFICATE = "CERTIFICATE"; + public static final String CERTIFICATE_NAME_PREFIX = "ACTIONS:"; + + private ActionManagement() { } + } + + /** + * Diagnostic log constants for the In-Flow Extension layer. + */ + public static final class Log { + + public static final String COMPONENT_ID = "inflow-extension"; + + private Log() { + + } + + /** + * Action IDs for diagnostic events emitted by the In-Flow Extension layer. + */ + public static final class ActionIDs { + + public static final String EXECUTE = "execute-inflow-extension"; + public static final String PROCESS_RESPONSE = "process-inflow-extension-response"; + + private ActionIDs() { + + } + } + } + + /** + * Compile-time default handover policy constants. + * + *

    These constants define which {@code FlowExecutionContext} and {@code FlowUser} + * fields are handed to the action framework during in-flow extension execution. + * {@code "properties"} is intentionally excluded from {@link #INCLUDED_ATTRIBUTES}: + * it is always modifiable via the executor response path (context tree always exposes + * it with MODIFY ops), but must not be forwarded to external services by default.

    + * + *

    When the toml-based dynamic config PR is merged, these constants serve as the + * documented defaults for {@code identity.xml.j2}.

    + */ + public static final class HandoverPolicy { + + private HandoverPolicy() { } + + /** Attribute name for the {@code flowUser} field. When present in + * {@link #INCLUDED_ATTRIBUTES}, {@code fullUserPassthrough} is set to true. */ + public static final String ATTR_FLOW_USER = "flowUser"; + + /** Context identifier; always copied by the filter regardless of config. */ + public static final String ATTR_CONTEXT_IDENTIFIER = "contextIdentifier"; + + /** User-credentials property name; requires per-entry {@code char[]} cloning. */ + public static final String ATTR_USER_CREDENTIALS = "userCredentials"; + + /** + * Top-level {@code FlowExecutionContext} fields that are handed to the action framework. + * Corresponds to the future toml key: + * {@code flow_execution_context.handover.filtering.included_attributes}. + */ + public static final Set INCLUDED_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "contextIdentifier", + "tenantDomain", + "applicationId", + "flowType", + "callbackUrl", + "portalUrl", + "flowUser" // presence sets fullUserPassthrough = true + // "properties" intentionally excluded — sensitive flow-state data + ))); + + /** + * {@code FlowUser} fields that are handed over when full-passthrough is not active. + * Corresponds to the future toml key: + * {@code flow_execution_context.handover.filtering.included_user_attributes}. + */ + public static final Set INCLUDED_USER_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "id", + "username", + "userStoreDomain", + "claims", + "userCredentials" + ))); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java new file mode 100644 index 000000000000..0a76dc1b8fde --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; +import org.wso2.carbon.utils.DiagnosticLog; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; +import org.wso2.carbon.identity.flow.execution.engine.util.FlowExecutionEngineUtils; +import org.wso2.carbon.identity.flow.extension.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extension.util.InFlowExtensionContextFilterUtil; +import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; +import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Executes In-Flow Extension actions during flow execution by delegating to + * {@link ActionExecutorService} and mapping the result to an {@link ExecutorResponse}. + * On success, pending context updates (claims, credentials, properties) are forwarded + * to the flow engine through the response object. + */ +public class InFlowExtensionExecutor implements Executor { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class); + private static final String EXECUTOR_NAME = "InFlowExtensionExecutor"; + private static final String CONFIG_PARAM_ACTION_TYPE = "actionType"; + private static final String CONFIG_PARAM_ACTION_ID = "actionId"; + + + + @Override + public String getName() { + + return EXECUTOR_NAME; + } + + @Override + public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineException { + + String actionId = getMetadataValue(context, InFlowExtensionConstants.ACTION_ID_METADATA_KEY); + if (actionId == null || actionId.isEmpty()) { + triggerDiagnosticFailure(null, + "In-Flow Extension action execution failed: action ID is not configured."); + return buildErrorResponse("Extension is not configured.", + "The In-Flow Extension action is missing required configuration. " + + "Contact your administrator."); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Executing In-Flow Extension action. actionId: " + actionId + + ", flowType: " + context.getFlowType() + + ", tenant: " + context.getTenantDomain()); + } + + ActionExecutorService actionExecutorService = getActionExecutorService(); + if (actionExecutorService == null) { + triggerDiagnosticFailure(actionId, + "In-Flow Extension action execution failed: ActionExecutorService is unavailable."); + throw FlowExecutionEngineUtils.handleServerException( + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR, + "ActionExecutorService is not available. actionId: " + actionId); + } + + if (!actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)) { + triggerDiagnosticFailure(actionId, + "In-Flow Extension action execution failed: action type is disabled."); + return buildErrorResponse("Extension execution is disabled.", + "The In-Flow Extension action type is currently disabled on this server."); + } + + try { + // Hand the action framework only a FILTERED copy of the FlowExecutionContext + // (non-whitelisted fields nulled out). Policy is sourced from compile-time constants. + FlowExecutionContext filteredContext = InFlowExtensionContextFilterUtil.filter( + context, FlowContextHandoverConfig.defaultPolicy()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, filteredContext); + + ActionExecutionStatus executionStatus = actionExecutorService.execute( + ActionType.FLOW_EXTENSION, actionId, flowContext, context.getTenantDomain()); + + ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context); + + // On success, extract pending context updates collected by the response processor + // and forward them to TaskExecutionNode via ExecutorResponse fields. + if (ExecutorStatus.STATUS_COMPLETE.equals(executionResponse.getResult())) { + applyPendingContextUpdates(executionResponse, flowContext, actionId); + } + + if (ExecutorStatus.STATUS_RETRY.equals(executionResponse.getResult())) { + applyRetryMetadata(executionResponse, actionId); + } + + return executionResponse; + + } catch (ActionExecutionException e) { + logActionExecutionException(e, actionId); + triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: " + e.getMessage()); + return buildErrorResponse("An error occurred while processing the extension. Please try again.", + "The external extension service could not complete the request. " + + "If the problem persists, contact your administrator."); + } + } + + @Override + public List getInitiationData() { + + return Collections.emptyList(); + } + + @Override + public ExecutorResponse rollback(FlowExecutionContext context) { + + return null; + } + + /** + * Map the {@link ActionExecutionStatus} to an {@link ExecutorResponse}. + * Performs status translation and (for INCOMPLETE/redirect) generates the OTFI used by + * {@code FlowExecutionService} to swap caches on resume — same pattern as {@code MagicLinkExecutor}. + * + * @param executionStatus The status returned by ActionExecutorService. + * @param flowContext The action {@link FlowContext} where the response processor stashed the redirect URL. + * @param context The engine {@link FlowExecutionContext} (used for OTFI collision-guard). + * @return The ExecutorResponse for the flow execution engine. + */ + private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionStatus, + FlowContext flowContext, FlowExecutionContext context) { + + ExecutorResponse response = new ExecutorResponse(); + + if (executionStatus == null) { + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage("Extension did not return a response."); + response.setErrorDescription("The In-Flow Extension action did not return a status. Please try again."); + return response; + } + + switch (executionStatus.getStatus()) { + case SUCCESS: + response.setResult(ExecutorStatus.STATUS_COMPLETE); + return response; + + case FAILED: + handleFailedStatus(response, executionStatus); + return response; + + case ERROR: + handleErrorStatus(response, executionStatus); + return response; + + case INCOMPLETE: + return handleIncompleteExecutionStatus(response, flowContext, context); + + default: + return handleUnknownExecutionStatus(response, executionStatus); + } + } + + /** + * Build a user-facing error message from the failure details returned by the external service. + * Prefers the failureDescription (human-readable). Falls back to failureReason if description is absent. + * + * @param failure The failure object from the external service. + * @return A display-ready error message string. + */ + private String buildUserFacingErrorMessage(Failure failure) { + + String description = failure.getFailureDescription(); + String reason = failure.getFailureReason(); + + if (description != null && !description.isEmpty()) { + return description; + } + if (reason != null && !reason.isEmpty()) { + return reason; + } + return "The operation could not be completed due to an external service failure."; + } + + private ExecutorResponse buildErrorResponse(String errorMessage, String errorDescription) { + + ExecutorResponse response = new ExecutorResponse(); + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage(errorMessage); + response.setErrorDescription(errorDescription); + return response; + } + + private void applyRetryMetadata(ExecutorResponse response, String actionId) { + + Map additionalInfo = response.getAdditionalInfo(); + if (additionalInfo == null) { + additionalInfo = new HashMap<>(); + } + additionalInfo.put(InFlowExtensionConstants.FAILURE_TYPE_KEY, + InFlowExtensionConstants.IN_FLOW_EXTENSION_FAILURE_TYPE); + response.setAdditionalInfo(additionalInfo); + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension action returned FAILED. actionId: " + actionId + + ", reason: " + additionalInfo.get(InFlowExtensionConstants.FAILURE_MESSAGE_KEY)); + } + } + + private void handleFailedStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { + + response.setResult(ExecutorStatus.STATUS_RETRY); + Failure failure = (Failure) executionStatus.getResponse(); + if (failure == null) { + return; + } + + Map failureInfo = new HashMap<>(); + if (failure.getFailureReason() != null) { + failureInfo.put(InFlowExtensionConstants.FAILURE_MESSAGE_KEY, failure.getFailureReason()); + } + if (failure.getFailureDescription() != null) { + failureInfo.put(InFlowExtensionConstants.FAILURE_DESCRIPTION_KEY, failure.getFailureDescription()); + } + response.setAdditionalInfo(failureInfo); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_FAILURE.getCode()); + response.setErrorMessage(buildUserFacingErrorMessage(failure)); + } + + private void handleErrorStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { + + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + Error error = (Error) executionStatus.getResponse(); + if (error == null) { + return; + } + + response.setErrorMessage(stripI18nBraces(error.getErrorMessage())); + response.setErrorDescription(stripI18nBraces(error.getErrorDescription())); + } + + private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse response, FlowContext flowContext, + FlowExecutionContext context) { + + String redirectUrl = flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class); + if (redirectUrl == null || redirectUrl.isEmpty()) { + // Defensive: response processor should have rejected this earlier. + LOG.debug("In-Flow Extension returned INCOMPLETE without a redirect URL."); + triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "In-Flow Extension returned INCOMPLETE without a redirect URL."); + return buildErrorResponse("Extension returned INCOMPLETE without a redirect URL.", + "The external extension returned an incomplete response. Please try again."); + } + + String otfi = generateUniqueOtfi(context.getContextIdentifier()); + Map redirectProps = new HashMap<>(); + redirectProps.put(Constants.OTFI, otfi); + response.setContextProperty(redirectProps); + + String urlWithFlowId = appendFlowId(redirectUrl, otfi); + Map redirectInfo = new HashMap<>(); + redirectInfo.put(Constants.REDIRECT_URL, urlWithFlowId); + response.setAdditionalInfo(redirectInfo); + + response.setResult(ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + triggerDiagnosticSuccess(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "In-Flow Extension returned INCOMPLETE with a redirect URL."); + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension returned INCOMPLETE. Redirect initiated and flowId (OTFI) generated."); + } + + return response; + } + + private ExecutorResponse handleUnknownExecutionStatus(ExecutorResponse response, + ActionExecutionStatus executionStatus) { + + LOG.warn("Unknown execution status: " + executionStatus.getStatus()); + response.setResult(ExecutorStatus.STATUS_ERROR); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + response.setErrorMessage("Extension returned an unexpected response."); + response.setErrorDescription("The In-Flow Extension returned an unrecognised status. Please try again."); + return response; + } + + private String generateUniqueOtfi(String currentContextIdentifier) { + + // Avoid accidental collision with the current context identifier. + String otfi = UUID.randomUUID().toString(); + while (otfi.equals(currentContextIdentifier)) { + otfi = UUID.randomUUID().toString(); + } + return otfi; + } + + private String appendFlowId(String redirectUrl, String otfi) { + + String separator = redirectUrl.contains("?") ? "&" : "?"; + return redirectUrl + separator + "flowId=" + otfi; + } + + @SuppressWarnings("unchecked") + private void applyPendingContextUpdates(ExecutorResponse response, FlowContext flowContext, String actionId) { + + Map pendingClaims = + flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + if (pendingClaims != null && !pendingClaims.isEmpty()) { + response.setUpdatedUserClaims(pendingClaims); + } + + Map pendingCredentials = + flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class); + if (pendingCredentials != null && !pendingCredentials.isEmpty()) { + response.setUserCredentials(pendingCredentials); + } + + Map pendingProperties = + flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + if (pendingProperties != null && !pendingProperties.isEmpty()) { + response.setContextProperty(pendingProperties); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension action succeeded. actionId: " + actionId + + ", pendingClaims: " + (pendingClaims != null ? pendingClaims.size() : 0) + + ", pendingCredentials: " + (pendingCredentials != null ? pendingCredentials.size() : 0) + + ", pendingProperties: " + (pendingProperties != null ? pendingProperties.size() : 0)); + } + } + + private void triggerDiagnosticFailure(String actionId, String resultMessage) { + + triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.EXECUTE, actionId, resultMessage); + } + + private void triggerDiagnosticFailure(String diagnosticActionId, String actionId, String resultMessage) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) + .resultMessage(resultMessage) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED); + + if (actionId != null) { + builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); + } + + LoggerUtils.triggerDiagnosticLogEvent(builder); + } + + private void triggerDiagnosticSuccess(String diagnosticActionId, String actionId, String resultMessage) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, diagnosticActionId) + .resultMessage(resultMessage) + .configParam(CONFIG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); + + if (actionId != null) { + builder.configParam(CONFIG_PARAM_ACTION_ID, actionId); + } + + LoggerUtils.triggerDiagnosticLogEvent(builder); + } + + private ActionExecutorService getActionExecutorService() { + + return InFlowExtensionDataHolder.getInstance().getActionExecutorService(); + } + + /** + * Log an {@link ActionExecutionException} at the appropriate level based on its root cause. + * Config and contract violations are logged at WARN; infrastructure and unexpected failures at ERROR. + */ + private void logActionExecutionException(ActionExecutionException e, String actionId) { + + Throwable cause = e.getCause(); + if (cause instanceof ActionExecutionRequestBuilderException) { + LOG.warn("In-Flow Extension action '" + actionId + + "' request build failed. Check action access configuration: " + e.getMessage()); + } else if (cause instanceof ActionExecutionResponseProcessorException) { + LOG.error("In-Flow Extension action '" + actionId + + "' response processing failed (extension contract violation or internal error).", e); + } else { + LOG.error("Error executing In-Flow Extension action '" + actionId + "'.", e); + } + } + + /** + * Strip the {@code {{...}}} wrapper from an i18n key so the JSP error page can resolve it + * via {@code AuthenticationEndpointUtil.i18n(resourceBundle, key)}. Raw text values (without + * the wrapper) and {@code null} are returned unchanged. + */ + private static String stripI18nBraces(String value) { + + if (value == null) { + return null; + } + if (value.startsWith("{{") && value.endsWith("}}") && value.length() > 4) { + return value.substring(2, value.length() - 2); + } + return value; + } + + /** + * Read a single metadata value from the current node's executor configuration. + * + * @param context The FlowExecutionContext. + * @param key The metadata key. + * @return The value, or {@code null} if not found. + */ + private String getMetadataValue(FlowExecutionContext context, String key) { + + NodeConfig currentNode = context.getCurrentNode(); + if (currentNode == null) { + return null; + } + ExecutorDTO executorConfig = currentNode.getExecutorConfig(); + if (executorConfig == null) { + return null; + } + Map metadata = executorConfig.getMetadata(); + if (metadata == null || metadata.isEmpty()) { + return null; + } + return metadata.get(key); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java new file mode 100644 index 000000000000..d337fc0d4afa --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java @@ -0,0 +1,800 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.extension.model.*; +import org.wso2.carbon.utils.DiagnosticLog; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; +import org.wso2.carbon.identity.action.execution.api.model.Application; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.Organization; +import org.wso2.carbon.identity.action.execution.api.model.Tenant; +import org.wso2.carbon.identity.action.execution.api.model.User; +import org.wso2.carbon.identity.action.execution.api.model.UserClaim; +import org.wso2.carbon.identity.action.execution.api.model.UserStore; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.core.context.IdentityContext; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.util.InFlowExtensionPathUtil; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class is responsible for building the {@link ActionExecutionRequest} for In-Flow Extension + * actions. + * + *

    Responsibility: expose-based filtering and request construction. + * It receives a {@link FlowContext} containing the full {@link FlowExecutionContext}, the expose + * list, and the access config. It filters the {@link FlowExecutionContext} data according + * to the expose configuration and maps the result into the {@link InFlowExtensionEvent} model. + * Modify paths from the access config are converted to a single REPLACE {@link AllowedOperation}.

    + */ +public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuilder { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionRequestBuilder.class); + private static final int MAX_DIAGNOSTIC_PATHS = 20; + private static final String DIAGNOSTIC_REASON = "reason"; + private static final String REASON_UNSUPPORTED_ACTION = "unsupported-action-model"; + private static final String REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS = "omitted-encrypted-expose-paths"; + + @Override + public ActionType getSupportedActionType() { + + return ActionType.FLOW_EXTENSION; + } + + @Override + public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContext, + ActionExecutionRequestContext actionExecutionContext) + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = getFlowExecutionContextOrThrow(flowContext); + ResolvedActionConfig resolvedActionConfig = resolveActionConfig(actionExecutionContext, execCtx.getFlowType()); + if (resolvedActionConfig.isFallback()) { + return buildFallbackRequest(flowContext, execCtx); + } + + AccessConfig accessConfig = resolvedActionConfig.getAccessConfig(); + Encryption encryption = resolvedActionConfig.getEncryption(); + + List exposePaths = resolveExposePaths(accessConfig); + List modifyPaths = resolveModifyPaths(accessConfig); + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); + + List allowedOperations = buildAllowedOperations(accessConfig, flowContext); + String certificatePEM = resolveOutboundCertificate(accessConfig, encryption); + ExposeResolution exposeResolution = pruneEncryptedExposePathsWithoutCertificate( + exposePaths, accessConfig, certificatePEM); + + triggerRequestBuildDiagnostic(execCtx, exposeResolution.getEffectiveExposePaths(), + modifyPaths, encryption, exposeResolution.getOmittedEncryptedExposePaths()); + + InFlowExtensionEvent event = buildEvent(execCtx, exposeResolution.getEffectiveExposePaths(), + accessConfig, certificatePEM); + return buildRequestPayload(event, allowedOperations); + } + + /** + * Build the allowed operations advertised to the external extension service. Always + * includes a REDIRECT operation so the extension may signal external redirection. A + * REPLACE operation is added only when the access config defines modify paths. Path + * type annotations (e.g. {@code []} or {@code [schema]}) are stripped and stored in + * the {@link FlowContext} under {@link InFlowExtensionConstants#PATH_TYPE_ANNOTATIONS_KEY} + * for the response processor. + * + * @param accessConfig The access config containing modify paths (may be null). + * @param flowContext The FlowContext to store path annotations. + * @return List of allowed operations (REPLACE if applicable, plus REDIRECT). + */ + private List buildAllowedOperations(AccessConfig accessConfig, + FlowContext flowContext) { + + List allowedOps = new ArrayList<>(); + + if (hasModifyPaths(accessConfig)) { + AllowedModifyExtraction extraction = extractAllowedModifyPaths(accessConfig.getModify()); + addReplaceOperationIfAny(allowedOps, extraction.getCleanPaths()); + storeAnnotationsIfAny(flowContext, extraction.getPathTypeAnnotations()); + + if (LOG.isDebugEnabled() && !extraction.getSkippedPaths().isEmpty()) { + LOG.debug("Skipped " + extraction.getSkippedPaths().size() + + " modify path(s) due to invalid or missing path definitions."); + } + } + + addRedirectOperation(allowedOps); + + return allowedOps; + } + + /** + * Build the {@link InFlowExtensionEvent} from the {@link FlowExecutionContext}, + * filtering data according to the expose configuration. + * Values for expose paths with {@code encrypted: true} are JWE-encrypted using the + * external service's certificate before being included in the event. + * + * @param context The FlowExecutionContext (full source of truth). + * @param expose The expose prefix list controlling which data is included. + * @param accessConfig The access config (may be null if no encryption). + * @param certificatePEM The external service's certificate PEM for JWE encryption (may be null). + * @return The InFlowExtensionEvent. + */ + private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + InFlowExtensionEvent.Builder eventBuilder = new InFlowExtensionEvent.Builder(); + InFlowExtensionFlow.Builder flowBuilder = new InFlowExtensionFlow.Builder(); + + applyTenant(eventBuilder, context, expose); + applyOrganization(eventBuilder, expose); + applyApplication(eventBuilder, context, expose); + applyUserAndUserStore(flowBuilder, context, expose, accessConfig, certificatePEM); + applyFlowMetadata(flowBuilder, eventBuilder, context, expose); + applyFlowProperties(eventBuilder, context, expose, accessConfig, certificatePEM); + + eventBuilder.flow(flowBuilder.build()); + return eventBuilder.build(); + } + + /** + * Build the {@link User} model from {@link FlowUser}, filtering by expose config. + * Encrypts credential and claim values for expose paths marked as encrypted. + * + * @param flowUser The FlowUser from the FlowExecutionContext. + * @param expose The expose prefix list. + * @param accessConfig The access config with encryption flags (may be null). + * @param certificatePEM The certificate PEM for JWE encryption (may be null). + * @return The filtered User model with encrypted values where configured. + */ + private User buildUser(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + User.Builder userBuilder = new User.Builder(resolveUserId(flowUser, expose)); + List userClaims = buildFilteredClaims(flowUser, expose, accessConfig, certificatePEM); + if (!userClaims.isEmpty()) { + userBuilder.claims(userClaims); + } + + Map filteredCredentials = + buildFilteredCredentials(flowUser, expose, accessConfig, certificatePEM); + if (!filteredCredentials.isEmpty()) { + userBuilder.userCredentials(filteredCredentials); + } + + if (isLeafExposed(InFlowExtensionConstants.USER_STORE_DOMAIN_PATH, expose)) { + String userStoreDomain = flowUser.getUserStoreDomain(); + userBuilder.userStoreDomain(new UserStore(userStoreDomain != null ? userStoreDomain : "")); + } + + return userBuilder.build(); + } + + private FlowExecutionContext getFlowExecutionContextOrThrow(FlowContext flowContext) + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = flowContext.getValue( + InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); + if (execCtx == null) { + throw new ActionExecutionRequestBuilderException("FlowExecutionContext not found in FlowContext."); + } + return execCtx; + } + + private ResolvedActionConfig resolveActionConfig(ActionExecutionRequestContext actionExecutionContext, + String flowType) { + + Action rawAction = actionExecutionContext.getAction(); + if (rawAction instanceof InFlowExtensionAction) { + InFlowExtensionAction ext = (InFlowExtensionAction) rawAction; + return new ResolvedActionConfig(ext.resolveAccessConfig(flowType), ext.getEncryption(), false); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("No InFlowExtensionAction resolved. Falling back to an empty request body."); + } + return new ResolvedActionConfig(null, null, true); + } + + private List resolveExposePaths(AccessConfig accessConfig) { + + if (accessConfig == null || accessConfig.getExpose() == null) { + return Collections.emptyList(); + } + return accessConfig.getExposePaths(); + } + + private List resolveModifyPaths(AccessConfig accessConfig) { + + if (accessConfig == null || accessConfig.getModify() == null) { + return Collections.emptyList(); + } + return accessConfig.getModify(); + } + + private String resolveOutboundCertificate(AccessConfig accessConfig, Encryption encryption) { + + if (accessConfig == null || encryption == null || encryption.getCertificate() == null) { + return null; + } + return encryption.getCertificate().getCertificateContent(); + } + + private ExposeResolution pruneEncryptedExposePathsWithoutCertificate(List exposePaths, + AccessConfig accessConfig, + String certificatePEM) { + + if (certificatePEM != null || accessConfig == null || exposePaths.isEmpty()) { + return new ExposeResolution(exposePaths, Collections.emptyList()); + } + + List effectiveExposePaths = new ArrayList<>(); + List omittedEncryptedExposePaths = new ArrayList<>(); + for (String exposePath : exposePaths) { + if (accessConfig.isExposePathEncrypted(exposePath)) { + omittedEncryptedExposePaths.add(exposePath); + } else { + effectiveExposePaths.add(exposePath); + } + } + + if (!omittedEncryptedExposePaths.isEmpty()) { + LOG.warn("Outbound certificate is not configured. Omitted " + omittedEncryptedExposePaths.size() + + " encrypted expose path(s)."); + triggerOmittedExposeDiagnostic(omittedEncryptedExposePaths); + } + + return new ExposeResolution(effectiveExposePaths, omittedEncryptedExposePaths); + } + + private ActionExecutionRequest buildFallbackRequest(FlowContext flowContext, FlowExecutionContext execCtx) { + + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, Collections.emptyList()); + List allowedOperations = buildAllowedOperations(null, flowContext); + triggerFallbackDiagnostic(execCtx); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flow(new InFlowExtensionFlow.Builder() + .flowId(execCtx.getContextIdentifier()) + .build()) + .build(); + return buildRequestPayload(event, allowedOperations); + } + + private ActionExecutionRequest buildRequestPayload(InFlowExtensionEvent event, + List allowedOperations) { + + return new ActionExecutionRequest.Builder() + .actionType(ActionType.FLOW_EXTENSION) + .event(event) + .allowedOperations(allowedOperations) + .build(); + } + + private void triggerRequestBuildDiagnostic(FlowExecutionContext execCtx, List exposePaths, + List modifyPaths, Encryption encryption, + List omittedEncryptedExposePaths) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("Building request for In-Flow Extension action.") + .configParam("actionType", ActionType.FLOW_EXTENSION.getDisplayName()) + .configParam("flowType", execCtx.getFlowType()) + .configParam("exposePaths", exposePaths.size()) + .configParam("modifyPaths", modifyPaths.size()) + .configParam("outboundEncryption", encryption != null) + .inputParam("exposePathKeys", limitForDiagnostic(exposePaths)) + .inputParam("modifyPathKeys", limitForDiagnostic(extractModifyPathKeys(modifyPaths))) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS); + + if (!omittedEncryptedExposePaths.isEmpty()) { + diagnosticLogBuilder + .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) + .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)); + } + + LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); + } + + private List extractModifyPathKeys(List modifyPaths) { + + List modifyPathKeys = new ArrayList<>(); + for (ContextPath modifyPath : modifyPaths) { + if (modifyPath != null && modifyPath.getPath() != null) { + modifyPathKeys.add(modifyPath.getPath()); + } + } + return modifyPathKeys; + } + + private List limitForDiagnostic(List values) { + + if (values.size() <= MAX_DIAGNOSTIC_PATHS) { + return values; + } + return new ArrayList<>(values.subList(0, MAX_DIAGNOSTIC_PATHS)); + } + + private void triggerFallbackDiagnostic(FlowExecutionContext execCtx) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("No InFlowExtensionAction resolved. Built minimal fallback request.") + .configParam("actionType", ActionType.FLOW_EXTENSION.getDisplayName()) + .configParam("flowType", execCtx.getFlowType()) + .configParam(DIAGNOSTIC_REASON, REASON_UNSUPPORTED_ACTION) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + + private void triggerOmittedExposeDiagnostic(List omittedEncryptedExposePaths) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_REQUEST) + .resultMessage("Omitted encrypted expose paths because outbound certificate is not configured.") + .configParam("actionType", ActionType.FLOW_EXTENSION.getDisplayName()) + .configParam(DIAGNOSTIC_REASON, REASON_OMITTED_ENCRYPTED_EXPOSE_PATHS) + .inputParam("omittedEncryptedExposePaths", limitForDiagnostic(omittedEncryptedExposePaths)) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + + private boolean hasModifyPaths(AccessConfig accessConfig) { + + return accessConfig != null && accessConfig.getModify() != null && !accessConfig.getModify().isEmpty(); + } + + private AllowedModifyExtraction extractAllowedModifyPaths(List modifyPaths) { + + List cleanPaths = new ArrayList<>(); + Map pathTypeAnnotations = new HashMap<>(); + List skippedPaths = new ArrayList<>(); + + for (ContextPath modifyPath : modifyPaths) { + String rawPath = modifyPath == null ? null : modifyPath.getPath(); + if (rawPath == null) { + skippedPaths.add(""); + } else { + String[] strippedPath = PathTypeAnnotationUtil.stripAnnotation(rawPath); + String cleanPath = strippedPath[0]; + String annotation = strippedPath[1]; + + boolean isAnnotationValid = annotation == null || PathTypeAnnotationUtil + .validateAnnotationLimits(annotation); + if (isAnnotationValid) { + if (annotation != null) { + pathTypeAnnotations.put(cleanPath, annotation); + } + cleanPaths.add(toExternalPath(cleanPath)); + } else { + LOG.warn("Annotation for path " + cleanPath + + " exceeds maximum attribute limit. Skipping path."); + skippedPaths.add(cleanPath); + } + } + } + + return new AllowedModifyExtraction(cleanPaths, pathTypeAnnotations, skippedPaths); + } + + private void addReplaceOperationIfAny(List allowedOperations, List cleanPaths) { + + if (cleanPaths.isEmpty()) { + return; + } + + AllowedOperation replaceOp = new AllowedOperation(); + replaceOp.setOp(Operation.REPLACE); + replaceOp.setPaths(cleanPaths); + allowedOperations.add(replaceOp); + } + + private void storeAnnotationsIfAny(FlowContext flowContext, Map pathTypeAnnotations) { + + if (!pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + } + + private void addRedirectOperation(List allowedOperations) { + + AllowedOperation redirectOp = new AllowedOperation(); + redirectOp.setOp(Operation.REDIRECT); + allowedOperations.add(redirectOp); + } + + private void applyTenant(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(InFlowExtensionConstants.FLOW_TENANT_PATH, expose)) { + return; + } + + String tenantDomain = context.getTenantDomain(); + if (tenantDomain != null) { + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + eventBuilder.tenant(new Tenant(String.valueOf(tenantId), tenantDomain)); + } else { + eventBuilder.tenant(new Tenant("", "")); + } + } + + private void applyOrganization(InFlowExtensionEvent.Builder eventBuilder, List expose) { + + if (!isAreaExposed(InFlowExtensionConstants.ORGANIZATION_PREFIX, expose)) { + return; + } + + org.wso2.carbon.identity.core.context.model.Organization coreOrg = + IdentityContext.getThreadLocalIdentityContext().getOrganization(); + if (coreOrg == null) { + return; + } + + Organization.Builder orgBuilder = new Organization.Builder(); + + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_ID_PATH, expose)) { + orgBuilder.id(coreOrg.getId()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_NAME_PATH, expose)) { + orgBuilder.name(coreOrg.getName()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_HANDLE_PATH, expose)) { + orgBuilder.orgHandle(coreOrg.getOrganizationHandle()); + } + if (isLeafExposed(InFlowExtensionConstants.ORGANIZATION_DEPTH_PATH, expose)) { + orgBuilder.depth(coreOrg.getDepth()); + } + + eventBuilder.organization(orgBuilder.build()); + } + + private void applyApplication(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(InFlowExtensionConstants.FLOW_APP_ID_PATH, expose)) { + return; + } + + String appId = context.getApplicationId(); + eventBuilder.application(new Application(appId != null ? appId : "", null)); + } + + private void applyUserAndUserStore(InFlowExtensionFlow.Builder flowBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(InFlowExtensionConstants.USER_PREFIX, expose)) { + return; + } + + FlowUser flowUser = context.getFlowUser(); + if (flowUser == null) { + return; + } + + flowBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); + } + + private void applyFlowMetadata(InFlowExtensionFlow.Builder flowBuilder, + InFlowExtensionEvent.Builder eventBuilder, + FlowExecutionContext context, List expose) { + + if (isLeafExposed(InFlowExtensionConstants.FLOW_TYPE_PATH, expose)) { + flowBuilder.flowType(context.getFlowType() != null ? context.getFlowType() : ""); + } + + flowBuilder.flowId(context.getContextIdentifier()); + + if (isLeafExposed(InFlowExtensionConstants.FLOW_CALLBACK_URL_PATH, expose)) { + eventBuilder.callbackUrl(context.getCallbackUrl() != null ? context.getCallbackUrl() : ""); + } + + if (isLeafExposed(InFlowExtensionConstants.FLOW_PORTAL_URL_PATH, expose)) { + eventBuilder.portalUrl(context.getPortalUrl() != null ? context.getPortalUrl() : ""); + } + } + + private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX, expose)) { + return; + } + + Map properties = context.getProperties(); + Map filteredProperties = new HashMap<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { + continue; + } + String propKey = exposePath.substring(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX.length()); + Object value = properties != null ? properties.get(propKey) : null; + if (value != null && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { + value = encryptValue(String.valueOf(value), certificatePEM); + } + filteredProperties.put(propKey, value != null ? value : ""); + } + + eventBuilder.flowProperties(filteredProperties); + } + + private String resolveUserId(FlowUser flowUser, List expose) { + + if (isLeafExposed(InFlowExtensionConstants.USER_ID_PATH, expose)) { + String userId = flowUser.getId(); + return userId != null ? userId : ""; + } + return null; + } + + private List buildFilteredClaims(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX, expose)) { + return Collections.emptyList(); + } + + Map claims = flowUser.getClaims(); + List userClaims = new ArrayList<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + continue; + } + String claimKey = exposePath.substring(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); + String claimValue = claims != null ? claims.get(claimKey) : null; + claimValue = claimValue != null ? claimValue : ""; + if (!claimValue.isEmpty() && shouldEncrypt(exposePath, accessConfig, certificatePEM)) { + claimValue = encryptValue(claimValue, certificatePEM); + } + userClaims.add(new UserClaim(claimKey, claimValue)); + } + return userClaims; + } + + private Map buildFilteredCredentials(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX, expose)) { + return Collections.emptyMap(); + } + + Map credentials = flowUser.getUserCredentials(); + Map filteredCredentials = new HashMap<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { + continue; + } + String credKey = exposePath.substring(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX.length()); + char[] credValue = credentials != null ? credentials.get(credKey) : null; + + if (credValue != null) { + String plaintext = new String(credValue); + java.util.Arrays.fill(credValue, '\0'); + filteredCredentials.put(credKey, + toEncryptedOrPlainCredentialChars(plaintext, exposePath, accessConfig, certificatePEM)); + } else { + filteredCredentials.put(credKey, new char[0]); + } + } + return filteredCredentials; + } + + private char[] toEncryptedOrPlainCredentialChars(String plaintext, String credentialPath, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (shouldEncrypt(credentialPath, accessConfig, certificatePEM)) { + return encryptValue(plaintext, certificatePEM).toCharArray(); + } + return plaintext.toCharArray(); + } + + private static class ResolvedActionConfig { + + private final AccessConfig accessConfig; + private final Encryption encryption; + private final boolean fallback; + + private ResolvedActionConfig(AccessConfig accessConfig, Encryption encryption, boolean fallback) { + + this.accessConfig = accessConfig; + this.encryption = encryption; + this.fallback = fallback; + } + + private AccessConfig getAccessConfig() { + + return accessConfig; + } + + private Encryption getEncryption() { + + return encryption; + } + + private boolean isFallback() { + + return fallback; + } + } + + private static class ExposeResolution { + + private final List effectiveExposePaths; + private final List omittedEncryptedExposePaths; + + private ExposeResolution(List effectiveExposePaths, List omittedEncryptedExposePaths) { + + this.effectiveExposePaths = effectiveExposePaths; + this.omittedEncryptedExposePaths = omittedEncryptedExposePaths; + } + + private List getEffectiveExposePaths() { + + return effectiveExposePaths; + } + + private List getOmittedEncryptedExposePaths() { + + return omittedEncryptedExposePaths; + } + } + + private static class AllowedModifyExtraction { + + private final List cleanPaths; + private final Map pathTypeAnnotations; + private final List skippedPaths; + + private AllowedModifyExtraction(List cleanPaths, Map pathTypeAnnotations, + List skippedPaths) { + + this.cleanPaths = cleanPaths; + this.pathTypeAnnotations = pathTypeAnnotations; + this.skippedPaths = skippedPaths; + } + + private List getCleanPaths() { + + return cleanPaths; + } + + private Map getPathTypeAnnotations() { + + return pathTypeAnnotations; + } + + private List getSkippedPaths() { + + return skippedPaths; + } + } + + /** + * Convert an internal path to its external (API-facing) form. + * User-claim paths stored internally as {@code /user/claims/} are emitted + * externally as {@code /user/claims[uri=]}. All other paths are unchanged. + */ + private static String toExternalPath(String internalPath) { + + if (internalPath != null + && internalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX)) { + String claimUri = internalPath.substring( + InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX.length()); + return InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + claimUri + + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX; + } + return internalPath; + } + + /** + * Check if any exposed leaf path falls under the given area prefix. + * Used as a gate before iterating a data block (claims, credentials, properties). + */ + private boolean isAreaExposed(String areaPrefix, List expose) { + + return InFlowExtensionPathUtil.anyExposedUnder(areaPrefix, expose); + } + + /** + * Check if a specific leaf path is present in the expose list. + * Used for exact leaf-level inclusion decisions. + */ + private boolean isLeafExposed(String leafPath, List expose) { + + return InFlowExtensionPathUtil.isExposedPath(leafPath, expose); + } + + /** + * Determine if a value at the given path should be JWE-encrypted before sending to the + * external service. Only expose paths with {@code encrypted: true} trigger outbound encryption. + * + * @param path The expose path. + * @param accessConfig The access config with encryption flags. + * @param certificatePEM The certificate PEM (null if no encryption configured). + * @return {@code true} if the value should be encrypted. + */ + private boolean shouldEncrypt(String path, AccessConfig accessConfig, String certificatePEM) { + + if (certificatePEM == null || accessConfig == null) { + return false; + } + return accessConfig.isExposePathEncrypted(path); + } + + /** + * JWE-encrypt a plaintext value using the external service's certificate. + * + * @param plaintext The value to encrypt. + * @param certificatePEM The external service's certificate PEM. + * @return The JWE compact serialization string. + * @throws ActionExecutionRequestBuilderException If encryption fails. + */ + private String encryptValue(String plaintext, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + try { + return JWEEncryptionUtil.encrypt(plaintext, certificatePEM); + } catch (Exception e) { + throw new ActionExecutionRequestBuilderException( + "Failed to JWE-encrypt outbound value for In-Flow Extension action.", e); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessor.java new file mode 100644 index 000000000000..2df20073ada1 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessor.java @@ -0,0 +1,750 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.execution.api.constant.ActionExecutionLogConstants; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.flow.extension.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.ErrorStatus; +import org.wso2.carbon.identity.action.execution.api.model.FailedStatus; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Incomplete; +import org.wso2.carbon.identity.action.execution.api.model.IncompleteStatus; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.action.execution.api.model.SuccessStatus; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.model.AccessConfig; +import org.wso2.carbon.identity.flow.extension.util.InFlowExtensionPathUtil; +import org.wso2.carbon.identity.flow.extension.model.ContextPath; +import org.wso2.carbon.identity.flow.extension.model.OperationExecutionResult; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.utils.DiagnosticLog; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class is responsible for processing the response from In-Flow Extension actions. + * + *

    Responsibility: operation processing and collecting context updates into pending maps + * that are stored in {@link FlowContext} for the executor to forward to {@code TaskExecutionNode} + * via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse} fields. + * It processes {@code REPLACE} operations on flow properties, user claims, and user credentials.

    + * + *

    Only {@code REPLACE} operations are supported. The {@code allowedOperations} list + * (derived from modify paths and sent to the external service in the request, enforced + * upstream by {@code ActionExecutorServiceImpl}) is the sole mechanism for gating which + * operations are permitted. This processor performs additional validations:

    + *
      + *
    • Read-only areas: No modifications allowed to {@code /flow/} paths.
    • + *
    + */ +public class InFlowExtensionResponseProcessor implements ActionExecutionResponseProcessor { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionResponseProcessor.class); + + /** Shared error message for null-value REPLACE operations. */ + private static final String ERROR_VALUE_REQUIRED_FOR_REPLACE = "Value is required for REPLACE operation."; + + /** Diagnostic log parameter key for the action type. */ + private static final String DIAG_PARAM_ACTION_TYPE = "actionType"; + + @Override + public ActionType getSupportedActionType() { + + return ActionType.FLOW_EXTENSION; + } + + @Override + @SuppressWarnings("unchecked") + public ActionExecutionStatus processSuccessResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = flowContext.getValue( + InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, FlowExecutionContext.class); + String tenantDomain = execCtx != null ? execCtx.getTenantDomain() : null; + + // Read path type annotations set by the request builder. + // Maps clean paths to annotation content. + Map pathTypeAnnotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + if (pathTypeAnnotations == null) { + pathTypeAnnotations = Collections.emptyMap(); + } + + // Reconstruct AccessConfig from the resolved modify paths stored by the request builder. + // This reuses AccessConfig.isModifyPathEncrypted() for canonical prefix-based encryption checking. + List modifyPaths = flowContext.getValue( + InFlowExtensionConstants.MODIFY_PATHS_KEY, List.class); + AccessConfig accessConfig = modifyPaths != null ? new AccessConfig(null, modifyPaths) : null; + + // Accumulate pending updates — applied by TaskExecutionNode via ExecutorResponse fields. + Map pendingClaims = new HashMap<>(); + Map pendingCredentials = new HashMap<>(); + Map pendingProperties = new HashMap<>(); + + List results = new ArrayList<>(); + + List operations = + responseContext.getActionInvocationResponse().getOperations(); + + if (operations != null && !operations.isEmpty()) { + for (PerformableOperation operation : operations) { + operation = decryptOperationValueIfNeeded(operation, accessConfig, tenantDomain); + results.add(processOperation( + operation, pathTypeAnnotations, pendingClaims, pendingCredentials, + pendingProperties, tenantDomain)); + } + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("In-Flow Extension SUCCESS response contained no operations. No context updates applied."); + } + } + + // Store non-empty pending maps in FlowContext for the executor to forward to TaskExecutionNode. + if (!pendingClaims.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_CLAIMS_KEY, pendingClaims); + } + if (!pendingCredentials.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, pendingCredentials); + } + if (!pendingProperties.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, pendingProperties); + } + + logOperationExecutionResults(results); + + return new SuccessStatus.Builder() + .setSuccess(new InFlowExtensionSuccess()) + .setResponseContext(Collections.emptyMap()) + .build(); + } + + + /** + * Process a single operation by validating and collecting it into the appropriate pending map. + * Updates are not applied directly — they are stored in the pending maps and forwarded to + * {@code TaskExecutionNode} via {@link org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse}. + * + * @param operation The operation to process. + * @param pathTypeAnnotations Map of clean paths to their type annotations from allowed operations. + * @param pendingClaims Accumulator map for user claim updates. + * @param pendingCredentials Accumulator map for user credential updates. + * @param pendingProperties Accumulator map for flow property updates. + * @param tenantDomain Tenant domain, used for claim URI validation. + * @return The result of the operation execution. + */ + private OperationExecutionResult processOperation(PerformableOperation operation, + Map pathTypeAnnotations, + Map pendingClaims, + Map pendingCredentials, + Map pendingProperties, + String tenantDomain) { + + // Reject null/empty path early to prevent NPE in subsequent startsWith checks. + String path = operation.getPath(); + if (path == null || path.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Operation path is null or empty."); + } + + // Only REPLACE is supported; reject any other operation type. + if (operation.getOp() != Operation.REPLACE) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Unsupported operation type: " + operation.getOp() + ". Only REPLACE is supported."); + } + + // Check if operation is on a read-only area. + if (InFlowExtensionPathUtil.isReadOnly(path)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Path is in a read-only area. Modifications not allowed: " + path); + } + + // Route to appropriate handler based on path prefix. + if (path.startsWith(InFlowExtensionConstants.PROPERTIES_PATH_PREFIX)) { + return handlePropertyOperation(operation, pathTypeAnnotations, pendingProperties); + } else if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX)) { + return handleUserClaimOperation(operation, pendingClaims, tenantDomain); + } else if (path.startsWith(InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX)) { + return handleUserCredentialOperation(operation, pendingCredentials); + } + + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Unknown path prefix. Supported: " + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX + + ", " + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX + "" + + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX + + ", " + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); + } + + /** + * Handle operation on flow properties — collect into pending properties map. + * + *

    Only flat property paths are supported (e.g., {@code /properties/riskScore}). + * Value coercion is applied based on path type annotations from the request builder via + * {@link PathTypeAnnotationUtil#coerceValue}.

    + * + * @param operation The performable operation. + * @param pathTypeAnnotations Path type annotations map from request builder. + * @param pendingProperties Accumulator map for property updates. + */ + private OperationExecutionResult handlePropertyOperation(PerformableOperation operation, + Map pathTypeAnnotations, Map pendingProperties) { + + String propertyName = extractNameFromPath(operation.getPath(), + InFlowExtensionConstants.PROPERTIES_PATH_PREFIX); + + if (propertyName == null || propertyName.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid property path. Property name is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + // Validate complex object structure against annotation schema before coercion. + if (!PathTypeAnnotationUtil.validateValueAgainstAnnotation( + operation.getPath(), operation.getValue(), pathTypeAnnotations)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Value does not match annotation schema for path: " + operation.getPath()); + } + + // Coerce value based on path type annotation. + Object coercedValue = PathTypeAnnotationUtil.coerceValue( + operation.getPath(), operation.getValue(), pathTypeAnnotations); + + // Enforce array item limits after coercion. + if (!PathTypeAnnotationUtil.enforceArrayItemLimit( + operation.getPath(), coercedValue, pathTypeAnnotations)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Array value exceeds maximum item limit for path: " + operation.getPath()); + } + + pendingProperties.put(propertyName, coercedValue); + + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "Property replace applied."); + } + + /** + * Handle REPLACE operation on user claims — validate the claim URI then collect into pending + * claims map. Validation gates: + *
      + *
    1. Claim URI must be in the WSO2 local claim dialect ({@code http://wso2.org/claims/}).
    2. + *
    3. Identity-system claims ({@code http://wso2.org/claims/identity/}) are rejected.
    4. + *
    5. If {@link org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService} + * is available, the claim URI must correspond to a registered local claim; unknown + * claims are rejected. When the service is unavailable the check is skipped + * (fail-open).
    6. + *
    + * The value is always stringified via {@code String.valueOf()}. + * + * @param operation The performable operation. + * @param pendingClaims Accumulator map for user claim updates. + * @param tenantDomain Tenant domain for claim existence lookup. + */ + private OperationExecutionResult handleUserClaimOperation(PerformableOperation operation, + Map pendingClaims, String tenantDomain) { + + String claimUri = extractClaimUriFromPath(operation.getPath()); + + if (claimUri == null || claimUri.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid claim path. Claim URI is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + // Validate claim URI — must be local dialect, not an identity sub-claim. + if (!claimUri.startsWith(PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Claim URI must be in the local dialect (" + + PathTypeAnnotationUtil.LOCAL_CLAIM_DIALECT_PREFIX + "): " + claimUri); + } + if (claimUri.startsWith(PathTypeAnnotationUtil.IDENTITY_CLAIM_URI_PREFIX)) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Identity-system claims cannot be modified by In-Flow Extensions: " + claimUri); + } + + // Verify claim existence via ClaimMetadataManagementService when available. + String claimValidationFailure = validateLocalClaimExists(claimUri, tenantDomain); + if (claimValidationFailure != null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + claimValidationFailure); + } + + pendingClaims.put(claimUri, String.valueOf(operation.getValue())); + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "User claim replace applied."); + } + + /** + * Validate that the given claim URI corresponds to a registered local claim in the tenant. + * Returns {@code null} if the claim is valid or if the service is unavailable (fail-open). + * Returns a descriptive failure message if validation fails. + * + * @param claimUri Local claim URI to check. + * @param tenantDomain Tenant domain for the lookup. + * @return Failure reason string, or {@code null} when validation passes or is skipped. + */ + private String validateLocalClaimExists(String claimUri, String tenantDomain) { + + org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService claimService = + InFlowExtensionDataHolder.getInstance().getClaimMetadataManagementService(); + if (claimService == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("ClaimMetadataManagementService is unavailable. Skipping claim existence check for: " + + claimUri); + } + return null; + } + try { + java.util.Optional localClaim = claimService.getLocalClaim(claimUri, tenantDomain); + if (!localClaim.isPresent()) { + return "Unknown local claim URI. Claim is not registered in the system: " + claimUri; + } + return null; + } catch (ClaimMetadataException e) { + LOG.warn("Failed to look up claim URI '" + claimUri + "' in tenant '" + tenantDomain + + "'. Skipping claim existence check.", e); + return null; + } + } + + /** + * Handle REPLACE operation on user credentials — collect into pending credentials map. + * No key validation is applied; any credential key is accepted. The value is converted + * to {@code char[]} immediately to avoid holding the secret as a plain {@code String} + * any longer than necessary. + * + * @param operation The performable operation. + * @param pendingCredentials Accumulator map for user credential updates. + */ + private OperationExecutionResult handleUserCredentialOperation(PerformableOperation operation, + Map pendingCredentials) { + + String credentialKey = extractNameFromPath(operation.getPath(), + InFlowExtensionConstants.USER_CREDENTIALS_PATH_PREFIX); + + if (credentialKey == null || credentialKey.isEmpty()) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Invalid credential path. Credential key is required."); + } + + if (operation.getValue() == null) { + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + ERROR_VALUE_REQUIRED_FOR_REPLACE); + } + + pendingCredentials.put(credentialKey, String.valueOf(operation.getValue()).toCharArray()); + return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS, + "User credential replace applied."); + } + + /** + * Extract the name/key from the operation path after the prefix. + */ + private String extractNameFromPath(String path, String prefix) { + + if (path == null || !path.startsWith(prefix)) { + return null; + } + + String remaining = path.substring(prefix.length()); + + if (remaining.endsWith("/")) { + remaining = remaining.substring(0, remaining.length() - 1); + } + + return remaining; + } + + /** + * Extract the claim URI from an external-format claim path. + * Accepts the selector form {@code /user/claims[uri=]}. + * Returns {@code null} if the path is null or does not match the expected format. + */ + private String extractClaimUriFromPath(String path) { + + if (path == null) { + return null; + } + if (path.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) + && path.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { + return path.substring( + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), + path.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); + } + return null; + } + + /** + * Normalize an external-format claim path to the internal format for encryption checks. + * Converts {@code /user/claims[uri=]} to {@code /user/claims/}. + * All other paths are returned unchanged. + */ + private static String normalizeToInternalPath(String externalPath) { + + if (externalPath != null + && externalPath.startsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX) + && externalPath.endsWith(InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX)) { + String claimUri = externalPath.substring( + InFlowExtensionConstants.USER_CLAIMS_SELECTOR_PREFIX.length(), + externalPath.length() - InFlowExtensionConstants.USER_CLAIMS_SELECTOR_SUFFIX.length()); + return InFlowExtensionConstants.USER_CLAIMS_PATH_PREFIX + claimUri; + } + return externalPath; + } + + @Override + public ActionExecutionStatus processIncompleteResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + // Contract: INCOMPLETE must carry a REDIRECT op. Every other op is intentionally + // discarded — the extension is expected to resend them on the resume call after redirect. + List operations = + responseContext.getActionInvocationResponse().getOperations(); + IncompleteOpScan scan = scanIncompleteOperations(operations); + + validateRedirectPresent(scan.redirectUrl); + logIgnoredIncompleteOps(scan.ignoredOpCount); + + flowContext.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, scan.redirectUrl); + debugLogRedirectHost(scan.redirectUrl); + logIncompleteSuccess(); + + return new IncompleteStatus.Builder() + .responseContext(Collections.emptyMap()) + .build(); + } + + /** + * Scan the operation list from an INCOMPLETE response to extract the redirect URL and count + * non-REDIRECT operations that will be ignored. + * + * @param operations List of operations from the INCOMPLETE response (may be null). + * @return Scan result holding the redirect URL and ignored-op count. + */ + private IncompleteOpScan scanIncompleteOperations(List operations) { + + if (operations == null) { + return new IncompleteOpScan(null, 0); + } + String redirectUrl = null; + int ignoredOpCount = 0; + for (PerformableOperation op : operations) { + if (op.getOp() == Operation.REDIRECT) { + redirectUrl = op.getUrl(); + } else { + ignoredOpCount++; + } + } + return new IncompleteOpScan(redirectUrl, ignoredOpCount); + } + + /** + * Assert that a redirect URL was found in the INCOMPLETE response; throw and emit diagnostics + * if it is absent. + */ + private void validateRedirectPresent(String redirectUrl) + throws ActionExecutionResponseProcessorException { + + if (redirectUrl != null && !redirectUrl.isEmpty()) { + return; + } + LOG.warn("In-Flow Extension INCOMPLETE response is missing a REDIRECT operation."); + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage( + "INCOMPLETE response from In-Flow Extension is missing a REDIRECT operation.") + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + throw new ActionExecutionResponseProcessorException( + "INCOMPLETE response from In-Flow Extension must contain a REDIRECT operation."); + } + + /** Log the number of non-REDIRECT ops discarded in an INCOMPLETE response, if any. */ + private void logIgnoredIncompleteOps(int ignoredOpCount) { + + if (ignoredOpCount > 0 && LOG.isDebugEnabled()) { + LOG.debug("Ignored " + ignoredOpCount + " non-REDIRECT operation(s) on INCOMPLETE response. " + + "REPLACE ops are by-contract dropped — the extension is expected to resend " + + "them on the resume call after callback."); + } + } + + /** Log the redirect URL host at DEBUG level after parsing the URI. */ + private void debugLogRedirectHost(String redirectUrl) { + + if (!LOG.isDebugEnabled()) { + return; + } + try { + String host = new java.net.URI(redirectUrl).getHost(); + LOG.debug("In-Flow Extension INCOMPLETE: redirect URL host resolved: " + host); + } catch (java.net.URISyntaxException ignored) { + LOG.debug("In-Flow Extension INCOMPLETE: redirect URL stored in flow context."); + } + } + + /** Emit a diagnostic success event after a valid INCOMPLETE response is processed. */ + private void logIncompleteSuccess() { + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage( + "In-Flow Extension INCOMPLETE response processed. Redirect URL stored in flow context.") + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSION.getDisplayName()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS)); + } + } + + /** Lightweight value holder for the result of scanning INCOMPLETE response operations. */ + private static final class IncompleteOpScan { + + private final String redirectUrl; + private final int ignoredOpCount; + + private IncompleteOpScan(String redirectUrl, int ignoredOpCount) { + + this.redirectUrl = redirectUrl; + this.ignoredOpCount = ignoredOpCount; + } + } + + @Override + public ActionExecutionStatus processErrorResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + String errorMessage = responseContext.getActionInvocationResponse().getErrorMessage(); + String errorDescription = responseContext.getActionInvocationResponse().getErrorDescription(); + + if (LOG.isDebugEnabled()) { + LOG.debug("Processing error response from In-Flow Extension. Error: " + errorMessage + + ", Description: " + errorDescription); + } + + return new ErrorStatus(new Error(errorMessage, errorDescription)); + } + + @Override + public ActionExecutionStatus processFailureResponse(FlowContext flowContext, + ActionExecutionResponseContext responseContext) + throws ActionExecutionResponseProcessorException { + + String failureReason = responseContext.getActionInvocationResponse().getFailureReason(); + String failureDescription = responseContext.getActionInvocationResponse().getFailureDescription(); + + if (LOG.isDebugEnabled()) { + LOG.debug("Processing failure response from In-Flow Extension. Reason: " + failureReason + + ", Description: " + failureDescription); + } + + return new FailedStatus(new Failure(failureReason, failureDescription)); + } + + /** + * Log operation execution results for diagnostics and debugging. + */ + private void logOperationExecutionResults(List results) { + + if (results.isEmpty()) { + return; + } + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + List> operationDetailsList = new ArrayList<>(); + results.forEach(result -> { + Map details = new HashMap<>(); + details.put("operation", result.getOperation().getOp() + " path: " + + result.getOperation().getPath()); + details.put("status", result.getStatus().toString()); + details.put("message", result.getMessage()); + operationDetailsList.add(details); + }); + + DiagnosticLog.DiagnosticLogBuilder diagnosticLogBuilder = new DiagnosticLog.DiagnosticLogBuilder( + ActionExecutionLogConstants.ACTION_EXECUTION_COMPONENT_ID, + ActionExecutionLogConstants.ActionIDs.PROCESS_ACTION_RESPONSE); + diagnosticLogBuilder + .inputParam("executedOperations", operationDetailsList) + .resultMessage("Processed operations for " + getSupportedActionType().getDisplayName() + + " action.") + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.SUCCESS) + .build(); + LoggerUtils.triggerDiagnosticLogEvent(diagnosticLogBuilder); + } + + if (LOG.isDebugEnabled()) { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + try { + String summary = mapper.writeValueAsString(results); + LOG.debug(String.format("Processed response for action type: %s. Results: %s", + getSupportedActionType(), summary)); + } catch (JsonProcessingException e) { + LOG.debug("Error occurred while logging operation execution results.", e); + } + } + } + + /** + * Inner class representing a successful In-Flow Extension execution result. + */ + public static class InFlowExtensionSuccess implements Success { + + // Marker class for successful execution. + } + + // ========================= Inbound decryption ========================= + + /** + * Decrypt the operation value if the operation path has encryption enabled in the AccessConfig. + * Uses {@link AccessConfig#isModifyPathEncrypted(String)} for canonical checking. + * For operations with encrypted paths, checks if the value looks like a JWE compact + * serialization and decrypts it using the IS's private key. + * + * @param operation The operation to potentially decrypt. + * @param accessConfig The access config with encryption flags (may be null). + * @param tenantDomain Tenant domain for IS private key resolution. + * @return The operation with decrypted value, or the original operation if no decryption needed. + */ + private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation operation, + AccessConfig accessConfig, String tenantDomain) + throws ActionExecutionResponseProcessorException { + + if (accessConfig == null || operation.getValue() == null) { + return operation; + } + + // Normalize external claim path to internal format before checking encryption flags. + String internalPath = normalizeToInternalPath(operation.getPath()); + if (!accessConfig.isModifyPathEncrypted(internalPath)) { + return operation; + } + + // For encrypted modify paths the value MUST be a JWE compact serialization. + // Accepting plaintext would make the encryption flag advisory; it is enforced here. + Object value = operation.getValue(); + + if (!(value instanceof String)) { + emitEncryptionContractViolation(operation.getPath(), + "Value for encrypted modify path is not a String."); + throw new ActionExecutionResponseProcessorException( + "Value for encrypted modify path '" + operation.getPath() + + "' must be a JWE-encrypted string, but received a non-String value."); + } + + String stringValue = (String) value; + if (!JWEEncryptionUtil.isJWEEncrypted(stringValue)) { + emitEncryptionContractViolation(operation.getPath(), + "Value for encrypted modify path is not JWE-encrypted."); + throw new ActionExecutionResponseProcessorException( + "Value for encrypted modify path '" + operation.getPath() + + "' must be JWE-encrypted, but received a plaintext value."); + } + + try { + String decrypted = JWEEncryptionUtil.decrypt(stringValue, tenantDomain); + PerformableOperation decryptedOp = new PerformableOperation(); + decryptedOp.setOp(operation.getOp()); + decryptedOp.setPath(operation.getPath()); + decryptedOp.setValue(decrypted); + if (LOG.isDebugEnabled()) { + LOG.debug("Successfully decrypted inbound JWE value for path: " + operation.getPath()); + } + return decryptedOp; + } catch (Exception e) { + LOG.error("Failed to decrypt inbound JWE value for path '" + operation.getPath() + + "', tenant: " + tenantDomain, e); + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage("Failed to decrypt inbound JWE value for modify path.") + .configParam("actionType", ActionType.FLOW_EXTENSION.getDisplayName()) + .inputParam("path", operation.getPath()) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + throw new ActionExecutionResponseProcessorException( + "Failed to decrypt inbound JWE value for path: " + operation.getPath(), e); + } + } + + // ========================= Decryption diagnostics helpers ========================= + + /** + * Emit a diagnostic failure event for a broken encryption contract on a modify path. + * Extracted to keep {@link #decryptOperationValueIfNeeded} readable. + */ + private void emitEncryptionContractViolation(String path, String reason) { + + if (LoggerUtils.isDiagnosticLogsEnabled()) { + LoggerUtils.triggerDiagnosticLogEvent(new DiagnosticLog.DiagnosticLogBuilder( + InFlowExtensionConstants.Log.COMPONENT_ID, + InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE) + .resultMessage(reason) + .configParam(DIAG_PARAM_ACTION_TYPE, ActionType.FLOW_EXTENSION.getDisplayName()) + .inputParam("path", path) + .logDetailLevel(DiagnosticLog.LogDetailLevel.APPLICATION) + .resultStatus(DiagnosticLog.ResultStatus.FAILED)); + } + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/JWEEncryptionUtil.java new file mode 100644 index 000000000000..a2e39b5281ca --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/JWEEncryptionUtil.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.RSADecrypter; +import com.nimbusds.jose.crypto.RSAEncrypter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.base.MultitenantConstants; +import org.wso2.carbon.core.util.KeyStoreManager; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Utility class for JWE encryption and decryption operations used by In-Flow Extension actions. + * + *

    Outbound encryption (IS → external service): uses the external service's X.509 + * certificate public key with {@code RSA-OAEP-256} + {@code A256GCM}. This follows the same + * pattern as {@code PasswordUpdatingUser.encryptCredential()}.

    + * + *

    Inbound decryption (external service → IS): uses the IS server's RSA private key + * retrieved via {@link KeyStoreManager} for the tenant. This follows the same + * pattern as {@code UserAssertionUtils.getPrivateKey()}.

    + */ +public final class JWEEncryptionUtil { + + private static final Log LOG = LogFactory.getLog(JWEEncryptionUtil.class); + + /** Cache of resolved private keys with TTL, keyed by tenant ID. */ + private static final Map PRIVATE_KEYS = new ConcurrentHashMap<>(); + private static final long PRIVATE_KEY_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(30); + + private JWEEncryptionUtil() { + + } + + /** + * Encrypt a plaintext string using JWE with the external service's X.509 certificate. + * Uses RSA-OAEP-256 key encryption and A256GCM content encryption. + * + * @param plaintext The plaintext JSON string to encrypt. + * @param certificatePEM The external service's X.509 certificate in PEM format. + * @return JWE compact serialization string (5-part dot-separated). + * @throws ActionExecutionException If encryption fails. + */ + public static String encrypt(String plaintext, String certificatePEM) throws ActionExecutionException { + + try { + X509Certificate certificate = parsePEMCertificate(certificatePEM); + RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); + + JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) + .contentType("application/json") + .build(); + + JWEObject jweObject = new JWEObject(header, new Payload(plaintext)); + jweObject.encrypt(new RSAEncrypter(publicKey)); + + return jweObject.serialize(); + } catch (JOSEException e) { + throw new ActionExecutionException( + "Failed to JWE-encrypt data for In-Flow Extension action.", e); + } + } + + /** + * Decrypt a JWE compact string using the IS server's RSA private key. + * The private key is resolved via {@link KeyStoreManager} for the tenant. + * + * @param jweString The JWE compact serialization string. + * @param tenantDomain The tenant domain to resolve the private key. + * @return Decrypted plaintext string. + * @throws ActionExecutionException If decryption fails. + */ + public static String decrypt(String jweString, String tenantDomain) throws ActionExecutionException { + + try { + JWEObject jweObject = JWEObject.parse(jweString); + + Key privateKey = getPrivateKey(tenantDomain); + + RSADecrypter decrypter = new RSADecrypter((RSAPrivateKey) privateKey); + jweObject.decrypt(decrypter); + + return jweObject.getPayload().toString(); + } catch (ParseException e) { + throw new ActionExecutionException( + "Failed to parse JWE string from In-Flow Extension response.", e); + } catch (JOSEException e) { + throw new ActionExecutionException( + "Failed to decrypt JWE value from In-Flow Extension response.", e); + } catch (ActionExecutionException e) { + throw e; + } catch (Exception e) { + throw new ActionExecutionException( + "Error resolving IS private key for In-Flow Extension JWE decryption.", e); + } + } + + /** + * Retrieve the IS server's RSA private key for the given tenant. + * Uses {@link KeyStoreManager} directly, bypassing IdentityKeyStoreResolver, + * to avoid requiring a protocol-specific keystore mapping. + * + * @param tenantDomain The tenant domain. + * @return The RSA private key. + * @throws ActionExecutionException If retrieval fails. + */ + private static Key getPrivateKey(String tenantDomain) throws ActionExecutionException { + + int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); + String cacheKey = String.valueOf(tenantId); + CachedKey cached = PRIVATE_KEYS.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.getKey(); + } + + try { + IdentityTenantUtil.initializeRegistry(tenantId); + KeyStoreManager keyStoreManager = KeyStoreManager.getInstance(tenantId); + Key privateKey; + + if (MultitenantConstants.SUPER_TENANT_DOMAIN_NAME.equals(tenantDomain)) { + privateKey = keyStoreManager.getDefaultPrivateKey(); + } else { + String tenantKeyStoreName = tenantDomain.trim().replace(".", "-") + ".jks"; + privateKey = keyStoreManager.getPrivateKey(tenantKeyStoreName, tenantDomain); + } + + PRIVATE_KEYS.put(cacheKey, new CachedKey(privateKey)); + return privateKey; + } catch (Exception e) { + throw new ActionExecutionException( + "Error retrieving private key for tenant: " + tenantDomain, e); + } + } + + /** + * Detect whether a string value is a JWE compact serialization. + * A JWE compact serialization has exactly 5 dot-separated Base64url-encoded parts. + * + * @param value The value to check. + * @return {@code true} if the value appears to be a JWE compact string. + */ + public static boolean isJWEEncrypted(String value) { + + if (value == null || value.isEmpty()) { + return false; + } + // Count dots — JWE compact serialization has exactly 4 dots (5 parts). + int dotCount = 0; + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) == '.') { + dotCount++; + if (dotCount > 4) { + return false; + } + } + } + return dotCount == 4; + } + + /** + * Parse a Base64-encoded PEM X.509 certificate string into an {@link X509Certificate} object. + * Expects the input to be a fully Base64-encoded string of a standard PEM file. + * + * @param base64EncodedPem The Base64 encoded PEM string. + * @return The parsed X509Certificate. + * @throws ActionExecutionException If parsing or decoding fails. + */ + public static X509Certificate parsePEMCertificate(String base64EncodedPem) throws ActionExecutionException { + + if (base64EncodedPem == null || base64EncodedPem.trim().isEmpty()) { + throw new ActionExecutionException("Certificate string is null or empty."); + } + + try { + byte[] decodedPemBytes = java.util.Base64.getDecoder().decode(base64EncodedPem.trim()); + + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decodedPemBytes)); + certificate.checkValidity(); + return certificate; + + } catch (CertificateExpiredException e) { + throw new ActionExecutionException( + "Certificate has expired for In-Flow Extension action.", e); + } catch (CertificateNotYetValidException e) { + throw new ActionExecutionException( + "Certificate is not yet valid for In-Flow Extension action.", e); + } catch (IllegalArgumentException e) { + throw new ActionExecutionException( + "Failed to decode certificate: Input is not valid Base64.", e); + } catch (Exception e) { + throw new ActionExecutionException( + "Failed to parse the decoded PEM certificate for In-Flow Extension action.", e); + } + } + + /** + * Cache wrapper for private keys with TTL support. + */ + private static class CachedKey { + + private final Key key; + private final long createdAt; + + CachedKey(Key key) { + + this.key = key; + this.createdAt = System.currentTimeMillis(); + } + + Key getKey() { + + return key; + } + + boolean isExpired() { + + return (System.currentTimeMillis() - createdAt) > PRIVATE_KEY_CACHE_TTL_MS; + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtil.java new file mode 100644 index 000000000000..e911c188cf3a --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtil.java @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class for path type annotation parsing, stripping, and value coercion. + * + *

    Path type annotations use a trailing brace expression at the end of a modify path + * to declare the expected data type for that path. The unified format uses curly braces:

    + *
      + *
    • {@code /properties/risk-factor{String}} — primary data type.
    • + *
    • {@code /properties/risk-factors{[String]}} — multivalued primary (array of type).
    • + *
    • {@code /properties/risk{risk: Float, factor: String}} — complex object with schema.
    • + *
    • {@code /properties/risk{[risk: Float, factor: String]}} — multivalued complex object array.
    • + *
    + * + *

    This class provides methods to strip annotations from paths and coerce incoming values + * based on the stored annotations.

    + */ +public final class PathTypeAnnotationUtil { + + /** + * Regex pattern to match a trailing curly brace annotation at the end of a path. + * Captures the content inside the braces (Group 1). + * Examples: {@code {String}}, {@code {[String]}}, {@code {risk: Float, factor: String}}. + */ + static final Pattern ANNOTATION_PATTERN = Pattern.compile("\\{([^}]*)}$"); + + /** Claim URI prefix for the WSO2 local claim dialect. */ + static final String LOCAL_CLAIM_DIALECT_PREFIX = "http://wso2.org/claims/"; + + /** Claim URI prefix for WSO2 identity claims (subset of local claims, not user-modifiable). */ + static final String IDENTITY_CLAIM_URI_PREFIX = "http://wso2.org/claims/identity/"; + + /** Reusable ObjectMapper for parsing JSON-string values received for complex-typed paths. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private PathTypeAnnotationUtil() { + + } + + /** + * Strip a trailing path type annotation from a raw path. + * + * @param rawPath The raw path potentially containing a trailing {@code {annotation}}. + * @return A two-element array: {@code [cleanPath, annotation]}. + * If no annotation is found, annotation element is {@code null}. + */ + public static String[] stripAnnotation(String rawPath) { + + if (rawPath == null) { + return new String[]{null, null}; + } + + Matcher matcher = ANNOTATION_PATTERN.matcher(rawPath); + if (matcher.find()) { + String cleanPath = rawPath.substring(0, matcher.start()); + String annotation = matcher.group(1); + return new String[]{cleanPath, annotation}; + } + return new String[]{rawPath, null}; + } + + /** + * Coerce a value based on path type annotations. + * + *

    Annotation interpretation:

    + *
      + *
    • {@code null} (no annotation): value is coerced to String via {@code String.valueOf()}.
    • + *
    • Starts with {@code [} and contains {@code :} (e.g., {@code [risk: Float]}): + * complex object array — value is passed through as-is.
    • + *
    • Starts with {@code [} without {@code :} (e.g., {@code [String]}): + * multivalued primary type — value is expected to be a List; each element coerced to String. + * A single value is wrapped into a list.
    • + *
    • Contains {@code :} (e.g., {@code risk: Float, factor: String}): + * complex object — value is passed through as-is.
    • + *
    • Any other annotation (e.g., {@code String}, {@code Integer}): + * primary type — value is coerced to String via {@code String.valueOf()}.
    • + *
    + * + * @param path The operation path (used as lookup key in annotations map). + * @param value The raw value from the operation. + * @param pathTypeAnnotations Map from clean path to annotation content (may be empty). + * @return The coerced value. + */ + @SuppressWarnings("unchecked") + public static Object coerceValue(String path, Object value, + Map pathTypeAnnotations) { + + if (value == null) { + return null; + } + + String annotation = pathTypeAnnotations.get(path); + + if (annotation == null) { + // No annotation: coerce to String. + return String.valueOf(value); + } + + // Check for multivalued annotation: starts with [ + if (annotation.startsWith("[")) { + String inner = annotation.substring(1, annotation.length() - 1); + if (inner.contains(":")) { + // Complex object array (e.g., [risk: Float, factor: String]): + // parse JSON string if needed, then pass through. + return tryParseJsonString(value); + } + // Multivalued primary type (e.g., [String], [Integer]): coerce to List. + // Parse JSON string first in case the value arrived as "[\"a\",\"b\"]". + Object resolvedList = tryParseJsonString(value); + if (resolvedList instanceof List) { + List rawList = (List) resolvedList; + List stringList = new ArrayList<>(); + for (Object item : rawList) { + stringList.add(item == null ? null : String.valueOf(item)); + } + return stringList; + } + // Single value — wrap in a list. + List singleList = new ArrayList<>(); + singleList.add(String.valueOf(value)); + return singleList; + } + + // Check for complex object annotation: contains ":" + if (annotation.contains(":")) { + // Complex object (e.g., risk: Float, factor: String): + // parse JSON string if needed, then pass through. + return tryParseJsonString(value); + } + + // Primary type annotation (e.g., String, Integer, Boolean): coerce to String. + return String.valueOf(value); + } + + /** Maximum number of attributes allowed in a complex object annotation. */ + static final int MAX_ATTRIBUTES_PER_OBJECT = 10; + + /** Maximum number of items allowed in an array (primary or complex object array). */ + static final int MAX_ARRAY_ITEMS = 10; + + /** + * Validate that a complex object annotation does not exceed the maximum attribute count. + * Should be called on the raw annotation content (inside braces) before stripping. + * + * @param annotation The annotation content (e.g., {@code "risk: Float, factor: String"} + * or {@code "[risk: Float, factor: String]"}). May be {@code null}. + * @return {@code true} if the annotation is valid (within limits or not a complex annotation). + */ + public static boolean validateAnnotationLimits(String annotation) { + + if (annotation == null || annotation.isEmpty()) { + return true; + } + + String inner = annotation; + // Unwrap array brackets if present. + if (inner.startsWith("[") && inner.endsWith("]")) { + inner = inner.substring(1, inner.length() - 1); + } + + // Only validate complex annotations (those with attribute definitions containing ':'). + if (!inner.contains(":")) { + return true; + } + + return parseAnnotationAttributes(inner).size() <= MAX_ATTRIBUTES_PER_OBJECT; + } + + /** + * Validate a complex object value against its path type annotation schema. + * + *

    Validates:

    + *
      + *
    • Value is a Map with attribute names matching the annotation schema.
    • + *
    • Only one nesting level: attributes must be primary types or primary arrays.
    • + *
    • Attribute count does not exceed {@link #MAX_ATTRIBUTES_PER_OBJECT}.
    • + *
    • Array attributes do not exceed {@link #MAX_ARRAY_ITEMS} items.
    • + *
    + * + *

    For non-complex annotations (primary types, primary arrays), this method returns + * {@code true} without further validation since those are handled by coercion.

    + * + * @param path The operation path. + * @param value The value to validate. + * @param pathTypeAnnotations Map from clean path to annotation content. + * @return {@code true} if the value is valid against the annotation, {@code false} otherwise. + */ + @SuppressWarnings("unchecked") + public static boolean validateValueAgainstAnnotation(String path, Object value, + Map pathTypeAnnotations) { + + String annotation = pathTypeAnnotations.get(path); + if (annotation == null) { + return true; + } + + boolean isArray = annotation.startsWith("["); + String inner = isArray ? annotation.substring(1, annotation.length() - 1) : annotation; + + // Only validate complex annotations (those with attribute definitions). + if (!inner.contains(":")) { + return true; + } + + Map schema = parseAnnotationAttributes(inner); + + // Parse JSON string if the value arrived as serialized JSON + // (e.g., after JWE decryption or when the external service stringifies before encrypting). + Object resolvedValue = tryParseJsonString(value); + + if (isArray) { + // Complex object array: validate each item. + if (!(resolvedValue instanceof List)) { + return false; + } + List items = (List) resolvedValue; + if (items.size() > MAX_ARRAY_ITEMS) { + return false; + } + for (Object item : items) { + if (!validateSingleComplexObject(item, schema)) { + return false; + } + } + return true; + } + + // Single complex object. + return validateSingleComplexObject(resolvedValue, schema); + } + + /** + * Enforce array item limits on a value. Applies to both primary arrays and complex object arrays. + * + * @param path The operation path. + * @param value The value to check. + * @param pathTypeAnnotations Map from clean path to annotation content. + * @return {@code true} if the value is within array item limits, {@code false} otherwise. + */ + @SuppressWarnings("unchecked") + public static boolean enforceArrayItemLimit(String path, Object value, + Map pathTypeAnnotations) { + + String annotation = pathTypeAnnotations.get(path); + if (annotation == null || !annotation.startsWith("[")) { + return true; + } + + if (!(value instanceof List)) { + return true; + } + + return ((List) value).size() <= MAX_ARRAY_ITEMS; + } + + /** + * Attempt to parse a value as JSON if it is a String that starts with {@code [} or {@code {}. + * This handles values that arrive as serialized JSON strings — for example, after JWE + * decryption, or when an external service serialises a complex object/array before encrypting it. + * + *

    If the value is not a String, does not start with {@code [} or {@code {}, or cannot be + * parsed as valid JSON, the original value is returned unchanged.

    + * + * @param value The value to inspect. + * @return The parsed JSON structure (List or Map), or the original value if not applicable. + */ + private static Object tryParseJsonString(Object value) { + + if (!(value instanceof String)) { + return value; + } + String str = ((String) value).trim(); + if (!str.startsWith("[") && !str.startsWith("{")) { + return value; + } + try { + return OBJECT_MAPPER.readValue(str, Object.class); + } catch (IOException ignored) { + return value; + } + } + + /** + * Parse complex annotation attributes into a map of attribute name to type. + * Handles array types indicated by trailing {@code []}. + * + * @param inner The inner annotation content (e.g., {@code "risk: Float, factor: String"}). + * @return Map from attribute name to type string (e.g., {@code "Float"} or {@code "String[]"}). + */ + private static Map parseAnnotationAttributes(String inner) { + + Map attributes = new HashMap<>(); + String[] parts = inner.split(","); + for (String part : parts) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + int colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + String name = trimmed.substring(0, colonIndex).trim(); + String type = trimmed.substring(colonIndex + 1).trim(); + attributes.put(name, type); + } + } + return attributes; + } + + /** + * Validate a single complex object value against a schema. + * Ensures value is a Map, keys match schema names, attribute count within limits, + * and nested values are only primary types or primary arrays (single nesting level). + * + * @param value The value to validate (expected to be a Map). + * @param schema The annotation schema (attribute name to type). + * @return {@code true} if valid. + */ + @SuppressWarnings("unchecked") + private static boolean validateSingleComplexObject(Object value, Map schema) { + + if (!isMapAndWithinAttributeLimit(value)) { + return false; + } + + Map map = (Map) value; + + if (!containsOnlySchemaKeys(map, schema)) { + return false; + } + + for (Map.Entry entry : map.entrySet()) { + if (!validateEntryValueAgainstType(entry, schema)) { + return false; + } + } + + return true; + } + + private static boolean isMapAndWithinAttributeLimit(Object value) { + + return value instanceof Map && ((Map) value).size() <= MAX_ATTRIBUTES_PER_OBJECT; + } + + private static boolean containsOnlySchemaKeys(Map valueMap, Map schema) { + + for (String key : valueMap.keySet()) { + if (!schema.containsKey(key)) { + return false; + } + } + return true; + } + + private static boolean validateEntryValueAgainstType(Map.Entry entry, Map schema) { + + String type = schema.get(entry.getKey()); + Object attrValue = entry.getValue(); + + if (type != null && type.endsWith("[]")) { + return validatePrimaryArrayAttribute(attrValue); + } + return !isNestedStructure(attrValue); + } + + @SuppressWarnings("unchecked") + private static boolean validatePrimaryArrayAttribute(Object value) { + + if (!(value instanceof List)) { + return false; + } + + List list = (List) value; + if (list.size() > MAX_ARRAY_ITEMS) { + return false; + } + + for (Object item : list) { + if (isNestedStructure(item)) { + return false; + } + } + return true; + } + + private static boolean isNestedStructure(Object value) { + + return value instanceof Map || value instanceof List; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionDataHolder.java new file mode 100644 index 000000000000..ced9c7f68667 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionDataHolder.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.internal; + +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; + +/** + * Data holder for the In-Flow Extension bundle. + * + *

    This singleton is used by DS bind/unbind methods to expose dynamic OSGi references + * to classes outside the component lifecycle methods.

    + */ +public final class InFlowExtensionDataHolder { + + private static final InFlowExtensionDataHolder instance = new InFlowExtensionDataHolder(); + + private ActionExecutorService actionExecutorService; + private ActionManagementService actionManagementService; + private CertificateManagementService certificateManagementService; + private ClaimMetadataManagementService claimMetadataManagementService; + + private InFlowExtensionDataHolder() { + + } + + public static InFlowExtensionDataHolder getInstance() { + + return instance; + } + + public ActionExecutorService getActionExecutorService() { + + return actionExecutorService; + } + + public void setActionExecutorService(ActionExecutorService actionExecutorService) { + + this.actionExecutorService = actionExecutorService; + } + + public ActionManagementService getActionManagementService() { + + return actionManagementService; + } + + public void setActionManagementService(ActionManagementService actionManagementService) { + + this.actionManagementService = actionManagementService; + } + + public CertificateManagementService getCertificateManagementService() { + + return certificateManagementService; + } + + public void setCertificateManagementService(CertificateManagementService certificateManagementService) { + + this.certificateManagementService = certificateManagementService; + } + + public ClaimMetadataManagementService getClaimMetadataManagementService() { + + return claimMetadataManagementService; + } + + public void setClaimMetadataManagementService(ClaimMetadataManagementService claimMetadataManagementService) { + + this.claimMetadataManagementService = claimMetadataManagementService; + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java new file mode 100644 index 000000000000..51f08128df03 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.internal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionResponseProcessor; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.action.management.api.service.ActionConverter; +import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; +import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; +import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionExecutor; +import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionRequestBuilder; +import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionResponseProcessor; +import org.wso2.carbon.identity.flow.extension.management.InFlowExtensionActionConverter; +import org.wso2.carbon.identity.flow.extension.management.InFlowExtensionActionDTOModelResolver; + +/** + * OSGi declarative services component which registers the In-Flow Extension services. + */ +@Component( + name = "flow.extension.component", + immediate = true) +public class InFlowExtensionServiceComponent { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionServiceComponent.class); + + @Activate + protected void activate(ComponentContext context) { + + try { + BundleContext bundleContext = context.getBundleContext(); + + bundleContext.registerService(Executor.class.getName(), new InFlowExtensionExecutor(), null); + bundleContext.registerService(ActionExecutionRequestBuilder.class.getName(), + new InFlowExtensionRequestBuilder(), null); + bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(), + new InFlowExtensionResponseProcessor(), null); + + bundleContext.registerService(ActionConverter.class.getName(), + new InFlowExtensionActionConverter(), null); + bundleContext.registerService(ActionDTOModelResolver.class.getName(), + new InFlowExtensionActionDTOModelResolver( + InFlowExtensionDataHolder.getInstance().getCertificateManagementService()), + null); + + LOG.debug("In-Flow Extension service successfully activated."); + } catch (Throwable e) { + LOG.error("Error while initiating In-Flow Extension service", e); + } + } + + @Deactivate + protected void deactivate(ComponentContext context) { + + LOG.debug("In-Flow Extension service successfully deactivated."); + } + + @Reference( + name = "ActionManagementService", + service = ActionManagementService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetActionManagementService" + ) + protected void setActionManagementService(ActionManagementService actionManagementService) { + + LOG.debug("Setting the ActionManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance().setActionManagementService(actionManagementService); + } + + protected void unsetActionManagementService(ActionManagementService actionManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionManagementService in the In-Flow Extension component. Service: " + + actionManagementService); + } + InFlowExtensionDataHolder.getInstance().setActionManagementService(null); + } + + @Reference( + name = "ActionExecutorService", + service = ActionExecutorService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetActionExecutorService" + ) + protected void setActionExecutorService(ActionExecutorService actionExecutorService) { + + LOG.debug("Setting the ActionExecutorService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance().setActionExecutorService(actionExecutorService); + } + + protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionExecutorService in the In-Flow Extension component. Service: " + + actionExecutorService); + } + InFlowExtensionDataHolder.getInstance().setActionExecutorService(null); + } + + @Reference( + name = "CertificateManagementService", + service = CertificateManagementService.class, + cardinality = ReferenceCardinality.MANDATORY, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetCertificateManagementService" + ) + protected void setCertificateManagementService(CertificateManagementService certificateManagementService) { + + LOG.debug("Setting the CertificateManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance() + .setCertificateManagementService(certificateManagementService); + } + + protected void unsetCertificateManagementService( + CertificateManagementService certificateManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the CertificateManagementService in the In-Flow Extension component. Service: " + + certificateManagementService); + } + InFlowExtensionDataHolder.getInstance().setCertificateManagementService(null); + } + + @Reference( + name = "ClaimMetadataManagementService", + service = ClaimMetadataManagementService.class, + cardinality = ReferenceCardinality.OPTIONAL, + policy = ReferencePolicy.DYNAMIC, + unbind = "unsetClaimMetadataManagementService" + ) + protected void setClaimMetadataManagementService( + ClaimMetadataManagementService claimMetadataManagementService) { + + LOG.debug("Setting the ClaimMetadataManagementService in the In-Flow Extension component."); + InFlowExtensionDataHolder.getInstance() + .setClaimMetadataManagementService(claimMetadataManagementService); + } + + protected void unsetClaimMetadataManagementService( + ClaimMetadataManagementService claimMetadataManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ClaimMetadataManagementService in the In-Flow Extension component. Service: " + + claimMetadataManagementService); + } + InFlowExtensionDataHolder.getInstance().setClaimMetadataManagementService(null); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionConverter.java new file mode 100644 index 000000000000..755f42926d14 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionConverter.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.management; + +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; +import org.wso2.carbon.identity.action.management.api.model.ActionProperty; +import org.wso2.carbon.identity.action.management.api.service.ActionConverter; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.flow.extension.model.AccessConfig; +import org.wso2.carbon.identity.flow.extension.model.Encryption; +import org.wso2.carbon.identity.flow.extension.model.ContextPath; +import org.wso2.carbon.identity.flow.extension.model.InFlowExtensionAction; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ICON_URL; + +/** + * ActionConverter implementation for In-Flow Extension actions. + *

    + * Handles the conversion between {@link InFlowExtensionAction} (domain model) and + * {@link ActionDTO} (data transfer object) by mapping the {@link AccessConfig} fields + * to/from action properties. + *

    + */ +public class InFlowExtensionActionConverter implements ActionConverter { + + @Override + public Action.ActionTypes getSupportedActionType() { + + return Action.ActionTypes.FLOW_EXTENSION; + } + + /** + * Converts an {@link InFlowExtensionAction} to an {@link ActionDTO} for persistence. + * Maps the access config fields (expose, modify) and flow type overrides + * into the DTO's properties map using prefixed keys. + * + * @param action The InFlowExtensionAction to convert. + * @return ActionDTO with access config properties. + */ + @Override + public ActionDTO buildActionDTO(Action action) { + + if (!(action instanceof InFlowExtensionAction inFlowExtensionAction)) { + return new ActionDTO.Builder(action).build(); + } + + Map properties = new HashMap<>(); + putDefaultAccessConfigProperties(properties, inFlowExtensionAction.getAccessConfig()); + putEncryptionProperty(properties, inFlowExtensionAction.getEncryption()); + if (inFlowExtensionAction.getIconUrl() != null) { + properties.put(ICON_URL, + new ActionProperty.BuilderForService(inFlowExtensionAction.getIconUrl()).build()); + } + putFlowTypeOverrideProperties(properties, inFlowExtensionAction.getFlowTypeOverrides()); + + return new ActionDTO.Builder(inFlowExtensionAction) + .properties(properties) + .build(); + } + + private void putDefaultAccessConfigProperties(Map properties, AccessConfig accessConfig) { + + if (accessConfig == null) { + return; + } + if (accessConfig.getExpose() != null) { + properties.put(ACCESS_CONFIG_EXPOSE, + new ActionProperty.BuilderForService(accessConfig.getExpose()).build()); + } + if (accessConfig.getModify() != null) { + properties.put(ACCESS_CONFIG_MODIFY, + new ActionProperty.BuilderForService(accessConfig.getModify()).build()); + } + } + + private void putEncryptionProperty(Map properties, Encryption encryption) { + + if (encryption == null) { + return; + } + if (encryption.getCertificate() != null) { + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService(encryption.getCertificate()).build()); + } else { + // Encryption object present but no certificate — signals explicit removal. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService("").build()); + } + } + + private void putFlowTypeOverrideProperties(Map properties, + Map overrides) { + + if (overrides == null) { + return; + } + for (Map.Entry entry : overrides.entrySet()) { + String flowType = entry.getKey(); + AccessConfig overrideConfig = entry.getValue(); + if (overrideConfig.getExpose() != null) { + properties.put(ACCESS_CONFIG_EXPOSE_PREFIX + flowType, + new ActionProperty.BuilderForService(overrideConfig.getExpose()).build()); + } + if (overrideConfig.getModify() != null) { + properties.put(ACCESS_CONFIG_MODIFY_PREFIX + flowType, + new ActionProperty.BuilderForService(overrideConfig.getModify()).build()); + } + } + } + + /** + * Converts an {@link ActionDTO} back to an {@link InFlowExtensionAction}. + * Reconstructs the default {@link AccessConfig} and per-flow-type overrides from the DTO's properties map. + * + * @param actionDTO The ActionDTO to convert. + * @return InFlowExtensionAction with access config and overrides populated. + */ + @SuppressWarnings("unchecked") + @Override + public Action buildAction(ActionDTO actionDTO) { + + // Default access config. + List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + List modify = + (List) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + + AccessConfig accessConfig = null; + if (expose != null || modify != null) { + accessConfig = new AccessConfig(expose, modify); + } + + // Encryption certificate (separate from access config). + Encryption encryption = null; + Object certValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certValue instanceof Certificate certificate) { + encryption = new Encryption(certificate); + } + + // Icon URL. + String iconUrl = null; + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue instanceof String iconUrlStr) { + iconUrl = iconUrlStr; + } + + // Reconstruct per-flow-type overrides from prefixed keys. + Map flowTypeOverrides = new HashMap<>(); + if (actionDTO.getProperties() != null) { + for (String propertyKey : actionDTO.getProperties().keySet()) { + if (propertyKey.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + String flowType = propertyKey.substring(ACCESS_CONFIG_EXPOSE_PREFIX.length()); + AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, + new AccessConfig(null, null)); + flowTypeOverrides.put(flowType, new AccessConfig( + (List) actionDTO.getPropertyValue(propertyKey), + existing.getModify())); + } else if (propertyKey.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + String flowType = propertyKey.substring(ACCESS_CONFIG_MODIFY_PREFIX.length()); + AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, + new AccessConfig(null, null)); + flowTypeOverrides.put(flowType, new AccessConfig( + existing.getExpose(), + (List) actionDTO.getPropertyValue(propertyKey))); + } + } + } + + return new InFlowExtensionAction.ResponseBuilder() + .id(actionDTO.getId()) + .type(actionDTO.getType()) + .name(actionDTO.getName()) + .description(actionDTO.getDescription()) + .status(actionDTO.getStatus()) + .actionVersion(actionDTO.getActionVersion()) + .createdAt(actionDTO.getCreatedAt()) + .updatedAt(actionDTO.getUpdatedAt()) + .endpoint(actionDTO.getEndpoint()) + .accessConfig(accessConfig) + .encryption(encryption) + .iconUrl(iconUrl) + .flowTypeOverrides(flowTypeOverrides) + .rule(actionDTO.getActionRule()) + .build(); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionDTOModelResolver.java new file mode 100644 index 000000000000..08587d2ea256 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/InFlowExtensionActionDTOModelResolver.java @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.management; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverClientException; +import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverException; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.ActionDTO; +import org.wso2.carbon.identity.action.management.api.model.ActionProperty; +import org.wso2.carbon.identity.action.management.api.model.BinaryObject; +import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver; +import org.wso2.carbon.identity.certificate.management.exception.CertificateMgtException; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; +import org.wso2.carbon.identity.flow.extension.model.ContextPath; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptyList; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.ICON_URL; +import static org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; + +/** + * ActionDTOModelResolver implementation for In-Flow Extension actions. + *

    + * Responsible for validating and transforming access config properties (expose paths and + * modify paths) between the service layer representation and the DAO layer BLOB format. + *

    + * + *
      + *
    • Add operation: Validates expose paths and modify paths, then serializes + * them to JSON {@link BinaryObject}s for BLOB storage in IDN_ACTION_PROPERTIES.
    • + *
    • Get operation: Deserializes BLOBs back to service-layer list objects.
    • + *
    • Update operation: Validates updated values or preserves existing ones (PUT semantics).
    • + *
    • Delete operation: No-op (properties are cascade-deleted with the action).
    • + *
    + */ +public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelResolver { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> CONTEXT_PATH_LIST_TYPE_REF = + new TypeReference>() { }; + + private final CertificateManagementService certificateManagementService; + + public InFlowExtensionActionDTOModelResolver(CertificateManagementService certificateManagementService) { + + this.certificateManagementService = certificateManagementService; + } + + @Override + public Action.ActionTypes getSupportedActionType() { + + return Action.ActionTypes.FLOW_EXTENSION; + } + + @Override + public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + // Expose is an optional field. + if (exposeValue != null) { + List validatedExpose = validateExpose(exposeValue); + properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose)); + } + + Object modifyValue = actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + // Modify is an optional field. + if (modifyValue != null) { + List validatedModify = validateExpose(modifyValue); + properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(validatedModify)); + } + + // Handle certificate: store via CertificateManagementService and replace with ID. + handleCertificateAdd(actionDTO, properties, tenantDomain); + + // Handle icon URL: pass through as a PRIMITIVE string. + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue instanceof String iconUrlStr && !iconUrlStr.isEmpty()) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO(iconUrlStr).build()); + } + + // Handle per-flow-type override properties (prefixed keys). + resolveAddOverrideProperties(actionDTO, properties); + + return new ActionDTO.Builder(actionDTO) + .properties(properties) + .build(); + } + + private void resolveAddOverrideProperties(ActionDTO actionDTO, Map properties) + throws ActionDTOModelResolverException { + + if (actionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : actionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + Object overrideExpose = actionDTO.getPropertyValue(key); + if (overrideExpose != null) { + properties.put(key, createBlobProperty(validateExpose(overrideExpose))); + } + } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + Object overrideModify = actionDTO.getPropertyValue(key); + if (overrideModify != null) { + properties.put(key, createBlobProperty(validateExpose(overrideModify))); + } + } + } + } + + @Override + public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + // Default access config properties. + if (actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + properties.put(ACCESS_CONFIG_EXPOSE, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)).getJSONString())); + } + + if (actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + properties.put(ACCESS_CONFIG_MODIFY, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)).getJSONString())); + } + + // Retrieve certificate by stored ID. + handleCertificateGet(actionDTO, properties, tenantDomain); + + // Icon URL: pass through as-is (already a PRIMITIVE string). + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue != null) { + properties.put(ICON_URL, new ActionProperty.BuilderForService(iconUrlValue.toString()).build()); + } + + // Deserialize per-flow-type override properties (prefixed keys). + if (actionDTO.getProperties() != null) { + for (Map.Entry entry : actionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if ((key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) + && actionDTO.getPropertyValue(key) != null) { + properties.put(key, deserializeExposeProperty( + ((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString())); + } + } + } + + return new ActionDTO.Builder(actionDTO) + .properties(properties) + .build(); + } + + @Override + public List resolveForGetOperation(List actionDTOList, String tenantDomain) + throws ActionDTOModelResolverException { + + List resolvedList = new ArrayList<>(); + for (ActionDTO actionDTO : actionDTOList) { + resolvedList.add(resolveForGetOperation(actionDTO, tenantDomain)); + } + return resolvedList; + } + + /** + * Resolves the actionDTO for the update operation. + * When properties are updated, the existing properties are replaced with the new properties. + * When properties are not updated, the existing properties should be sent to the upstream component. + * + * @param updatingActionDTO ActionDTO that needs to be updated. + * @param existingActionDTO Existing ActionDTO. + * @param tenantDomain Tenant domain. + * @return Resolved ActionDTO. + * @throws ActionDTOModelResolverException ActionDTOModelResolverException. + */ + @Override + public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + String tenantDomain) throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + // Action Properties updating operation is treated as a PUT in DAO layer. Therefore if no properties are + // updated the existing properties should be sent to the DAO layer. + + // Handle default access config properties. + List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO); + if (!expose.isEmpty()) { + properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose)); + } + + List modify = getResolvedUpdatingModify(updatingActionDTO, existingActionDTO); + if (!modify.isEmpty()) { + properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(modify)); + } + + // Handle certificate update. + handleCertificateUpdate(updatingActionDTO, existingActionDTO, properties, tenantDomain); + resolveUpdateIconUrl(updatingActionDTO, existingActionDTO, properties); + carryForwardExistingOverrides(existingActionDTO, properties); + overlayUpdatedOverrides(updatingActionDTO, properties); + + return new ActionDTO.Builder(updatingActionDTO) + .properties(properties) + .build(); + } + + private void resolveUpdateIconUrl(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + Map properties) + throws ActionDTOModelResolverException { + + Object updatingIconUrl = updatingActionDTO.getPropertyValue(ICON_URL); + if (updatingIconUrl instanceof String updatingIconUrlStr && !updatingIconUrlStr.isEmpty()) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO(updatingIconUrlStr).build()); + } else if (existingActionDTO.getPropertyValue(ICON_URL) != null) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO( + existingActionDTO.getPropertyValue(ICON_URL).toString()).build()); + } + } + + private void carryForwardExistingOverrides(ActionDTO existingActionDTO, + Map properties) + throws ActionDTOModelResolverException { + + if (existingActionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : existingActionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX) || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + properties.put(key, createBlobProperty(existingActionDTO.getPropertyValue(key))); + } + } + } + + private void overlayUpdatedOverrides(ActionDTO updatingActionDTO, Map properties) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getProperties() == null) { + return; + } + for (Map.Entry entry : updatingActionDTO.getProperties().entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) { + Object overrideExpose = updatingActionDTO.getPropertyValue(key); + if (overrideExpose != null) { + properties.put(key, createBlobProperty(validateExpose(overrideExpose))); + } + } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) { + Object overrideModify = updatingActionDTO.getPropertyValue(key); + if (overrideModify != null) { + properties.put(key, createBlobProperty(validateExpose(overrideModify))); + } + } + } + } + + @Override + public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + // Delete the certificate if one was stored for this action. + handleCertificateDelete(deletingActionDTO, tenantDomain); + } + + // ---- Update helpers ---- + + @SuppressWarnings("unchecked") + private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)); + } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) { + return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE); + } + return emptyList(); + } + + @SuppressWarnings("unchecked") + private List getResolvedUpdatingModify(ActionDTO updatingActionDTO, + ActionDTO existingActionDTO) + throws ActionDTOModelResolverException { + + if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)); + } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) { + return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + } + return emptyList(); + } + + // ---- Validation ---- + + @SuppressWarnings("unchecked") + private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException { + + if (!(exposeValue instanceof List)) { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Expose should be provided as a list."); + } + + List exposeList = (List) exposeValue; + List result = new ArrayList<>(); + + for (Object item : exposeList) { + if (item instanceof Map) { + Map map = (Map) item; + if (!map.containsKey("path") || !(map.get("path") instanceof String)) { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Each expose entry must be an object with a 'path' field."); + } + String path = (String) map.get("path"); + boolean encrypted = map.containsKey("encrypted") && toBooleanSafe(map.get("encrypted")); + result.add(new ContextPath(path, encrypted)); + } else if (item instanceof ContextPath contextPath) { + result.add(contextPath); + } else { + throw new ActionDTOModelResolverClientException("Invalid expose format.", + "Each expose entry must be an object with 'path' and optional 'encrypted' fields."); + } + } + + validateExposeCount(result); + validateContextPathFormat(result); + return result; + } + + private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException { + + if (expose.size() > MAX_EXPOSE_PATHS) { + throw new ActionDTOModelResolverClientException("Maximum expose paths limit exceeded.", + String.format("The number of configured expose paths: %d exceeds the maximum allowed limit: %d.", + expose.size(), MAX_EXPOSE_PATHS)); + } + } + + private void validateContextPathFormat(List expose) throws ActionDTOModelResolverClientException { + + Set seen = new HashSet<>(); + for (ContextPath exposePath : expose) { + String path = exposePath.getPath(); + if (path == null || path.trim().isEmpty()) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + "Expose paths must not be null or empty."); + } + if (!path.startsWith("/")) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + String.format("Expose path '%s' must start with '/'.", path)); + } + if (path.endsWith("/")) { + throw new ActionDTOModelResolverClientException("Invalid expose path.", + String.format("Expose path '%s' must not end with a trailing '/'.", path)); + } + if (!seen.add(path)) { + throw new ActionDTOModelResolverClientException("Duplicate expose path.", + String.format("The expose path '%s' is duplicated.", path)); + } + } + } + + // ---- Certificate lifecycle helpers ---- + + /** + * Stores the external service's certificate via CertificateManagementService during action creation. + * The certificate PEM is replaced with the stored certificate's ID as a PRIMITIVE property. + */ + private void handleCertificateAdd(ActionDTO actionDTO, Map properties, + String tenantDomain) throws ActionDTOModelResolverException { + + Object certValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certValue == null) { + return; + } + + String certificatePEM = extractCertificatePEM(certValue); + String certName = CERTIFICATE_NAME_PREFIX + actionDTO.getId(); + + try { + String certificateId = certificateManagementService.addCertificate( + new Certificate.Builder() + .name(certName) + .certificateContent(certificatePEM) + .build(), + tenantDomain); + + // Store the certificate ID as a primitive property so DAO persists just the ID. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(certificateId).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error storing certificate for action: " + + actionDTO.getId(), e); + } + } + + /** + * Retrieves the certificate by its stored ID during action get operations. + * Replaces the stored ID with the full Certificate object as a service-layer property. + */ + private void handleCertificateGet(ActionDTO actionDTO, Map properties, + String tenantDomain) throws ActionDTOModelResolverException { + + Object certIdValue = actionDTO.getPropertyValue(CERTIFICATE); + if (certIdValue == null) { + return; + } + + try { + String certIdStr = certIdValue.toString(); + Certificate certificate = certificateManagementService.getCertificate( + certIdStr, tenantDomain); + properties.put(CERTIFICATE, + new ActionProperty.BuilderForService(certificate).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error retrieving certificate for action: " + + actionDTO.getId(), e); + } + } + + /** + * Handles certificate lifecycle during action update: add new, update existing, delete, or carry forward. + */ + private void handleCertificateUpdate(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + Map properties, String tenantDomain) + throws ActionDTOModelResolverException { + + Object newCertValue = updatingActionDTO.getPropertyValue(CERTIFICATE); + Object existingCertValue = existingActionDTO.getPropertyValue(CERTIFICATE); + + // Empty string signals explicit certificate removal. + boolean isExplicitRemoval = newCertValue instanceof String s && s.isEmpty(); + + if (isExplicitRemoval && existingCertValue != null) { + // Explicitly clearing the certificate — delete the existing one. + try { + String existingCertId = extractCertificateId(existingCertValue); + certificateManagementService.deleteCertificate(existingCertId, tenantDomain); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error deleting certificate for action: " + + updatingActionDTO.getId(), e); + } + } else if (newCertValue != null && !isExplicitRemoval && existingCertValue != null) { + // Update existing certificate. + String certificatePEM = extractCertificatePEM(newCertValue); + try { + String existingCertId = extractCertificateId(existingCertValue); + certificateManagementService.updateCertificateContent( + existingCertId, certificatePEM, tenantDomain); + // Carry forward the existing certificate ID. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(existingCertId).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error updating certificate for action: " + + updatingActionDTO.getId(), e); + } + } else if (newCertValue != null && !isExplicitRemoval) { + // Add new certificate (previously had none). + handleCertificateAdd(updatingActionDTO, properties, tenantDomain); + } else if (existingCertValue != null) { + // No new certificate provided — carry forward the existing one (PUT semantics). + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(extractCertificateId(existingCertValue)).build()); + } + // else: both null — no certificate to handle. + } + + /** + * Deletes the certificate from IDN_CERTIFICATE during action deletion. + */ + private void handleCertificateDelete(ActionDTO deletingActionDTO, String tenantDomain) + throws ActionDTOModelResolverException { + + Object certIdValue = deletingActionDTO.getPropertyValue(CERTIFICATE); + if (certIdValue == null) { + return; + } + + try { + String certId = extractCertificateId(certIdValue); + certificateManagementService.deleteCertificate(certId, tenantDomain); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error deleting certificate for action: " + + deletingActionDTO.getId(), e); + } + } + + /** + * Extracts the certificate UUID from a certificate property value. + *

    + * The existing action DTO may come from the GET resolver, which replaces the stored UUID + * with the full {@link Certificate} object. This method handles both cases: + * - {@link Certificate} object: extracts the ID via {@code getId()}. + * - String: assumes it is already the UUID. + *

    + */ + private String extractCertificateId(Object certValue) { + + if (certValue instanceof Certificate certificate) { + return certificate.getId(); + } + return certValue.toString(); + } + + /** + * Extracts the PEM string from a certificate value, which may be a Certificate object, + * a Map, or a plain string. + */ + private String extractCertificatePEM(Object certValue) throws ActionDTOModelResolverClientException { + + if (certValue instanceof Certificate certificate) { + return certificate.getCertificateContent(); + } else if (certValue instanceof Map) { + Map certMap = (Map) certValue; + Object content = certMap.get("certificateContent"); + if (content instanceof String pem) { + return pem; + } + throw new ActionDTOModelResolverClientException("Invalid certificate format.", + "Certificate object must contain a 'certificateContent' field."); + } else if (certValue instanceof String pem) { + return pem; + } + throw new ActionDTOModelResolverClientException("Invalid certificate format.", + "Certificate must be a PEM string, a Certificate object, or a map with 'certificateContent'."); + } + + // ---- Serialization helpers ---- + + private ActionProperty createBlobProperty(Object value) throws ActionDTOModelResolverException { + + try { + BinaryObject binaryObject = BinaryObject.fromJsonString(OBJECT_MAPPER.writeValueAsString(value)); + return new ActionProperty.BuilderForDAO(binaryObject).build(); + } catch (JsonProcessingException e) { + throw new ActionDTOModelResolverException("Failed to serialize access config property to JSON.", e); + } + } + + private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException { + + try { + List expose = OBJECT_MAPPER.readValue(jsonString, CONTEXT_PATH_LIST_TYPE_REF); + return new ActionProperty.BuilderForService(expose).build(); + } catch (IOException e) { + throw new ActionDTOModelResolverException("Error reading expose values from storage.", e); + } + } + + /** + * Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types. + * Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}. + * + * @param value The value to convert. + * @return {@code true} if the value is Boolean TRUE or the string "true" (case-insensitive). + */ + private static boolean toBooleanSafe(Object value) { + + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return Boolean.parseBoolean(s); + } + return false; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilder.java new file mode 100644 index 000000000000..4dd5bf92d4f9 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilder.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Builds the controlled In-Flow Extension context tree returned by the metadata endpoint. + * Consumes the engine-level {@link FlowContextHandoverConfig} flat allow-lists and projects + * them back into the user/flow/properties tree shape the Console UI expects, so the frontend + * requires no change. Nodes are pruned when the corresponding attribute is absent from the + * allow-list. + */ +public class InFlowExtensionContextTreeBuilder { + + // Flow type identifiers — must match the values produced by FlowTypes.getType(). + private static final String FLOW_REGISTRATION = "REGISTRATION"; + private static final String FLOW_INVITED_USER_REGISTRATION = "INVITED_USER_REGISTRATION"; + private static final String FLOW_PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; + + // Node-type sentinels matching the tree component's NodeType enum on the Console side. + private static final String NODE_OBJECT = "OBJECT"; + private static final String NODE_LEAF = "LEAF"; + private static final String NODE_MAP = "MAP"; + private static final String NODE_COMPLEX_MAP = "COMPLEX_MAP"; + + private static final String OP_EXPOSE = "EXPOSE"; + private static final String OP_MODIFY = "MODIFY"; + private static final String DATA_TYPE_STRING = "String"; + + // Flow context attributes that appear under the "flow" branch of the tree. + private static final List FLOW_BRANCH_ATTRS = + Arrays.asList("tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl"); + + private final FlowContextHandoverConfig handoverConfig; + + public InFlowExtensionContextTreeBuilder(FlowContextHandoverConfig handoverConfig) { + + this.handoverConfig = handoverConfig; + } + + /** + * Build the metadata response for the given flow type. + * + * @param flowType the flow type (null → default tree). + * @return a fully populated metadata DTO. + */ + public InFlowExtensionContextTreeMetadata build(String flowType) { + + Set attrs = handoverConfig.getIncludedAttributes(); + Set userAttrs = handoverConfig.isFullUserPassthrough() + ? null // null → all user children shown + : handoverConfig.getIncludedUserAttributes(); + + List tree = new ArrayList<>(); + + // User and properties nodes are always non-null (always construct and return a node). + tree.add(buildUserNode(userAttrs)); + tree.add(buildPropertiesNode(attrs)); + + // Flow node is conditionally non-null (returns null if no flow attributes are configured). + InFlowExtensionContextTreeNode flowNode = buildFlowNode(attrs); + if (flowNode != null) { + tree.add(flowNode); + } + + return new InFlowExtensionContextTreeMetadata( + flowType, + tree, + true, // redirection is unconditionally enabled + resolveAllowReadOnlyClaimsModification(flowType)); + } + + /** + * Whether the Console UI may permit MODIFY on read-only claims for this flow type. + * Hardcoded enumerative mapping so that any future flow type defaults to false until + * explicitly added here. + * + *

    The default tree (null flowType) returns true — matches current behaviour for the + * connection-level access-config editor which doesn't yet know which flow the action + * will be wired into.

    + */ + static boolean resolveAllowReadOnlyClaimsModification(String flowType) { + + if (flowType == null) { + return true; + } + switch (flowType) { + case FLOW_REGISTRATION, FLOW_INVITED_USER_REGISTRATION: + return true; + case FLOW_PASSWORD_RECOVERY: + default: + return false; + } + } + + /** + * Build the "user" subtree. + * + *

    Strategy: + *

      + *
    • Read-only fields (id, username, userStoreDomain): only emitted when the + * attribute is in the allow-list (or full-passthrough is active). They support EXPOSE + * only, so excluding them when restricted is the correct behaviour.
    • + *
    • Modifiable fields (claims, credentials): always emitted because modifications + * bypass the context handover — they travel through the executor response and are applied + * by the task execution node. EXPOSE is added only when the attribute is configured.
    • + *
    + * + *

    {@code userAttrs == null} signals full-passthrough (show and expose everything). + */ + private InFlowExtensionContextTreeNode buildUserNode(Set userAttrs) { + + // userAttrs == null → full passthrough set by the caller (build()). + boolean fullPassthrough = (userAttrs == null); + + List children = new ArrayList<>(); + + // ── Read-only fields: emit only when exposed ──────────────────────────────────────── + if (fullPassthrough || userAttrs.contains("id")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("id") + .title("User ID") + .path("/user/id") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + if (fullPassthrough || userAttrs.contains("username")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("username") + .title("Username") + .path("/user/username") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + if (fullPassthrough || userAttrs.contains("userStoreDomain")) { + children.add(InFlowExtensionContextTreeNode.builder() + .key("userStoreDomain") + .title("User Store Domain") + .path("/user/userStoreDomain") + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .replaceable(false) + .build()); + } + + // ── Modifiable fields: always present, restrict EXPOSE when not configured ────────── + List claimsOps = (fullPassthrough || userAttrs.contains("claims")) + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + children.add(InFlowExtensionContextTreeNode.builder() + .key("claims") + .title("Claims") + .path("/user/claims/") + .dataType("Map") + .nodeType(NODE_MAP) + .allowedOperations(claimsOps) + .dynamicEntryAllowed(true) + .dynamicEntryType("String") + .children(Collections.emptyList()) + .build()); + + List credOps = (fullPassthrough || userAttrs.contains("userCredentials")) + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + children.add(InFlowExtensionContextTreeNode.builder() + .key("credentials") + .title("Credentials") + .path("/user/credentials/") + .dataType("Map") + .nodeType(NODE_MAP) + .allowedOperations(credOps) + .dynamicEntryAllowed(true) + .dynamicEntryType("char[]") + .children(Collections.emptyList()) + .build()); + + // User node is always returned (claims + credentials are always present). + return InFlowExtensionContextTreeNode.builder() + .key("user") + .title("User") + .path("/user/") + .dataType("") + .nodeType(NODE_OBJECT) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .children(children) + .build(); + } + + /** + * Build the "flow" subtree from top-level context attributes that map to the flow branch. + */ + private InFlowExtensionContextTreeNode buildFlowNode(Set attrs) { + + List children = new ArrayList<>(); + for (String attr : FLOW_BRANCH_ATTRS) { + if (attrs.contains(attr)) { + String title = attrTitle(attr); + children.add(flowLeaf(attr, title, "/flow/" + attr)); + } + } + if (children.isEmpty()) { + return null; + } + return InFlowExtensionContextTreeNode.builder() + .key("flow") + .title("Flow") + .path("/flow/") + .dataType("") + .nodeType(NODE_OBJECT) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .readOnly(true) + .children(children) + .build(); + } + + private InFlowExtensionContextTreeNode flowLeaf(String key, String title, String path) { + + return InFlowExtensionContextTreeNode.builder() + .key(key) + .title(title) + .path(path) + .dataType(DATA_TYPE_STRING) + .nodeType(NODE_LEAF) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .readOnly(true) + .build(); + } + + /** + * Build the "properties" node. + * + *

    Properties is always emitted because modifications bypass the context handover (they + * travel through the executor response and are applied by the task execution node). + * EXPOSE is included only when {@code "properties"} is in the allow-list. + */ + private InFlowExtensionContextTreeNode buildPropertiesNode(Set attrs) { + + List ops = attrs.contains("properties") + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + return InFlowExtensionContextTreeNode.builder() + .key("properties") + .title("Properties") + .path("/properties/") + .dataType("Map") + .nodeType(NODE_COMPLEX_MAP) + .allowedOperations(ops) + .dynamicEntryAllowed(true) + .dynamicEntryType("Object") + .children(Collections.emptyList()) + .build(); + } + + private static String attrTitle(String attr) { + + switch (attr) { + case "tenantDomain": return "Tenant Domain"; + case "applicationId": return "Application ID"; + case "flowType": return "Flow Type"; + case "callbackUrl": return "Callback URL"; + case "portalUrl": return "Portal URL"; + default: return attr; + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeMetadata.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeMetadata.java new file mode 100644 index 000000000000..52f7fd4786a9 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeMetadata.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import java.util.Collections; +import java.util.List; + +/** + * Metadata response served by the {@code GET /flow/in-flow-extension/context-tree} endpoint. + * Carries the controlled context tree (filtered by {@code FlowContextHandoverPolicy}) plus + * per-flow-type policy flags that the Console UI uses to gate the access-config editor. + */ +public class InFlowExtensionContextTreeMetadata { + + private final String flowType; + private final List contextTree; + private final boolean redirectionEnabled; + private final boolean allowReadOnlyClaimsModification; + + public InFlowExtensionContextTreeMetadata(String flowType, + List contextTree, + boolean redirectionEnabled, + boolean allowReadOnlyClaimsModification) { + + this.flowType = flowType; + this.contextTree = contextTree != null ? Collections.unmodifiableList(contextTree) + : Collections.emptyList(); + this.redirectionEnabled = redirectionEnabled; + this.allowReadOnlyClaimsModification = allowReadOnlyClaimsModification; + } + + /** + * @return The flow type this metadata applies to. {@code null} indicates the default tree. + */ + public String getFlowType() { + + return flowType; + } + + public List getContextTree() { + + return contextTree; + } + + public boolean isRedirectionEnabled() { + + return redirectionEnabled; + } + + public boolean isAllowReadOnlyClaimsModification() { + + return allowReadOnlyClaimsModification; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeNode.java new file mode 100644 index 000000000000..0ee2a00bbd98 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeNode.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import java.util.Collections; +import java.util.List; + +/** + * Single node in the In-Flow Extension context tree returned by the metadata service. + * Mirrors the shape of {@code default-flow-context-tree.json} on the Console side so the + * existing tree component can render this without translation. + */ +public class InFlowExtensionContextTreeNode { + + private final String key; + private final String title; + private final String path; + private final String dataType; + private final String nodeType; + private final List allowedOperations; + private final boolean readOnly; + private final boolean replaceable; + private final boolean dynamicEntryAllowed; + private final String dynamicEntryType; + private final List children; + + private InFlowExtensionContextTreeNode(Builder b) { + + this.key = b.key; + this.title = b.title; + this.path = b.path; + this.dataType = b.dataType; + this.nodeType = b.nodeType; + this.allowedOperations = b.allowedOperations != null + ? Collections.unmodifiableList(b.allowedOperations) : Collections.emptyList(); + this.readOnly = b.readOnly; + this.replaceable = b.replaceable; + this.dynamicEntryAllowed = b.dynamicEntryAllowed; + this.dynamicEntryType = b.dynamicEntryType; + this.children = b.children != null + ? Collections.unmodifiableList(b.children) : Collections.emptyList(); + } + + public String getKey() { + + return key; + } + + public String getTitle() { + + return title; + } + + public String getPath() { + + return path; + } + + public String getDataType() { + + return dataType; + } + + public String getNodeType() { + + return nodeType; + } + + public List getAllowedOperations() { + + return allowedOperations; + } + + public boolean isReadOnly() { + + return readOnly; + } + + public boolean isReplaceable() { + + return replaceable; + } + + public boolean isDynamicEntryAllowed() { + + return dynamicEntryAllowed; + } + + public String getDynamicEntryType() { + + return dynamicEntryType; + } + + public List getChildren() { + + return children; + } + + public static Builder builder() { + + return new Builder(); + } + + public static final class Builder { + + private String key; + private String title; + private String path; + private String dataType = ""; + private String nodeType; + private List allowedOperations; + private boolean readOnly; + private boolean replaceable; + private boolean dynamicEntryAllowed; + private String dynamicEntryType; + private List children; + + public Builder key(String v) { + + this.key = v; + return this; + } + + public Builder title(String v) { + + this.title = v; + return this; + } + + public Builder path(String v) { + + this.path = v; + return this; + } + + public Builder dataType(String v) { + + this.dataType = v; + return this; + } + + public Builder nodeType(String v) { + + this.nodeType = v; + return this; + } + + public Builder allowedOperations(List v) { + + this.allowedOperations = v; + return this; + } + + public Builder readOnly(boolean v) { + + this.readOnly = v; + return this; + } + + public Builder replaceable(boolean v) { + + this.replaceable = v; + return this; + } + + public Builder dynamicEntryAllowed(boolean v) { + + this.dynamicEntryAllowed = v; + return this; + } + + public Builder dynamicEntryType(String v) { + + this.dynamicEntryType = v; + return this; + } + + public Builder children(List v) { + + this.children = v; + return this; + } + + public InFlowExtensionContextTreeNode build() { + + return new InFlowExtensionContextTreeNode(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeService.java new file mode 100644 index 000000000000..2082c96ef841 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; + +/** + * Public-API entry point for retrieving the controlled In-Flow Extension context tree. + * Lives in the {@code metadata} package (which is exported by the engine OSGi bundle), so + * external bundles such as the flow-management API server can call it without depending on + * the engine's {@code internal} package. + * + *

    Delegates config lookup to {@link FlowExecutionEngineDataHolder}, which owns the + * engine-level {@link FlowContextHandoverConfig} singleton.

    + */ +public final class InFlowExtensionContextTreeService { + + private static final InFlowExtensionContextTreeService INSTANCE = new InFlowExtensionContextTreeService(); + + private InFlowExtensionContextTreeService() { + + } + + public static InFlowExtensionContextTreeService getInstance() { + + return INSTANCE; + } + + /** + * Build the controlled context tree for the given flow type. + * + * @param flowType the flow type, or null for the default tree. + * @return the metadata DTO carrying the pruned tree + per-flow-type policy flags. + */ + public InFlowExtensionContextTreeMetadata buildContextTree(String flowType) { + + return new InFlowExtensionContextTreeBuilder( + FlowContextHandoverConfig.defaultPolicy()).build(flowType); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/AccessConfig.java new file mode 100644 index 000000000000..7ec835dc2955 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/AccessConfig.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.flow.extension.executor.PathTypeAnnotationUtil; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Access Configuration for In-Flow Extension actions. + *

    + * Defines which parts of the flow context are exposed to the external service + * and which paths the service is allowed to modify. + *

    + * + *
      + *
    • {@code expose} – structured list of {@link ContextPath} entries, each with a hierarchical + * path prefix and an {@code encrypted} flag controlling outbound JWE encryption.
    • + *
    • {@code modify} – structured list of {@link ContextPath} entries defining which paths the + * external service can change. All modifications map to REPLACE operations internally. + * The {@code encrypted} flag on modify paths controls inbound JWE encryption (the external + * service encrypts values, IS decrypts with its private key).
    • + *
    + * + *

    Expose and modify are independent: expose controls what data is sent to the external service, + * while modify controls what data the external service is allowed to change. A path can appear + * in both lists with independent encryption flags.

    + * + *

    Note: The external service's certificate for outbound encryption is held in the + * separate {@link Encryption} model, not in AccessConfig.

    + */ +public class AccessConfig { + + private final List expose; + private final List modify; + + /** + * Constructs an AccessConfig with the given expose and modify paths. + * + * @param expose List of expose path entries. May be {@code null}. + * @param modify List of modify path entries. May be {@code null}. + */ + public AccessConfig(List expose, List modify) { + + this.expose = expose != null ? Collections.unmodifiableList(expose) : null; + this.modify = modify != null ? Collections.unmodifiableList(modify) : null; + } + + /** + * Returns the list of expose path entries. + * + * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. + */ + public List getExpose() { + + return expose; + } + + /** + * Returns the flat list of expose path strings (without encryption metadata). + * Convenience method for components that only need path prefixes. + * + * @return List of path strings, or an empty list if expose is not configured. + */ + public List getExposePaths() { + + if (expose == null) { + return Collections.emptyList(); + } + return expose.stream().map(ContextPath::getPath).collect(Collectors.toList()); + } + + /** + * Returns the list of modify path entries. + * + * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured. + */ + public List getModify() { + + return modify; + } + + /** + * Returns the flat list of modify path strings (without encryption metadata). + * Convenience method for components that only need path strings. + * + * @return List of path strings, or an empty list if modify is not configured. + */ + public List getModifyPaths() { + + if (modify == null) { + return Collections.emptyList(); + } + return modify.stream().map(ContextPath::getPath).collect(Collectors.toList()); + } + + /** + * Check if a given expose path has outbound encryption enabled. + * With leaf-only expose paths, this performs an exact match lookup. + * + * @param path The expose path to check. + * @return {@code true} if the matching expose entry has {@code encrypted = true}. + */ + public boolean isExposePathEncrypted(String path) { + + if (expose == null) { + return false; + } + return expose.stream() + .filter(ep -> ep.getPath().equals(path)) + .findFirst() + .map(ContextPath::isEncrypted) + .orElse(false); + } + + /** + * Check if a given modify path has inbound encryption enabled. + * Matches the modify entry whose path (after stripping type annotations) exactly equals the given path. + * + * @param path The terminal path to check (clean, without annotations). + * @return {@code true} if the matching modify entry has {@code encrypted = true}. + */ + public boolean isModifyPathEncrypted(String path) { + + if (modify == null) { + return false; + } + return modify.stream() + .filter(mp -> path.equals(PathTypeAnnotationUtil.stripAnnotation(mp.getPath())[0])) + .findFirst() + .map(ContextPath::isEncrypted) + .orElse(false); + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/ContextPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/ContextPath.java new file mode 100644 index 000000000000..5a0df2b69818 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/ContextPath.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Domain model for a context path with an optional encryption flag. + * Used for both expose and modify path entries in {@link AccessConfig}. + *

    + * When {@code encrypted} is {@code true}, the value at this path prefix is JWE-encrypted: + * for expose paths, outbound values are encrypted with the external service's certificate; + * for modify paths, inbound values from the external service are encrypted with IS's key. + *

    + */ +public class ContextPath { + + private final String path; + private final boolean encrypted; + + @JsonCreator + public ContextPath(@JsonProperty("path") String path, + @JsonProperty("encrypted") boolean encrypted) { + + this.path = path; + this.encrypted = encrypted; + } + + /** + * Returns the hierarchical path prefix (e.g. {@code "/user/credentials/"}). + * + * @return The path string. + */ + public String getPath() { + + return path; + } + + /** + * Returns whether values at this path should be JWE-encrypted before sending + * to the external service. + * + * @return {@code true} if encryption is enabled for this path. + */ + public boolean isEncrypted() { + + return encrypted; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/Encryption.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/Encryption.java new file mode 100644 index 000000000000..2a61e200fbb9 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/Encryption.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.certificate.management.model.Certificate; + +/** + * Encryption configuration for In-Flow Extension actions. + *

    + * Holds the external service's X.509 public certificate used for outbound JWE encryption. + * This model is separate from {@link AccessConfig} — following the same pattern as + * {@code PasswordSharing} in the PreUpdatePassword action type. + *

    + *

    + * The IS uses this certificate to encrypt expose path values marked {@code encrypted: true} + * before sending them to the external service. For inbound encryption (Extension → IS), + * the external service must obtain the IS's public key out-of-band. + *

    + */ +public class Encryption { + + private final Certificate certificate; + + /** + * Constructs an Encryption configuration with the given certificate. + * + * @param certificate The external service's X.509 public certificate. + * May be {@code null} if encryption is not configured. + */ + public Encryption(Certificate certificate) { + + this.certificate = certificate; + } + + /** + * Returns the external service's X.509 public certificate. + * + * @return The certificate, or {@code null} if not configured. + */ + public Certificate getCertificate() { + + return certificate; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowContextHandoverConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowContextHandoverConfig.java new file mode 100644 index 000000000000..f05294785855 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowContextHandoverConfig.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; + +import java.util.Collections; +import java.util.Set; + +/** + * Immutable snapshot of which {@code FlowExecutionContext} attributes are allowed to be + * handed over to the action framework during an in-flow extension execution. + * + *

    The default policy is sourced from compile-time constants in + * {@link InFlowExtensionConstants.HandoverPolicy}. When the dynamic toml-based + * configuration PR is merged, these constants will serve as the documented defaults.

    + */ +public final class FlowContextHandoverConfig { + + private final Set includedAttributes; + private final Set includedUserAttributes; + private final boolean fullUserPassthrough; + + private FlowContextHandoverConfig(Set includedAttributes, + Set includedUserAttributes, + boolean fullUserPassthrough) { + + this.includedAttributes = Collections.unmodifiableSet(includedAttributes); + this.includedUserAttributes = Collections.unmodifiableSet(includedUserAttributes); + this.fullUserPassthrough = fullUserPassthrough; + } + + /** + * Construct a config from explicit attribute sets. + * {@code fullUserPassthrough} is true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} + * is present in {@code attrs}, meaning the entire FlowUser passes through without per-field filtering. + * + * @param attrs top-level context attributes to include; null is treated as empty. + * @param userAttrs FlowUser attributes to include; null is treated as empty. + * @return a new immutable {@link FlowContextHandoverConfig}. + */ + public static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { + + Set resolvedAttrs = (attrs != null) ? attrs : Collections.emptySet(); + Set resolvedUserAttrs = (userAttrs != null) ? userAttrs : Collections.emptySet(); + boolean fullPassthrough = resolvedAttrs.contains( + InFlowExtensionConstants.HandoverPolicy.ATTR_FLOW_USER); + return new FlowContextHandoverConfig(resolvedAttrs, resolvedUserAttrs, fullPassthrough); + } + + /** + * Returns the default handover policy built from compile-time constants defined in + * {@link InFlowExtensionConstants.HandoverPolicy}. + * + *

    This is the factory method called at runtime by the executor and the context tree + * service. To change the effective policy, update the constants in + * {@link InFlowExtensionConstants.HandoverPolicy}.

    + * + * @return a new {@link FlowContextHandoverConfig} reflecting the default policy. + */ + public static FlowContextHandoverConfig defaultPolicy() { + + return of( + InFlowExtensionConstants.HandoverPolicy.INCLUDED_ATTRIBUTES, + InFlowExtensionConstants.HandoverPolicy.INCLUDED_USER_ATTRIBUTES + ); + } + + /** + * Returns the set of top-level {@code FlowExecutionContext} attribute names that may be + * handed over to the action framework. + * + * @return unmodifiable set of allowed attribute names. + */ + public Set getIncludedAttributes() { + + return includedAttributes; + } + + /** + * Returns the set of {@code FlowUser} attribute names that may be handed over when + * {@link #isFullUserPassthrough()} is false. + * + * @return unmodifiable set of allowed user attribute names. + */ + public Set getIncludedUserAttributes() { + + return includedUserAttributes; + } + + /** + * Returns true when {@link InFlowExtensionConstants.HandoverPolicy#ATTR_FLOW_USER} is + * present in {@link #getIncludedAttributes()}, meaning the entire {@code FlowUser} object + * is passed through without per-field inspection. + * + * @return true if the full FlowUser should pass through; false if per-field filtering applies. + */ + public boolean isFullUserPassthrough() { + + return fullUserPassthrough; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionAction.java new file mode 100644 index 000000000000..96a222684a42 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionAction.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.action.management.api.model.EndpointConfig; + +import java.sql.Timestamp; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * In-Flow Extension Action. + *

    + * Extends the base {@link Action} with an {@link AccessConfig} that defines what flow context data + * is exposed to the external service and which operations are allowed. + *

    + */ +public class InFlowExtensionAction extends Action { + + private final AccessConfig accessConfig; + private final Encryption encryption; + private final Map flowTypeOverrides; + private final String iconUrl; + + public InFlowExtensionAction(ResponseBuilder responseBuilder) { + + super(responseBuilder); + this.accessConfig = responseBuilder.accessConfig; + this.encryption = responseBuilder.encryption; + this.flowTypeOverrides = responseBuilder.flowTypeOverrides != null + ? Collections.unmodifiableMap(new HashMap<>(responseBuilder.flowTypeOverrides)) + : Collections.emptyMap(); + this.iconUrl = responseBuilder.iconUrl; + } + + public InFlowExtensionAction(RequestBuilder requestBuilder) { + + super(requestBuilder); + this.accessConfig = requestBuilder.accessConfig; + this.encryption = requestBuilder.encryption; + this.flowTypeOverrides = requestBuilder.flowTypeOverrides != null + ? Collections.unmodifiableMap(new HashMap<>(requestBuilder.flowTypeOverrides)) + : Collections.emptyMap(); + this.iconUrl = requestBuilder.iconUrl; + } + + /** + * Returns the default access configuration for this In-Flow Extension action. + * + * @return The access config, or {@code null} if not configured. + */ + public AccessConfig getAccessConfig() { + + return accessConfig; + } + + /** + * Returns the encryption configuration holding the external service's certificate. + * + * @return The encryption config, or {@code null} if not configured. + */ + public Encryption getEncryption() { + + return encryption; + } + + /** + * Returns the icon URL for this In-Flow Extension action. + * + * @return The icon URL, or {@code null} if not configured. + */ + public String getIconUrl() { + + return iconUrl; + } + + /** + * Returns the per-flow-type access config overrides. + * Keys are flow type strings (e.g., "REGISTRATION", "LOGIN"). + * + * @return Unmodifiable map of flow type to AccessConfig overrides. + */ + public Map getFlowTypeOverrides() { + + return flowTypeOverrides; + } + + /** + * Resolves the effective access config for the given flow type. + * Returns the flow-type-specific override if present, otherwise falls back to the default access config. + * + * @param flowType The flow type (e.g., "REGISTRATION"). + * @return The resolved AccessConfig, or {@code null} if neither override nor default is configured. + */ + public AccessConfig resolveAccessConfig(String flowType) { + + if (flowType != null && flowTypeOverrides.containsKey(flowType)) { + return flowTypeOverrides.get(flowType); + } + return accessConfig; + } + + /** + * Response Builder for InFlowExtensionAction. + * Used when building from persisted data (DAO → service layer). + */ + public static class ResponseBuilder extends ActionResponseBuilder { + + private AccessConfig accessConfig; + private Encryption encryption; + private Map flowTypeOverrides; + private String iconUrl; + + @Override + public ResponseBuilder id(String id) { + + super.id(id); + return this; + } + + @Override + public ResponseBuilder type(ActionTypes type) { + + super.type(type); + return this; + } + + @Override + public ResponseBuilder name(String name) { + + super.name(name); + return this; + } + + @Override + public ResponseBuilder description(String description) { + + super.description(description); + return this; + } + + @Override + public ResponseBuilder status(Status status) { + + super.status(status); + return this; + } + + @Override + public ResponseBuilder actionVersion(String actionVersion) { + + super.actionVersion(actionVersion); + return this; + } + + @Override + public ResponseBuilder createdAt(Timestamp createdAt) { + + super.createdAt(createdAt); + return this; + } + + @Override + public ResponseBuilder updatedAt(Timestamp updatedAt) { + + super.updatedAt(updatedAt); + return this; + } + + @Override + public ResponseBuilder endpoint(EndpointConfig endpoint) { + + super.endpoint(endpoint); + return this; + } + + @Override + public ResponseBuilder rule(org.wso2.carbon.identity.action.management.api.model.ActionRule rule) { + + super.rule(rule); + return this; + } + + public ResponseBuilder accessConfig(AccessConfig accessConfig) { + + this.accessConfig = accessConfig; + return this; + } + + public ResponseBuilder encryption(Encryption encryption) { + + this.encryption = encryption; + return this; + } + + public ResponseBuilder flowTypeOverrides(Map flowTypeOverrides) { + + this.flowTypeOverrides = flowTypeOverrides; + return this; + } + + public ResponseBuilder iconUrl(String iconUrl) { + + this.iconUrl = iconUrl; + return this; + } + + @Override + public InFlowExtensionAction build() { + + return new InFlowExtensionAction(this); + } + } + + /** + * Request Builder for InFlowExtensionAction. + * Used when building from REST API request (API → service layer). + */ + public static class RequestBuilder extends ActionRequestBuilder { + + private AccessConfig accessConfig; + private Encryption encryption; + private Map flowTypeOverrides; + private String iconUrl; + + public RequestBuilder(Action action) { + + name(action.getName()); + description(action.getDescription()); + actionVersion(action.getActionVersion()); + endpoint(action.getEndpoint()); + rule(action.getActionRule()); + } + + @Override + public RequestBuilder name(String name) { + + super.name(name); + return this; + } + + @Override + public RequestBuilder description(String description) { + + super.description(description); + return this; + } + + @Override + public RequestBuilder endpoint(EndpointConfig endpoint) { + + super.endpoint(endpoint); + return this; + } + + public RequestBuilder accessConfig(AccessConfig accessConfig) { + + this.accessConfig = accessConfig; + return this; + } + + public RequestBuilder encryption(Encryption encryption) { + + this.encryption = encryption; + return this; + } + + public RequestBuilder flowTypeOverrides(Map flowTypeOverrides) { + + this.flowTypeOverrides = flowTypeOverrides; + return this; + } + + public RequestBuilder iconUrl(String iconUrl) { + + this.iconUrl = iconUrl; + return this; + } + + @Override + public InFlowExtensionAction build() { + + return new InFlowExtensionAction(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEvent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEvent.java new file mode 100644 index 000000000000..75e2386dbc7e --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEvent.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.wso2.carbon.identity.action.execution.api.model.Application; +import org.wso2.carbon.identity.action.execution.api.model.Event; +import org.wso2.carbon.identity.action.execution.api.model.Organization; +import org.wso2.carbon.identity.action.execution.api.model.Tenant; +import org.wso2.carbon.identity.action.execution.api.model.UserStore; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * This class models the In-Flow Extension Event. + * It represents the event sent to the In-Flow Extension action over the Action Execution Request. + */ +public class InFlowExtensionEvent extends Event { + + private final InFlowExtensionFlow flow; + private final String callbackUrl; + private final String portalUrl; + private final Map flowProperties; + + private InFlowExtensionEvent(Builder builder) { + + this.tenant = builder.tenant; + this.organization = builder.organization; + this.userStore = builder.userStore; + this.application = builder.application; + this.flow = builder.flow; + this.callbackUrl = builder.callbackUrl; + this.portalUrl = builder.portalUrl; + this.flowProperties = builder.flowProperties != null ? + Collections.unmodifiableMap(new HashMap<>(builder.flowProperties)) : Collections.emptyMap(); + } + + /** + * Get the flow context (type, ID, and user). + * + * @return The flow context object, or {@code null} if not set. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public InFlowExtensionFlow getFlow() { + + return flow; + } + + /** + * Get the callback URL for the flow, if exposed. + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed callbackUrl with no + * context value is serialized as {@code ""} rather than omitted. + * + * @return The callback URL, or {@code null} if not exposed. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getCallbackUrl() { + + return callbackUrl; + } + + /** + * Get the portal URL for the flow, if exposed. + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed portalUrl with no + * context value is serialized as {@code ""} rather than omitted. + * + * @return The portal URL, or {@code null} if not exposed. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getPortalUrl() { + + return portalUrl; + } + + /** + * Get the flow properties/context data. + * + * @return Unmodifiable map of flow properties. + */ + public Map getFlowProperties() { + + return flowProperties; + } + + /** + * Builder for the InFlowExtensionEvent. + */ + public static class Builder { + + private InFlowExtensionFlow flow; + private Tenant tenant; + private Organization organization; + private UserStore userStore; + private Application application; + private String callbackUrl; + private String portalUrl; + private Map flowProperties; + + public Builder flow(InFlowExtensionFlow flow) { + + this.flow = flow; + return this; + } + + public Builder tenant(Tenant tenant) { + + this.tenant = tenant; + return this; + } + + public Builder organization(Organization organization) { + + this.organization = organization; + return this; + } + + public Builder userStore(UserStore userStore) { + + this.userStore = userStore; + return this; + } + + public Builder application(Application application) { + + this.application = application; + return this; + } + + public Builder callbackUrl(String callbackUrl) { + + this.callbackUrl = callbackUrl; + return this; + } + + public Builder portalUrl(String portalUrl) { + + this.portalUrl = portalUrl; + return this; + } + + public Builder flowProperties(Map flowProperties) { + + this.flowProperties = flowProperties != null ? new HashMap<>(flowProperties) : null; + return this; + } + + public InFlowExtensionEvent build() { + + return new InFlowExtensionEvent(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionFlow.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionFlow.java new file mode 100644 index 000000000000..a5f84c6991eb --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionFlow.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.wso2.carbon.identity.action.execution.api.model.User; + +/** + * Models the {@code flow} object nested inside the In-Flow Extension Event. + * Groups flow-scoped context: the flow type, flow identifier, and the acting user. + */ +public class InFlowExtensionFlow { + + private final String flowType; + private final String flowId; + private final User user; + + private InFlowExtensionFlow(Builder builder) { + + this.flowType = builder.flowType; + this.flowId = builder.flowId; + this.user = builder.user; + } + + /** + * NON_NULL overrides the ObjectMapper-level NON_EMPTY so that an exposed flowType with no + * context value is serialized as {@code ""} rather than omitted. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getFlowType() { + + return flowType; + } + + public String getFlowId() { + + return flowId; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public User getUser() { + + return user; + } + + public static class Builder { + + private String flowType; + private String flowId; + private User user; + + public Builder flowType(String flowType) { + + this.flowType = flowType; + return this; + } + + public Builder flowId(String flowId) { + + this.flowId = flowId; + return this; + } + + public Builder user(User user) { + + this.user = user; + return this; + } + + public InFlowExtensionFlow build() { + + return new InFlowExtensionFlow(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionRequest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionRequest.java new file mode 100644 index 000000000000..534c7c21f91a --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionRequest.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.action.execution.api.model.Request; + +/** + * Request payload carried inside an {@link InFlowExtensionEvent}. Holds the inbound HTTP + * request's additional headers and parameters (filtered downstream by the action framework + * against the action's allowed-headers / allowed-parameters configuration). + */ +public class InFlowExtensionRequest extends Request { + + public InFlowExtensionRequest() { + + super(); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResult.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResult.java new file mode 100644 index 000000000000..1ae1c4efd05f --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResult.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; + +/** + * This class represents the result of the execution of an operation. + * It contains the operation that was executed, the status of the execution and a message. + * This is used to summarize the operations performed based on action response. + */ +public class OperationExecutionResult { + + private final PerformableOperation operation; + private final Status status; + private final String message; + + public OperationExecutionResult(PerformableOperation operation, Status status, String message) { + + this.operation = operation; + this.status = status; + this.message = message; + } + + public PerformableOperation getOperation() { + + return operation; + } + + public Status getStatus() { + + return status; + } + + public String getMessage() { + + return message; + } + + /** + * Enum to represent the status of the operation execution. + */ + public enum Status { + SUCCESS, FAILURE + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionContextFilterUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionContextFilterUtil.java new file mode 100644 index 000000000000..a74c2d0d3602 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionContextFilterUtil.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.util; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants.HandoverPolicy; +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; + +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Builds a filtered defensive copy of a {@link FlowExecutionContext} containing only the + * attributes permitted by the supplied {@link FlowContextHandoverConfig}. + * + *

    This class mirrors the engine's {@code FlowExecutionContextFilter} but lives in + * the inflow module to avoid a cross-bundle dependency on {@code engine.config.*}. When the + * toml-based dynamic configuration PR is merged, this class can be removed and the engine's + * filter used directly.

    + * + *

    Implementation reflects over the JavaBean property descriptors of + * {@link FlowExecutionContext} and {@link FlowUser}. Descriptors are cached on class load. + * The original context is never mutated; non-permitted attributes are left null/empty on + * the copy.

    + * + *

    Map fields receive a defensive shallow {@link HashMap} copy. The + * {@code userCredentials} field receives a per-entry {@code char[]} clone so that the + * request builder's post-extraction wipe zeroes the copy, not the source.

    + */ +public final class InFlowExtensionContextFilterUtil { + + private static final Log LOG = LogFactory.getLog(InFlowExtensionContextFilterUtil.class); + + private static final Map CONTEXT_PROPERTIES; + private static final Map USER_PROPERTIES; + + static { + CONTEXT_PROPERTIES = Collections.unmodifiableMap(introspect(FlowExecutionContext.class)); + USER_PROPERTIES = Collections.unmodifiableMap(introspect(FlowUser.class)); + } + + private InFlowExtensionContextFilterUtil() { + + } + + /** + * Build a filtered copy of {@code original} according to {@code config}. + * + * @param original the source context (untouched). + * @param config the handover policy. + * @return a new {@link FlowExecutionContext} carrying only whitelisted attributes, + * or {@code null} if {@code original} is {@code null}. + */ + public static FlowExecutionContext filter(FlowExecutionContext original, + FlowContextHandoverConfig config) { + + if (original == null) { + return null; + } + + FlowExecutionContext copy = new FlowExecutionContext(); + + // contextIdentifier is engine-internal and always propagated regardless of config. + copy.setContextIdentifier(original.getContextIdentifier()); + + // Top-level attributes (flowUser and contextIdentifier are handled separately). + for (String name : config.getIncludedAttributes()) { + if (HandoverPolicy.ATTR_FLOW_USER.equals(name) + || HandoverPolicy.ATTR_CONTEXT_IDENTIFIER.equals(name)) { + continue; + } + copyProperty(CONTEXT_PROPERTIES, name, original, copy); + } + + // User attributes — a fresh non-null FlowUser is always set on the copy so that + // request builders / response processors don't need to null-guard the user object. + FlowUser dstUser = new FlowUser(); + copy.setFlowUser(dstUser); + FlowUser srcUser = original.getFlowUser(); + if (srcUser != null) { + Set userAttrs = config.isFullUserPassthrough() + ? USER_PROPERTIES.keySet() + : config.getIncludedUserAttributes(); + for (String name : userAttrs) { + copyProperty(USER_PROPERTIES, name, srcUser, dstUser); + } + } + + return copy; + } + + private static void copyProperty(Map descriptors, String name, + T source, T destination) { + + PropertyDescriptor pd = descriptors.get(name); + if (pd == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Skipping unknown handover attribute: " + name); + } + return; + } + Method reader = pd.getReadMethod(); + Method writer = pd.getWriteMethod(); + if (reader == null || writer == null) { + if (LOG.isDebugEnabled()) { + LOG.debug("Skipping handover attribute without readable+writable accessors: " + name); + } + return; + } + try { + Object value = reader.invoke(source); + value = defensivelyCopy(name, value); + writer.invoke(destination, value); + } catch (IllegalAccessException | InvocationTargetException e) { + LOG.warn("Failed to copy handover attribute '" + name + "': " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private static Object defensivelyCopy(String name, Object value) { + + if (!(value instanceof Map)) { + return value; + } + if (HandoverPolicy.ATTR_USER_CREDENTIALS.equals(name)) { + Map src = (Map) value; + Map out = new LinkedHashMap<>(); + for (Map.Entry entry : src.entrySet()) { + char[] v = entry.getValue(); + out.put(entry.getKey(), v == null ? null : v.clone()); + } + return out; + } + return new HashMap<>((Map) value); + } + + private static Map introspect(Class beanClass) { + + Map result = new HashMap<>(); + try { + for (PropertyDescriptor pd : + Introspector.getBeanInfo(beanClass, Object.class).getPropertyDescriptors()) { + if (pd.getReadMethod() != null && pd.getWriteMethod() != null) { + result.put(pd.getName(), pd); + } + } + } catch (IntrospectionException e) { + LOG.error("Failed to introspect " + beanClass.getName() + " for handover filtering.", e); + } + return result; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtil.java new file mode 100644 index 000000000000..9e53e457ab63 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtil.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.util; + +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; + +import java.util.List; + +/** + * Path-matching utilities for In-Flow Extension access control. + */ +public final class InFlowExtensionPathUtil { + + private InFlowExtensionPathUtil() { + + } + + /** + * Returns {@code true} if the path is in the read-only {@code /flow/} area. + */ + public static boolean isReadOnly(String path) { + + if (path == null) { + return false; + } + return path.startsWith(InFlowExtensionConstants.FLOW_PREFIX); + } + + /** + * Returns {@code true} if at least one leaf path in {@code leafPaths} starts with + * {@code areaPrefix}. Used as an area-gate before iterating a data block. + */ + public static boolean anyExposedUnder(String areaPrefix, List leafPaths) { + + if (areaPrefix == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + for (String path : leafPaths) { + if (path != null && path.startsWith(areaPrefix)) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if {@code leafPath} is present in {@code leafPaths}. + */ + public static boolean isExposedPath(String leafPath, List leafPaths) { + + if (leafPath == null || leafPaths == null || leafPaths.isEmpty()) { + return false; + } + return leafPaths.contains(leafPath); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionTestUtils.java new file mode 100644 index 000000000000..f39974688fc0 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionTestUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension; + +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Test-only helper that builds {@link FlowContextHandoverConfig} instances using the + * inflow module's own factory method — no reflection, no engine class dependencies. + */ +public final class InFlowExtensionTestUtils { + + /** + * Commonly used attribute sets that cover all fields exposed by the filter. + */ + public static final Set ALL_CONTEXT_ATTRS = new HashSet<>(Arrays.asList( + "tenantDomain", "applicationId", "flowType", "callbackUrl", "portalUrl", + "properties", "contextIdentifier")); + + public static final Set ALL_USER_ATTRS = new HashSet<>(Arrays.asList( + "username", "id", "userStoreDomain", "claims", "userCredentials")); + + private InFlowExtensionTestUtils() { + + } + + /** + * Construct a permissive {@link FlowContextHandoverConfig} covering all known fields. + */ + public static FlowContextHandoverConfig permissiveConfig() { + + return configOf(ALL_CONTEXT_ATTRS, ALL_USER_ATTRS); + } + + /** + * Construct a {@link FlowContextHandoverConfig} with explicit allow-lists. + */ + public static FlowContextHandoverConfig configOf(Set attrs, Set userAttrs) { + + return FlowContextHandoverConfig.of(attrs, userAttrs); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java new file mode 100644 index 000000000000..8349ebc88a1b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java @@ -0,0 +1,616 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; +import org.wso2.carbon.identity.flow.extension.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.mgt.model.ExecutorDTO; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionExecutor}. + */ +public class InFlowExtensionExecutorTest { + + private InFlowExtensionExecutor executor; + + @Mock + private ActionExecutorService actionExecutorService; + + private AutoCloseable mocks; + private MockedStatic holderMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + mocks = MockitoAnnotations.openMocks(this); + executor = new InFlowExtensionExecutor(); + + // Stub InFlowExtensionDataHolder for action executor service. + InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(actionExecutorService); + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + } + + @AfterMethod + public void tearDown() throws Exception { + + loggerUtilsMock.close(); + holderMock.close(); + mocks.close(); + } + + // ========================= getName ========================= + + @Test + public void testGetName() { + + assertEquals(executor.getName(), "InFlowExtensionExecutor"); + } + + // ========================= getInitiationData ========================= + + @Test + public void testGetInitiationData() { + + assertNotNull(executor.getInitiationData()); + assertTrue(executor.getInitiationData().isEmpty()); + } + + // ========================= rollback ========================= + + @Test + public void testRollback() { + + assertNull(executor.rollback(new FlowExecutionContext())); + } + + // ========================= execute — no actionId ========================= + + @Test + public void testExecuteNoActionId() throws Exception { + + FlowExecutionContext context = createContextWithMetadata(new HashMap<>()); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + verify(actionExecutorService, never()) + .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); + } + + @Test + public void testExecuteEmptyActionId() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", ""); + FlowExecutionContext context = createContextWithMetadata(metadata); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= execute — execution disabled ========================= + + @Test + public void testExecuteDisabledExecution() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(false); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + verify(actionExecutorService, never()) + .execute(any(ActionType.class), anyString(), any(FlowContext.class), anyString()); + } + + // ========================= execute — SUCCESS ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteSuccess() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus successStatus = mock(ActionExecutionStatus.class); + when(successStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.SUCCESS); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(successStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_COMPLETE); + } + + // ========================= execute — FAILED ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailed() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure("risk_detected", "Risk score exceeds threshold"); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + assertEquals(response.getErrorMessage(), "Risk score exceeds threshold"); + // Verify failureType metadata is set for RETRY. + assertNotNull(response.getAdditionalInfo()); + assertEquals(response.getAdditionalInfo().get("failureType"), "IN_FLOW_EXTENSION_FAILURE"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailedNoDescription() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure("risk_detected", null); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + // Falls back to reason when description is null. + assertEquals(response.getErrorMessage(), "risk_detected"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteFailedBothNull() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Failure failure = new Failure(null, null); + ActionExecutionStatus failedStatus = mock(ActionExecutionStatus.class); + when(failedStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.FAILED); + when(failedStatus.getResponse()).thenReturn(failure); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(failedStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_RETRY); + assertEquals(response.getErrorMessage(), + "The operation could not be completed due to an external service failure."); + } + + // ========================= execute — ERROR ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteError() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error("internal_error", "DB connection failed"); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + // errorMessage carries the Error reason/code field; errorDescription carries the human-readable text. + assertEquals(response.getErrorMessage(), "internal_error"); + assertEquals(response.getErrorDescription(), "DB connection failed"); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteErrorNoDescription() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error("internal_error", null); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + assertEquals(response.getErrorMessage(), "internal_error"); + assertNull(response.getErrorDescription()); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteErrorBothNull() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + Error error = new Error(null, null); + ActionExecutionStatus errorStatus = mock(ActionExecutionStatus.class); + when(errorStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.ERROR); + when(errorStatus.getResponse()).thenReturn(error); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(errorStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorCode(), + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); + // Both fields null → errorMessage and errorDescription remain null; errorCode alone triggers FE-65033 routing. + assertNull(response.getErrorMessage()); + assertNull(response.getErrorDescription()); + } + + // ========================= execute — INCOMPLETE ========================= + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteWithoutRedirectUrlReturnsError() throws Exception { + + // INCOMPLETE without a stashed redirect URL is a contract violation — + // the response processor should normally have thrown, but the executor + // defends against it as well by returning STATUS_ERROR with a clear message. + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(incompleteStatus); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "Extension returned INCOMPLETE without a redirect URL."); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteWithRedirectUrlReturnsExternalRedirection() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + // Set a context identifier so the OTFI collision-guard has something to compare against. + context.setContextIdentifier("original-flow-id"); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + // Simulate the response processor stashing the redirect URL into the FlowContext + // during the actionExecutorService.execute call. + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, + "https://example.com/step-up"); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + + // OTFI must be set on contextProperties so FlowExecutionService can swap caches on resume. + Map ctxProps = response.getContextProperties(); + assertNotNull(ctxProps); + Object otfi = ctxProps.get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); + assertNotNull(otfi); + assertTrue(otfi instanceof String); + assertFalse(((String) otfi).isEmpty()); + // OTFI must not collide with the original context identifier. + assertFalse("original-flow-id".equals(otfi)); + + // Redirect URL must carry the OTFI as a flowId query parameter. + Map additionalInfo = response.getAdditionalInfo(); + assertNotNull(additionalInfo); + String redirectUrl = additionalInfo.get( + org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); + assertNotNull(redirectUrl); + assertEquals(redirectUrl, "https://example.com/step-up?flowId=" + otfi); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteRedirectAppendsFlowIdWithAmpersandWhenUrlHasQuery() + throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + context.setContextIdentifier("original-flow-id"); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + // URL already has a query string — the executor must use & not ?. + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, + "https://example.com/step-up?ref=abc"); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); + String otfi = (String) response.getContextProperties() + .get(org.wso2.carbon.identity.flow.execution.engine.Constants.OTFI); + String redirectUrl = response.getAdditionalInfo() + .get(org.wso2.carbon.identity.flow.execution.engine.Constants.REDIRECT_URL); + assertEquals(redirectUrl, "https://example.com/step-up?ref=abc&flowId=" + otfi); + } + + @Test + @SuppressWarnings("unchecked") + public void testExecuteIncompleteRedirectEmptyUrlReturnsError() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + + ActionExecutionStatus incompleteStatus = mock(ActionExecutionStatus.class); + when(incompleteStatus.getStatus()).thenReturn(ActionExecutionStatus.Status.INCOMPLETE); + + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenAnswer(invocation -> { + FlowContext fc = invocation.getArgument(2); + fc.add(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, ""); + return incompleteStatus; + }); + + ExecutorResponse response = executor.execute(context); + + // Empty URL is treated the same as missing — defensive STATUS_ERROR. + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "Extension returned INCOMPLETE without a redirect URL."); + } + + // ========================= execute — null status ========================= + + @Test + public void testExecuteNullStatus() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenReturn(null); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= execute — exception ========================= + + @Test + public void testExecuteActionException() throws Exception { + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + when(actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)).thenReturn(true); + when(actionExecutorService.execute( + eq(ActionType.FLOW_EXTENSION), eq("test-action-001"), + any(FlowContext.class), eq("carbon.super"))) + .thenThrow(new ActionExecutionException("Connection timeout")); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + assertEquals(response.getErrorMessage(), + "An error occurred while processing the extension. Please try again."); + } + + // ========================= execute — service unavailable ========================= + + @Test(expectedExceptions = org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException.class) + public void testExecuteServiceUnavailable() throws Exception { + + // Override holder mock to return null service. + holderMock.close(); + + InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(null); + + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + + Map metadata = new HashMap<>(); + metadata.put("actionId", "test-action-001"); + FlowExecutionContext context = createContextWithMetadata(metadata); + + // Should throw FlowEngineException since service is unavailable. + executor.execute(context); + } + + // ========================= execute — no node config ========================= + + @Test + public void testExecuteNoNodeConfig() throws Exception { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + // No current node set → getMetadataValue returns null. + + ExecutorResponse response = executor.execute(context); + + // actionId is null → missing configuration → ERROR. + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + @Test + public void testExecuteNoExecutorDTO() throws Exception { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + + NodeConfig nodeConfig = new NodeConfig.Builder().build(); + context.setCurrentNode(nodeConfig); + + ExecutorResponse response = executor.execute(context); + + assertEquals(response.getResult(), ExecutorStatus.STATUS_ERROR); + } + + // ========================= Helper methods ========================= + + private FlowExecutionContext createContextWithMetadata(Map metadata) { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + + ExecutorDTO executorDTO = new ExecutorDTO("InFlowExtensionExecutor", metadata); + NodeConfig nodeConfig = new NodeConfig.Builder() + .executorConfig(executorDTO) + .build(); + context.setCurrentNode(nodeConfig); + + return context; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java new file mode 100644 index 000000000000..c2d974026d2b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java @@ -0,0 +1,1047 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequest; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionRequestContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.AllowedOperation; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.management.api.model.Action; +import org.wso2.carbon.identity.core.util.IdentityTenantUtil; +import org.wso2.carbon.identity.flow.extension.model.*; +import org.wso2.carbon.identity.certificate.management.model.Certificate; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; +import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +/** + * Unit tests for {@link InFlowExtensionRequestBuilder}. + */ +public class InFlowExtensionRequestBuilderTest { + + private InFlowExtensionRequestBuilder requestBuilder; + private MockedStatic identityTenantUtilMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + requestBuilder = new InFlowExtensionRequestBuilder(); + identityTenantUtilMock = mockStatic(IdentityTenantUtil.class); + identityTenantUtilMock.when(() -> IdentityTenantUtil.getTenantId(anyString())).thenReturn(1); + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + } + + @AfterMethod + public void tearDown() { + + loggerUtilsMock.close(); + identityTenantUtilMock.close(); + } + + // ========================= getSupportedActionType ========================= + + @Test + public void testGetSupportedActionType() { + + assertEquals(requestBuilder.getSupportedActionType(), ActionType.FLOW_EXTENSION); + } + + // ========================= buildActionExecutionRequest — basics ========================= + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) + public void testBuildRequestThrowsWhenFlowExecutionContextMissing() + throws ActionExecutionRequestBuilderException { + + FlowContext flowContext = FlowContext.create(); + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + } + + @Test + public void testBuildRequestWithMinimalContext() throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + assertNotNull(request); + assertEquals(request.getActionType(), ActionType.FLOW_EXTENSION); + assertNotNull(request.getEvent()); + } + + @Test + public void testBuildRequestUsesEmptyExposeWhenExposeIsNull() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + // With empty expose, no context areas should be included in the event. + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event); + assertNull(event.getFlow().getFlowType()); + // flowProperties defaults to emptyMap() in the builder — verify it is empty. + assertTrue(event.getFlowProperties().isEmpty()); + } + + // ========================= buildAllowedOperations (from modify) ========================= + + @Test + public void testBuildRequestWithValidModifyPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + // REPLACE + REDIRECT (REDIRECT is always advertised). + assertEquals(ops.size(), 2); + AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); + assertNotNull(replaceOp, "REPLACE should be present when modify paths are configured"); + assertTrue(replaceOp.getPaths().contains("/properties/riskScore")); + assertNotNull(findOperation(ops, Operation.REDIRECT), + "REDIRECT should always be present"); + } + + @Test + public void testBuildRequestWithNoAccessConfig() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + // No action → no access config → no modify paths → only REDIRECT (always present). + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + assertEquals(ops.size(), 1); + assertEquals(ops.get(0).getOp(), Operation.REDIRECT); + } + + @Test + public void testBuildRequestWithNonInFlowActionFallsBackToMinimalPayload() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + when(reqCtx.getAction()).thenReturn(mock(Action.class)); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + assertNotNull(request); + assertEquals(request.getActionType(), ActionType.FLOW_EXTENSION); + assertEquals(request.getAllowedOperations().size(), 1); + assertEquals(request.getAllowedOperations().get(0).getOp(), Operation.REDIRECT); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event); + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowId(), execCtx.getContextIdentifier()); + assertNull(event.getFlow().getUser()); + assertNull(event.getFlow().getFlowType()); + } + + @Test + public void testBuildRequestWithEmptyModifyPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + List ops = request.getAllowedOperations(); + assertNotNull(ops); + // No modify paths → only REDIRECT. + assertEquals(ops.size(), 1); + assertEquals(ops.get(0).getOp(), Operation.REDIRECT); + } + + // ========================= REDIRECT always advertised ========================= + + @Test + public void testRedirectIsAdvertisedAlongsideReplace() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); + assertNotNull(redirectOp); + // REDIRECT does not target a path — paths must be null or empty. + assertTrue(redirectOp.getPaths() == null || redirectOp.getPaths().isEmpty(), + "REDIRECT must not carry any paths"); + } + + @Test + public void testRedirectIsAdvertisedWhenNoAccessConfig() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequestContext reqCtx = mock(ActionExecutionRequestContext.class); + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest(flowContext, reqCtx); + + // REDIRECT must be available even without any modify config so extensions can always + // signal mid-flow redirects. + AllowedOperation redirectOp = findOperation(request.getAllowedOperations(), Operation.REDIRECT); + assertNotNull(redirectOp); + } + + // ========================= Path annotation stripping ========================= + + @Test + @SuppressWarnings("unchecked") + public void testPathAnnotationStrippingSimpleArray() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskFactors{[String]}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // AllowedOperation should have clean path (without {[String]}). + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertTrue(op.getPaths().contains("/properties/riskFactors")); + assertFalse(op.getPaths().contains("/properties/riskFactors{[String]}")); + + // Annotations should be stored in FlowContext. + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertNotNull(annotations); + assertEquals(annotations.get("/properties/riskFactors"), "[String]"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPathAnnotationStrippingSchemaAnnotation() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/items{name: String, count: Integer}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertTrue(op.getPaths().contains("/properties/items")); + assertFalse(op.getPaths().contains("/properties/items{name: String, count: Integer}")); + + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPathWithoutAnnotationNotStoredInAnnotationsMap() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, null)); + + // No annotations should be stored when paths have no annotations. + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertNull(annotations); + } + + @Test + @SuppressWarnings("unchecked") + public void testMultipleAnnotatedPaths() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/properties/riskFactors{[String]}", false), + new ContextPath("/properties/items{name: String, count: Integer}", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + AllowedOperation op = findOperation(request.getAllowedOperations(), Operation.REPLACE); + assertNotNull(op); + assertEquals(op.getPaths().size(), 3); + assertTrue(op.getPaths().contains("/properties/riskScore")); + assertTrue(op.getPaths().contains("/properties/riskFactors")); + assertTrue(op.getPaths().contains("/properties/items")); + + Map annotations = flowContext.getValue( + InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, Map.class); + assertEquals(annotations.size(), 2); + assertEquals(annotations.get("/properties/riskFactors"), "[String]"); + assertEquals(annotations.get("/properties/items"), "name: String, count: Integer"); + } + + // ========================= Expose and modify independence ========================= + + @Test + public void testModifyPathsDoNotAffectExpose() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose only /input/ and /flow/ — /properties/ is NOT exposed. + // Modify path targets /properties/riskScore — but this should NOT auto-expose it. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/input/", false), + new ContextPath("/flow/", false)), + Arrays.asList(new ContextPath("/properties/riskScore", false))); + + execCtx.setProperty("riskScore", "50"); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // Modify paths should produce a REPLACE allowed op alongside the always-present REDIRECT. + List ops = request.getAllowedOperations(); + assertEquals(ops.size(), 2); + assertNotNull(findOperation(ops, Operation.REPLACE)); + assertNotNull(findOperation(ops, Operation.REDIRECT)); + + // Properties should NOT be in event — expose and modify are independent. + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertTrue(event.getFlowProperties() == null || event.getFlowProperties().isEmpty()); + } + + @Test + public void testMultipleModifyPathsProduceSingleReplaceOperation() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + AccessConfig accessConfig = new AccessConfig(null, Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/properties/riskLevel", false), + new ContextPath("/user/claims/http://wso2.org/claims/email", false))); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + // All modify paths grouped into a single REPLACE op (REDIRECT is always added too). + List ops = request.getAllowedOperations(); + assertEquals(ops.size(), 2); + AllowedOperation replaceOp = findOperation(ops, Operation.REPLACE); + assertNotNull(replaceOp); + assertEquals(replaceOp.getPaths().size(), 3); + assertNotNull(findOperation(ops, Operation.REDIRECT)); + } + + // ========================= Expose filtering ========================= + + @Test + public void testExposeFilteringOnlyExposedAreaIncluded() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Only expose /flow/ — no user, no properties, no input. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/tenantDomain", false), + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + // User should NOT be in the event since /user/ is not exposed. + assertNull(event.getFlow().getUser()); + // Flow type should be present. + assertNotNull(event.getFlow().getFlowType()); + // Tenant should be present. + assertNotNull(event.getTenant()); + } + + @Test + public void testFlowPortalUrlExposed() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/portalUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getPortalUrl(), "https://localhost:9443/accounts/recovery"); + } + + @Test + public void testFlowPortalUrlNotExposedYieldsNull() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setPortalUrl("https://localhost:9443/accounts/recovery"); + + // /flow/portalUrl NOT in expose list — must be omitted from the event even when + // the context has a value, mirroring the rest of the expose-gated paths. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNull(event.getPortalUrl()); + } + + @Test + public void testFlowCallbackUrlExposed() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setCallbackUrl("https://example.com/callback"); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/callbackUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getCallbackUrl(), "https://example.com/callback"); + } + + @Test + public void testExposeFilteringSpecificClaim() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Only expose a specific claim. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", false), + new ContextPath("/user/id", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + // Only the email claim should be present, not the country claim. + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 1); + } + + // ========================= Null-to-empty-string: consistent contract ========================= + + @Test + public void testExposedCallbackUrlNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // callbackUrl is NOT set — context returns null. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/callbackUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getCallbackUrl(), "", + "Exposed callbackUrl must be '' when context value is null"); + } + + @Test + public void testExposedPortalUrlNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // portalUrl is NOT set — context returns null. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/portalUrl", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getPortalUrl(), "", + "Exposed portalUrl must be '' when context value is null"); + } + + @Test + public void testExposedFlowTypeNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + // flowType is not set in the minimal context. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/flowType", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertEquals(event.getFlow().getFlowType(), "", + "Exposed flowType must be '' when context value is null"); + } + + @Test + public void testExposedTenantDomainNullYieldsEmptyStrings() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + execCtx.setTenantDomain(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/tenantDomain", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getTenant(), + "Tenant must be present when /flow/tenantDomain is exposed"); + assertEquals(event.getTenant().getId(), "", + "Tenant id must be '' when tenantDomain is null"); + assertEquals(event.getTenant().getName(), "", + "Tenant name must be '' when tenantDomain is null"); + } + + @Test + public void testExposedApplicationIdNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createMinimalFlowExecutionContext(); + // applicationId is not set in the minimal context. + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/flow/applicationId", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getApplication(), + "Application must be present when /flow/applicationId is exposed"); + assertEquals(event.getApplication().getId(), "", + "Application id must be '' when applicationId is null"); + } + + @Test + public void testExposedUserIdNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.getFlowUser().setId(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/id", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + assertEquals(event.getFlow().getUser().getId(), "", + "User id must be '' when id is null and path is exposed"); + } + + @Test + public void testExposedUserStoreDomainNullYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.getFlowUser().setUserStoreDomain(null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/userStoreDomain", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser(), "User must be present when /user/userStoreDomain is exposed"); + assertNotNull(event.getFlow().getUser().getUserStoreDomain(), + "UserStore must be present when /user/userStoreDomain is exposed"); + assertEquals(event.getFlow().getUser().getUserStoreDomain().getName(), "", + "UserStore name must be '' when userStoreDomain is null"); + } + + @Test + public void testExposedClaimAbsentFromClaimsMapYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose a claim URI that is NOT present in the flowUser's claims map. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/mobile", false), + new ContextPath("/user/id", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 1); + org.wso2.carbon.identity.action.execution.api.model.UserClaim mobileClaim = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); + assertEquals(mobileClaim.getUri(), "http://wso2.org/claims/mobile"); + assertEquals(mobileClaim.getValue(), "", + "Exposed claim absent from claims map must yield ''"); + } + + @Test + public void testExposedPropertyAbsentFromPropertiesMapYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Expose a property key that is NOT present in the context properties map. + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/properties/riskScore", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + assertTrue(event.getFlowProperties().containsKey("riskScore"), + "Exposed property absent from properties map must still appear"); + assertEquals(event.getFlowProperties().get("riskScore"), "", + "Exposed property absent from properties map must yield ''"); + } + + @Test + public void testExposedClaimWithNullValueYieldsEmptyString() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + // Overwrite existing claim with null value. + execCtx.getFlowUser().getClaims().put("http://wso2.org/claims/email", null); + + AccessConfig accessConfig = new AccessConfig(Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 1); + org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) claims.get(0); + assertEquals(emailClaim.getValue(), "", + "Claim value must be '' when source value is null"); + } + + // ========================= Outbound encryption of properties and inputs ========================= + + @Test + public void testPropertiesEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + // riskScore is expose-encrypted; existingProp is exposed plaintext. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/properties/riskScore", true), + new ContextPath("/properties/existingProp", false)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + // riskScore should be encrypted. + Object riskScoreValue = event.getFlowProperties().get("riskScore"); + assertNotNull(riskScoreValue); + assertTrue(riskScoreValue.toString().startsWith("encrypted."), + "Property value should be encrypted when expose path is marked encrypted"); + // existingProp is NOT marked encrypted — should remain plaintext. + assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); + } + } + + @Test + public void testClaimEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + + // email claim is expose-encrypted; country claim is exposed plaintext. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/user/claims/http://wso2.org/claims/email", true), + new ContextPath("/user/claims/http://wso2.org/claims/country", false)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + List claims = event.getFlow().getUser().getClaims(); + assertEquals(claims.size(), 2); + + org.wso2.carbon.identity.action.execution.api.model.UserClaim emailClaim = null; + org.wso2.carbon.identity.action.execution.api.model.UserClaim countryClaim = null; + for (Object c : claims) { + org.wso2.carbon.identity.action.execution.api.model.UserClaim uc = + (org.wso2.carbon.identity.action.execution.api.model.UserClaim) c; + if ("http://wso2.org/claims/email".equals(uc.getUri())) { + emailClaim = uc; + } else if ("http://wso2.org/claims/country".equals(uc.getUri())) { + countryClaim = uc; + } + } + + assertNotNull(emailClaim, "email claim should be present"); + assertTrue(emailClaim.getValue().toString().startsWith("encrypted."), + "email claim should be encrypted when expose path is marked encrypted"); + + assertNotNull(countryClaim, "country claim should be present"); + assertEquals(countryClaim.getValue().toString(), "US", + "country claim should remain plaintext when expose path is not marked encrypted"); + } + } + + @Test + public void testCredentialEncryptedWhenExposePathMarkedEncrypted() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenAnswer(inv -> "encrypted." + inv.getArgument(0) + ".jwe.part.four"); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + Map creds = new java.util.HashMap<>(); + creds.put("password", "secret123".toCharArray()); + execCtx.getFlowUser().setUserCredentials(creds); + + // password credential is expose-encrypted. + AccessConfig accessConfig = new AccessConfig( + Arrays.asList(new ContextPath("/user/credentials/password", true)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlow().getUser()); + Map eventCreds = event.getFlow().getUser().getUserCredentials(); + assertNotNull(eventCreds); + assertTrue(eventCreds.containsKey("password")); + String credValue = new String(eventCreds.get("password")); + assertTrue(credValue.startsWith("encrypted."), + "credential should be encrypted when expose path is marked encrypted"); + } + } + + @Test + public void testEncryptedExposePathIsOmittedWhenCertificateIsMissing() + throws ActionExecutionRequestBuilderException { + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + AccessConfig accessConfig = new AccessConfig( + Arrays.asList( + new ContextPath("/properties/riskScore", true), + new ContextPath("/properties/existingProp", false)), + null); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + InFlowExtensionEvent event = (InFlowExtensionEvent) request.getEvent(); + assertNotNull(event.getFlowProperties()); + assertFalse(event.getFlowProperties().containsKey("riskScore"), + "Encrypted expose path should be omitted when outbound certificate is missing."); + assertEquals(event.getFlowProperties().get("existingProp"), "existingValue"); + } + + // ========================= Outbound encryption failure ========================= + + @Test(expectedExceptions = ActionExecutionRequestBuilderException.class) + public void testEncryptionFailureThrowsException() + throws ActionExecutionRequestBuilderException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.encrypt(anyString(), anyString())) + .thenThrow(new RuntimeException("Encryption failed")); + + FlowExecutionContext execCtx = createFullFlowExecutionContext(); + execCtx.setProperty("riskScore", "85"); + + AccessConfig accessConfig = new AccessConfig( + Arrays.asList(new ContextPath("/properties/riskScore", true)), + null); + + Encryption encryption = new Encryption( + new Certificate.Builder().id("cert-1").name("test") + .certificateContent("test-cert-pem").build()); + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + requestBuilder.buildActionExecutionRequest(flowContext, mockReqCtx(accessConfig, encryption)); + } + } + + // ========================= Helper methods ========================= + + /** + * Locate the first AllowedOperation in {@code ops} matching {@code op}, or null if absent. + */ + private AllowedOperation findOperation(List ops, Operation op) { + + if (ops == null) { + return null; + } + for (AllowedOperation candidate : ops) { + if (candidate.getOp() == op) { + return candidate; + } + } + return null; + } + + /** + * Create a mock ActionExecutionRequestContext whose getAction() returns an InFlowExtensionAction + * configured with the given access config and encryption. + */ + private ActionExecutionRequestContext mockReqCtx(AccessConfig accessConfig, Encryption encryption) { + + InFlowExtensionAction action = mock(InFlowExtensionAction.class); + when(action.resolveAccessConfig(any())).thenReturn(accessConfig); + when(action.getEncryption()).thenReturn(encryption); + when(action.getName()).thenReturn("Test Action"); + ActionExecutionRequestContext ctx = mock(ActionExecutionRequestContext.class); + when(ctx.getAction()).thenReturn(action); + return ctx; + } + + private FlowExecutionContext createMinimalFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-correlation-id"); + + NodeConfig node = new NodeConfig.Builder() + .id("node1") + .type("EXECUTION") + .build(); + context.setCurrentNode(node); + + return context; + } + + private FlowExecutionContext createFullFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-correlation-id"); + context.setApplicationId("app-123"); + context.setFlowType("REGISTRATION"); + + NodeConfig node = new NodeConfig.Builder() + .id("execution_node_1") + .type("EXECUTION") + .build(); + context.setCurrentNode(node); + + FlowUser flowUser = new FlowUser(); + flowUser.setId("user-456"); + flowUser.setUsername("testuser"); + flowUser.setUserStoreDomain("PRIMARY"); + flowUser.addClaim("http://wso2.org/claims/email", "test@example.com"); + flowUser.addClaim("http://wso2.org/claims/country", "US"); + context.setFlowUser(flowUser); + + context.addUserInputData("username", "testuser"); + context.addUserInputData("consent", "true"); + + context.setProperty("existingProp", "existingValue"); + + return context; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java new file mode 100644 index 000000000000..8e49ed88a9f1 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java @@ -0,0 +1,999 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionResponseProcessorException; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionResponseContext; +import org.wso2.carbon.identity.action.execution.api.model.ActionExecutionStatus; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationErrorResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationFailureResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationIncompleteResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionInvocationSuccessResponse; +import org.wso2.carbon.identity.action.execution.api.model.ActionType; +import org.wso2.carbon.identity.action.execution.api.model.Error; +import org.wso2.carbon.identity.action.execution.api.model.Failure; +import org.wso2.carbon.identity.action.execution.api.model.FlowContext; +import org.wso2.carbon.identity.action.execution.api.model.Incomplete; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; +import org.wso2.carbon.identity.action.execution.api.model.Success; +import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; +import org.wso2.carbon.identity.claim.metadata.mgt.exception.ClaimMetadataException; +import org.wso2.carbon.identity.claim.metadata.mgt.model.LocalClaim; +import org.wso2.carbon.identity.flow.execution.engine.Constants; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.internal.InFlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extension.model.ContextPath; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext; +import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionResponseProcessor}. + */ +public class InFlowExtensionResponseProcessorTest { + + private InFlowExtensionResponseProcessor responseProcessor; + private MockedStatic loggerUtilsMock; + private MockedStatic holderMock; + private InFlowExtensionDataHolder holderInstance; + private FlowContext capturedFlowContext; + + @BeforeMethod + public void setUp() throws Exception { + + responseProcessor = new InFlowExtensionResponseProcessor(); + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + + holderInstance = mock(InFlowExtensionDataHolder.class); + holderMock = mockStatic(InFlowExtensionDataHolder.class); + holderMock.when(InFlowExtensionDataHolder::getInstance).thenReturn(holderInstance); + } + + @AfterMethod + public void tearDown() { + + holderMock.close(); + loggerUtilsMock.close(); + capturedFlowContext = null; + } + + // ========================= getSupportedActionType ========================= + + @Test + public void testGetSupportedActionType() { + + assertEquals(responseProcessor.getSupportedActionType(), ActionType.FLOW_EXTENSION); + } + + // ========================= processSuccessResponse — Property REPLACE ========================= + + @Test + public void testPropertyReplaceFlatExists() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("riskScore", "50"); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "80"); + } + + @Test + public void testPropertyReplaceCreatesIfMissing() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + // riskScore not set — REPLACE should auto-create it. + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", "80"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "80"); + } + + @Test + public void testPropertyReplaceCoercesToStringByDefault() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Integer value with no annotation → coerced to String. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskScore", 75); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, replaceOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("riskScore"), "75"); + } + + @Test + public void testPropertyReplaceWithMultivaluedAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[String]"); + + List factors = Arrays.asList("ip_mismatch", "new_device"); + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/riskFactors", factors); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + Object stored = pendingProps.get("riskFactors"); + assertTrue(stored instanceof List); + assertEquals(((List) stored).size(), 2); + } + + @Test + public void testPropertyReplaceMultivaluedSingleValueWrapped() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + // Single value with [String] annotation → wrapped in a list. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/tags", "singleTag"); + executeSuccessResponse(execCtx, replaceOp, annotations); + + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + Object stored = pendingProps.get("tags"); + assertTrue(stored instanceof List); + assertEquals(((List) stored).size(), 1); + assertEquals(((List) stored).get(0), "singleTag"); + } + + @Test + public void testPropertyReplaceWithComplexAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/item", "name: String, count: Integer"); + + Map item = new HashMap<>(); + item.put("name", "item1"); + item.put("count", 5); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/item", item); + ActionExecutionStatus status = executeSuccessResponse(execCtx, replaceOp, annotations); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + // Complex annotation → value passed through as-is. + Object stored = pendingProps.get("item"); + assertTrue(stored instanceof Map); + } + + @Test + public void testPropertyReplaceWithPrimaryTypeAnnotation() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + // Integer annotation → coerced to String. + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", 95); + executeSuccessResponse(execCtx, replaceOp, annotations); + + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("score"), "95"); + } + + @Test + public void testPropertyReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("score", "50"); + + PerformableOperation replaceOp = createOperation(Operation.REPLACE, "/properties/score", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, replaceOp, Collections.emptyMap()); + + // Null value should fail for REPLACE. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Original value should remain. + assertEquals(execCtx.getProperties().get("score"), "50"); + } + + @Test + public void testPropertyReplaceEmptyPropertyName() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/", "value"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Empty property name should fail. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testPropertyReplaceComplexObjectInvalidSchema() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "risk: Float, factor: String"); + + // Value has an unknown attribute not in the schema. + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("unknown", "bad"); + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", value); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall (logged as failure for this operation) but property not set. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("data")); + } + + @Test + public void testPropertyReplaceArrayExceedsItemLimit() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + // Create a list with 11 items (exceeds max 10). + List bigList = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/tags", bigList); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall but property not set due to array limit. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("tags")); + } + + @Test + public void testPropertyReplaceComplexArrayExceedsItemLimit() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float]"); + + List> items = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + Map item = new HashMap<>(); + item.put("risk", (float) i); + items.add(item); + } + + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/risks", items); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, annotations); + + // Should succeed overall but property not set due to item limit on complex array. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getProperties().get("risks")); + } + + // ========================= processSuccessResponse — User claim REPLACE ========================= + + @Test + public void testUserClaimReplace() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.getFlowUser().addClaim("http://wso2.org/claims/email", "old@example.com"); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "new@example.com"); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/email"), "new@example.com"); + } + + @Test + public void testUserClaimReplaceCreatesNewClaim() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", "US"); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "US"); + } + + @Test + public void testUserClaimReplaceStringifiesValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Numeric value should be stringified. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/country]", 42); + executeSuccessResponse(execCtx, claimOp, Collections.emptyMap()); + + Map pendingClaims = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + assertNotNull(pendingClaims); + assertEquals(pendingClaims.get("http://wso2.org/claims/country"), "42"); + } + + @Test + public void testUserClaimReplaceIdentityClaimRejected() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Identity claim should be rejected by the identity-claim prefix guard. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/identity/accountLocked]", "true"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNonExistentClaimRejected() + throws ActionExecutionResponseProcessorException, ClaimMetadataException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Mock ClaimMetadataManagementService to return null for an unknown claim URI. + ClaimMetadataManagementService claimService = mock(ClaimMetadataManagementService.class); + when(holderInstance.getClaimMetadataManagementService()).thenReturn(claimService); + when(claimService.getLocalClaim("http://wso2.org/claims/nonexistent", "carbon.super")) + .thenReturn(java.util.Optional.empty()); + + // Claim not registered in the system should be rejected. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, + "/user/claims[uri=http://wso2.org/claims/nonexistent]", "value"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNonLocalDialectRejected() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Non-local dialect claim should be rejected by the local-dialect prefix guard. + PerformableOperation claimOp = createOperation( + Operation.REPLACE, + "/user/claims[uri=urn:ietf:params:scim:schemas:core:2.0:User:name.givenName]", "John"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Claim must NOT be staged in the pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + } + + @Test + public void testUserClaimReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Operation should fail — null value. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getFlowUser().getClaims().get("http://wso2.org/claims/email")); + } + + @Test + public void testUserClaimReplaceEmptyClaimUri() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation claimOp = createOperation(Operation.REPLACE, "/user/claims[uri=]", "value"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Should fail — empty claim URI. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testUserClaimReplaceNoFlowUser() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setFlowUser(null); + + PerformableOperation claimOp = createOperation( + Operation.REPLACE, "/user/claims[uri=http://wso2.org/claims/email]", "test@email.com"); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, claimOp, Collections.emptyMap()); + + // Should fail — no FlowUser. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processSuccessResponse — User input REPLACE ========================= + + @Test + public void testUserInputReplace() throws ActionExecutionResponseProcessorException { + + // /input/ paths are no longer a modify target; operations on them are silently ignored. + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.addUserInputData("consent", "false"); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); + + // Operation succeeds overall but /input/ is not a modify target — input data unchanged. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertEquals(execCtx.getUserInputData().get("consent"), "false"); + } + + @Test + public void testUserInputReplaceCreatesNew() throws ActionExecutionResponseProcessorException { + + // /input/ paths are no longer a modify target; operations on them are silently ignored. + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", "true"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, inputOp, Collections.emptyMap()); + + // Operation succeeds overall but /input/ is not a modify target — value is not created. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + assertNull(execCtx.getUserInputData().get("consent")); + } + + @Test + public void testUserInputReplaceNullValue() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.addUserInputData("consent", "true"); + + PerformableOperation inputOp = createOperation(Operation.REPLACE, "/input/consent", null); + ActionExecutionStatus status = executeSuccessResponse( + execCtx, inputOp, Collections.emptyMap()); + + // Null value should fail. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Original value should remain. + assertEquals(execCtx.getUserInputData().get("consent"), "true"); + } + + // ========================= processSuccessResponse — Read-only paths ========================= + + @Test + public void testReadOnlyFlowPath() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/flow/tenantDomain", "newValue"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Operation should fail but overall status is SUCCESS. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testReadOnlyGraphPath() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/graph/currentNode/id", "newId"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processSuccessResponse — Unknown path ========================= + + @Test + public void testUnknownPathPrefix() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + PerformableOperation op = createOperation(Operation.REPLACE, "/unknown/path", "value"); + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + // Unknown path → operation fails, but overall status is SUCCESS. + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + @Test + public void testNullPathOperationFails() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // Operation with null path must fail gracefully without NPE. + PerformableOperation op = new PerformableOperation(); + op.setOp(Operation.REPLACE); + // path is intentionally not set (null). + + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Nothing should be staged in any pending map. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); + } + + @Test + public void testUnsupportedOperationTypeOnValidPathFails() + throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + // ADD (non-REPLACE) on a valid properties path must be rejected before routing. + PerformableOperation op = new PerformableOperation(); + op.setOp(Operation.ADD); + op.setPath("/properties/riskScore"); + op.setValue("90"); + + ActionExecutionStatus status = executeSuccessResponse(execCtx, op, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + // Property must NOT be staged. + assertNull(capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + } + + // ========================= processSuccessResponse — Multiple operations ========================= + + @Test + public void testMultipleOperationsMixedResults() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + execCtx.setProperty("existingProp", "old"); + + List operations = new ArrayList<>(); + operations.add(createOperation(Operation.REPLACE, "/properties/newProp", "newValue")); + operations.add(createOperation(Operation.REPLACE, "/properties/existingProp", "updated")); + operations.add(createOperation(Operation.REPLACE, "/flow/readonly", "fail")); // This should fail. + + ActionExecutionStatus status = executeSuccessResponse(execCtx, operations, Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + Map pendingProps = + capturedFlowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + assertNotNull(pendingProps); + assertEquals(pendingProps.get("newProp"), "newValue"); + assertEquals(pendingProps.get("existingProp"), "updated"); + } + + @Test + public void testEmptyOperations() throws ActionExecutionResponseProcessorException { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + + ActionExecutionStatus status = executeSuccessResponse( + execCtx, Collections.emptyList(), Collections.emptyMap()); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.SUCCESS); + } + + // ========================= processFailureResponse ========================= + + @Test + public void testProcessFailureResponse() throws ActionExecutionResponseProcessorException { + + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk_detected"); + when(failureResponse.getFailureDescription()).thenReturn("Risk score exceeds threshold"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.FAILED); + assertNotNull(status.getResponse()); + assertEquals(status.getResponse().getFailureReason(), "high_risk_detected"); + assertEquals(status.getResponse().getFailureDescription(), "Risk score exceeds threshold"); + } + + // ========================= processFailureResponse — full-key pass-through ========================= + + @Test + public void testProcessFailureResponseWithFullKeyPassesThrough() + throws ActionExecutionResponseProcessorException { + + // Extension now sends the full i18n key directly — processor must pass it through unchanged. + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk"); + when(failureResponse.getFailureDescription()) + .thenReturn("inflow.extension.risk.assessment.extension.user.access.denied"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + // Full key passes through unchanged — no wrapping or prefixing. + assertEquals(status.getResponse().getFailureDescription(), + "inflow.extension.risk.assessment.extension.user.access.denied"); + assertEquals(status.getResponse().getFailureReason(), "high_risk"); + } + + @Test + public void testProcessFailureResponseWithShortKeyPassesThrough() + throws ActionExecutionResponseProcessorException { + + // Short dot-separated key also passes through as-is — IS no longer adds a prefix. + ActionInvocationFailureResponse failureResponse = mock(ActionInvocationFailureResponse.class); + when(failureResponse.getFailureReason()).thenReturn("high_risk"); + when(failureResponse.getFailureDescription()).thenReturn("user.access.denied"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(failureResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processFailureResponse( + flowContext, responseContext); + + assertEquals(status.getResponse().getFailureDescription(), "user.access.denied"); + assertEquals(status.getResponse().getFailureReason(), "high_risk"); + } + + // ========================= processErrorResponse ========================= + + @Test + public void testProcessErrorResponse() throws ActionExecutionResponseProcessorException { + + ActionInvocationErrorResponse errorResponse = mock(ActionInvocationErrorResponse.class); + when(errorResponse.getErrorMessage()).thenReturn("internal_error"); + when(errorResponse.getErrorDescription()).thenReturn("Database connection failed"); + + @SuppressWarnings("unchecked") + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(errorResponse); + + FlowContext flowContext = FlowContext.create(); + + ActionExecutionStatus status = responseProcessor.processErrorResponse( + flowContext, responseContext); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.ERROR); + assertNotNull(status.getResponse()); + assertEquals(status.getResponse().getErrorMessage(), "internal_error"); + assertEquals(status.getResponse().getErrorDescription(), "Database connection failed"); + } + + // ========================= processIncompleteResponse ========================= + + @Test + public void testProcessIncompleteResponseWithRedirectStashesUrlAndReturnsIncomplete() + throws ActionExecutionResponseProcessorException { + + PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); + + FlowContext flowContext = FlowContext.create(); + ActionExecutionStatus status = executeIncompleteResponse( + flowContext, Collections.singletonList(redirect)); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); + // URL must be stashed under the key the executor reads. + assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), + "https://example.com/step-up"); + } + + @Test + public void testProcessIncompleteResponseIgnoresNonRedirectOperations() + throws ActionExecutionResponseProcessorException { + + // Contract: REPLACE ops sent alongside REDIRECT on an INCOMPLETE response are dropped + // — the extension is expected to resend them on the resume call. + PerformableOperation redirect = createRedirectOperation("https://example.com/step-up"); + PerformableOperation replace = createOperation( + Operation.REPLACE, "/properties/riskScore", "42"); + + FlowContext flowContext = FlowContext.create(); + ActionExecutionStatus status = executeIncompleteResponse( + flowContext, Arrays.asList(redirect, replace)); + + assertEquals(status.getStatus(), ActionExecutionStatus.Status.INCOMPLETE); + // Redirect URL is captured, but the REPLACE must NOT have produced any pending props. + assertEquals(flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), + "https://example.com/step-up"); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(flowContext.getValue(InFlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class)); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithoutRedirectThrows() + throws ActionExecutionResponseProcessorException { + + // INCOMPLETE without a REDIRECT op is a contract violation. + PerformableOperation replace = createOperation( + Operation.REPLACE, "/properties/riskScore", "42"); + + executeIncompleteResponse(FlowContext.create(), Collections.singletonList(replace)); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithEmptyOperationsThrows() + throws ActionExecutionResponseProcessorException { + + executeIncompleteResponse(FlowContext.create(), Collections.emptyList()); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithNullOperationsThrows() + throws ActionExecutionResponseProcessorException { + + executeIncompleteResponse(FlowContext.create(), null); + } + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testProcessIncompleteResponseWithEmptyRedirectUrlThrows() + throws ActionExecutionResponseProcessorException { + + PerformableOperation emptyRedirect = createRedirectOperation(""); + + executeIncompleteResponse(FlowContext.create(), Collections.singletonList(emptyRedirect)); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeIncompleteResponse( + FlowContext flowContext, List operations) + throws ActionExecutionResponseProcessorException { + + ActionInvocationIncompleteResponse incompleteResponse = + mock(ActionInvocationIncompleteResponse.class); + when(incompleteResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(incompleteResponse); + + return responseProcessor.processIncompleteResponse(flowContext, responseContext); + } + + // ========================= Helper methods ========================= + + private FlowExecutionContext createFlowExecutionContext() { + + FlowExecutionContext context = new FlowExecutionContext(); + context.setTenantDomain("carbon.super"); + context.setContextIdentifier("test-id"); + + FlowUser flowUser = new FlowUser(); + flowUser.setId("user-1"); + flowUser.setUsername("testuser"); + context.setFlowUser(flowUser); + + return context; + } + + private PerformableOperation createOperation(Operation op, String path, Object value) { + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(op); + operation.setPath(path); + if (value != null) { + operation.setValue(value); + } + return operation; + } + + private PerformableOperation createRedirectOperation(String url) { + + // PerformableOperation rejects setPath/setValue for REDIRECT and rejects setUrl for + // every other op — REDIRECT carries its target in the dedicated `url` field. + PerformableOperation operation = new PerformableOperation(); + operation.setOp(Operation.REDIRECT); + operation.setUrl(url); + return operation; + } + + private ActionExecutionStatus executeSuccessResponse( + FlowExecutionContext execCtx, PerformableOperation operation, + Map pathTypeAnnotations) + throws ActionExecutionResponseProcessorException { + + return executeSuccessResponse(execCtx, Collections.singletonList(operation), pathTypeAnnotations); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeSuccessResponse( + FlowExecutionContext execCtx, List operations, + Map pathTypeAnnotations) + throws ActionExecutionResponseProcessorException { + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + + capturedFlowContext = flowContext; + + ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); + when(successResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); + + return responseProcessor.processSuccessResponse(flowContext, responseContext); + } + + @SuppressWarnings("unchecked") + private ActionExecutionStatus executeSuccessResponseWithModifyPaths( + FlowExecutionContext execCtx, List operations, + Map pathTypeAnnotations, List modifyPaths) + throws ActionExecutionResponseProcessorException { + + FlowContext flowContext = FlowContext.create() + .add(InFlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(InFlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + if (modifyPaths != null) { + flowContext.add(InFlowExtensionConstants.MODIFY_PATHS_KEY, modifyPaths); + } + + capturedFlowContext = flowContext; + + ActionInvocationSuccessResponse successResponse = mock(ActionInvocationSuccessResponse.class); + when(successResponse.getOperations()).thenReturn(operations); + + ActionExecutionResponseContext responseContext = + mock(ActionExecutionResponseContext.class); + when(responseContext.getActionInvocationResponse()).thenReturn(successResponse); + + return responseProcessor.processSuccessResponse(flowContext, responseContext); + } + + // ========================= Inbound decryption failure ========================= + + @Test(expectedExceptions = ActionExecutionResponseProcessorException.class) + public void testDecryptionFailureThrowsException() + throws ActionExecutionResponseProcessorException { + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(true); + jweUtilMock.when(() -> JWEEncryptionUtil.decrypt(anyString(), anyString())) + .thenThrow(new RuntimeException("Decryption failed")); + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); + + // Value looks like JWE (5 dot-separated parts). + PerformableOperation op = createOperation( + Operation.REPLACE, "/properties/secret", "a.b.c.d.e"); + + executeSuccessResponseWithModifyPaths(execCtx, Collections.singletonList(op), + Collections.emptyMap(), modifyPaths); + } + } + + @Test + public void testNonStringValueForEncryptedPathThrowsException() { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/data", true)); + + // Non-string value (Integer) for an encrypted path must now be rejected with an exception + // because accepting non-JWE values would make the encryption flag advisory. + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/data", 42); + + try { + executeSuccessResponseWithModifyPaths( + execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); + throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); + } catch (ActionExecutionResponseProcessorException e) { + assertTrue(e.getMessage().contains("/properties/data"), + "Exception message should reference the offending path."); + } + } + + @Test + public void testPlaintextStringForEncryptedPathThrowsException() { + + FlowExecutionContext execCtx = createFlowExecutionContext(); + List modifyPaths = Arrays.asList(new ContextPath("/properties/secret", true)); + + // Plain-text string (not JWE-formatted) for an encrypted modify path must be rejected. + PerformableOperation op = createOperation(Operation.REPLACE, "/properties/secret", "plaintext-value"); + + try (MockedStatic jweUtilMock = mockStatic(JWEEncryptionUtil.class)) { + jweUtilMock.when(() -> JWEEncryptionUtil.isJWEEncrypted(anyString())).thenReturn(false); + try { + executeSuccessResponseWithModifyPaths( + execCtx, Collections.singletonList(op), Collections.emptyMap(), modifyPaths); + throw new AssertionError("Expected ActionExecutionResponseProcessorException was not thrown."); + } catch (ActionExecutionResponseProcessorException e) { + assertTrue(e.getMessage().contains("/properties/secret"), + "Exception message should reference the offending path."); + } + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtilTest.java new file mode 100644 index 000000000000..400bbc24b554 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/PathTypeAnnotationUtilTest.java @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.executor; + +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link PathTypeAnnotationUtil}. + */ +public class PathTypeAnnotationUtilTest { + + // ========================= stripAnnotation ========================= + + @Test + public void testStripAnnotationPrimaryType() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactor{String}"); + assertEquals(result[0], "/properties/riskFactor"); + assertEquals(result[1], "String"); + } + + @Test + public void testStripAnnotationMultivaluedPrimary() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskFactors{[String]}"); + assertEquals(result[0], "/properties/riskFactors"); + assertEquals(result[1], "[String]"); + } + + @Test + public void testStripAnnotationComplexObject() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation( + "/properties/risk{risk: Float, factor: String}"); + assertEquals(result[0], "/properties/risk"); + assertEquals(result[1], "risk: Float, factor: String"); + } + + @Test + public void testStripAnnotationMultivaluedComplex() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation( + "/properties/risks{[risk: Float, factor: String]}"); + assertEquals(result[0], "/properties/risks"); + assertEquals(result[1], "[risk: Float, factor: String]"); + } + + @Test + public void testStripAnnotationNoAnnotation() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/riskScore"); + assertEquals(result[0], "/properties/riskScore"); + assertNull(result[1]); + } + + @Test + public void testStripAnnotationNullInput() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation(null); + assertNull(result[0]); + assertNull(result[1]); + } + + @Test + public void testStripAnnotationEmptyBraces() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/field{}"); + assertEquals(result[0], "/properties/field"); + assertEquals(result[1], ""); + } + + @Test + public void testStripAnnotationIntegerType() { + + String[] result = PathTypeAnnotationUtil.stripAnnotation("/properties/count{Integer}"); + assertEquals(result[0], "/properties/count"); + assertEquals(result[1], "Integer"); + } + + // ========================= coerceValue ========================= + + @Test + public void testCoerceValueNoAnnotation() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", 42, Collections.emptyMap()); + assertEquals(result, "42"); + } + + @Test + public void testCoerceValuePrimaryTypeAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", 95, annotations); + assertEquals(result, "95"); + } + + @Test + public void testCoerceValueMultivaluedPrimaryList() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List input = Arrays.asList("tag1", "tag2", "tag3"); + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + } + + @Test + public void testCoerceValueMultivaluedPrimarySingleValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", "singleTag", annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 1); + assertEquals(list.get(0), "singleTag"); + } + + @Test + public void testCoerceValueComplexObjectAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map complexValue = new HashMap<>(); + complexValue.put("risk", 0.85); + complexValue.put("factor", "ip_mismatch"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/risk", complexValue, annotations); + // Complex annotation — passed through as-is. + assertTrue(result instanceof Map); + assertEquals(result, complexValue); + } + + @Test + public void testCoerceValueComplexArrayAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + List> items = Arrays.asList( + new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, + new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} + ); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/risks", items, annotations); + // Complex array annotation — passed through as-is. + assertTrue(result instanceof List); + assertEquals(result, items); + } + + @Test + public void testCoerceValueBooleanPrimaryType() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/active", "Boolean"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/active", true, annotations); + assertEquals(result, "true"); + } + + @Test + public void testCoerceValueStringValue() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/name", "test", Collections.emptyMap()); + assertEquals(result, "test"); + } + + @Test + public void testCoerceValueNoAnnotationNullValue() { + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", null, Collections.emptyMap()); + assertNull(result); + } + + @Test + public void testCoerceValuePrimaryTypeAnnotationNullValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + Object result = PathTypeAnnotationUtil.coerceValue("/properties/score", null, annotations); + assertNull(result); + } + + @Test + public void testCoerceValueMultivaluedPrimaryPreservesNullElements() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List input = Arrays.asList("tag1", null, "tag3"); + Object result = PathTypeAnnotationUtil.coerceValue("/properties/tags", input, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + assertNull(list.get(1)); + assertEquals(list.get(2), "tag3"); + } + + // ========================= validateAnnotationLimits ========================= + + @Test + public void testValidateAnnotationLimitsNull() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(null)); + } + + @Test + public void testValidateAnnotationLimitsEmpty() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("")); + } + + @Test + public void testValidateAnnotationLimitsPrimaryType() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("String")); + } + + @Test + public void testValidateAnnotationLimitsPrimaryArray() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[String]")); + } + + @Test + public void testValidateAnnotationLimitsComplexWithinLimit() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("risk: Float, factor: String")); + } + + @Test + public void testValidateAnnotationLimitsComplexArrayWithinLimit() { + + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits("[risk: Float, factor: String]")); + } + + @Test + public void testValidateAnnotationLimitsExceedsMax() { + + // Build an annotation with 11 attributes (exceeds MAX_ATTRIBUTES_PER_OBJECT = 10). + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 11; i++) { + if (i > 0) sb.append(", "); + sb.append("attr").append(i).append(": String"); + } + assertFalse(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); + } + + @Test + public void testValidateAnnotationLimitsExactlyAtMax() { + + // Build an annotation with exactly 10 attributes. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10; i++) { + if (i > 0) sb.append(", "); + sb.append("attr").append(i).append(": String"); + } + assertTrue(PathTypeAnnotationUtil.validateAnnotationLimits(sb.toString())); + } + + // ========================= validateValueAgainstAnnotation ========================= + + @Test + public void testValidateValueNoAnnotation() { + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/score", 42, Collections.emptyMap())); + } + + @Test + public void testValidateValuePrimaryAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/score", 42, annotations)); + } + + @Test + public void testValidateValueComplexObjectValid() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("factor", "ip_mismatch"); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectUnknownAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("unknown", "bad"); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectNotAMap() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", "not a map", annotations)); + } + + @Test + public void testValidateValueComplexObjectNestedMap() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + Map nested = new HashMap<>(); + nested.put("deep", "value"); + + Map value = new HashMap<>(); + value.put("risk", 0.85); + value.put("factor", nested); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", value, annotations)); + } + + @Test + public void testValidateValueComplexArrayValid() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + List> items = Arrays.asList( + new HashMap() {{ put("risk", 0.5); put("factor", "a"); }}, + new HashMap() {{ put("risk", 0.8); put("factor", "b"); }} + ); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", items, annotations)); + } + + @Test + public void testValidateValueComplexArrayExceedsItemLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float]"); + + List> items = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + Map item = new HashMap<>(); + item.put("risk", (float) i); + items.add(item); + } + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", items, annotations)); + } + + @Test + public void testValidateValueComplexObjectWithArrayAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "tags: String[], score: Float"); + + Map value = new HashMap<>(); + value.put("tags", Arrays.asList("a", "b", "c")); + value.put("score", 0.9); + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/data", value, annotations)); + } + + @Test + public void testValidateValueComplexObjectArrayAttrExceedsLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/data", "tags: String[]"); + + List bigList = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + Map value = new HashMap<>(); + value.put("tags", bigList); + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/data", value, annotations)); + } + + // ========================= enforceArrayItemLimit ========================= + + @Test + public void testEnforceArrayItemLimitNoAnnotation() { + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/score", Arrays.asList(1, 2, 3), Collections.emptyMap())); + } + + @Test + public void testEnforceArrayItemLimitWithinLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", Arrays.asList("a", "b", "c"), annotations)); + } + + @Test + public void testEnforceArrayItemLimitExceedsLimit() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + List bigList = new java.util.ArrayList<>(); + for (int i = 0; i < 11; i++) { + bigList.add("item" + i); + } + + assertFalse(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", bigList, annotations)); + } + + @Test + public void testEnforceArrayItemLimitNonArrayAnnotation() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/score", 42, annotations)); + } + + @Test + public void testEnforceArrayItemLimitNonListValue() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + assertTrue(PathTypeAnnotationUtil.enforceArrayItemLimit( + "/properties/tags", "singleValue", annotations)); + } + + // ========================= JSON string parsing ========================= + + @Test + public void testValidateValueComplexArrayFromJsonString() + throws Exception { + + // This is the exact failing case: after JWE decryption the value is a JSON string. + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); + + String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/riskFactors", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexArrayFromJsonStringMultipleItems() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + String jsonString = "[{\"risk\":0.5,\"factor\":\"ip_mismatch\"}" + + ",{\"risk\":0.8,\"factor\":\"high_risk_email\"}]"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexObjectFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; + + assertTrue(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risk", jsonString, annotations)); + } + + @Test + public void testValidateValueComplexArrayFromJsonStringUnknownAttribute() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risks", "[risk: Float, factor: String]"); + + // JSON string with an attribute not in the schema. + String jsonString = "[{\"risk\":0.5,\"unknown\":\"bad\"}]"; + + assertFalse(PathTypeAnnotationUtil.validateValueAgainstAnnotation( + "/properties/risks", jsonString, annotations)); + } + + @Test + public void testCoerceValueComplexArrayFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/riskFactors", "[factor: String, is-critical: Boolean]"); + + String jsonString = "[{\"factor\":\"no_risk_factors_detected\",\"is-critical\":false}]"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/riskFactors", jsonString, annotations); + + // Should be parsed into a List, not returned as a plain string. + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 1); + assertTrue(list.get(0) instanceof Map); + } + + @Test + public void testCoerceValueMultivaluedPrimaryFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/tags", "[String]"); + + String jsonString = "[\"tag1\",\"tag2\",\"tag3\"]"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/tags", jsonString, annotations); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List list = (List) result; + assertEquals(list.size(), 3); + assertEquals(list.get(0), "tag1"); + } + + @Test + public void testCoerceValueComplexObjectFromJsonString() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/risk", "risk: Float, factor: String"); + + String jsonString = "{\"risk\":0.85,\"factor\":\"ip_mismatch\"}"; + + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/risk", jsonString, annotations); + + // Should be parsed into a Map, not returned as a plain string. + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map map = (Map) result; + assertEquals(map.get("factor"), "ip_mismatch"); + } + + @Test + public void testCoerceValueNonJsonStringIsNotParsed() { + + Map annotations = new HashMap<>(); + annotations.put("/properties/score", "Integer"); + + // A plain string with no JSON structure should be coerced to String normally. + Object result = PathTypeAnnotationUtil.coerceValue( + "/properties/score", "42", annotations); + assertEquals(result, "42"); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowContextHandoverConfigTestHelper.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowContextHandoverConfigTestHelper.java new file mode 100644 index 000000000000..2b4ffe1866e3 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowContextHandoverConfigTestHelper.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extension.InFlowExtensionTestUtils; + +import java.util.Set; + +/** + * Test-only helper that delegates to {@link InFlowExtensionTestUtils#configOf} to + * instantiate {@link FlowContextHandoverConfig} with explicit allow-lists. + */ +final class FlowContextHandoverConfigTestHelper { + + private FlowContextHandoverConfigTestHelper() { + + } + + static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { + + return InFlowExtensionTestUtils.configOf(attrs, userAttrs); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilderTest.java new file mode 100644 index 000000000000..19235868d302 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/InFlowExtensionContextTreeBuilderTest.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.metadata; + +import org.testng.annotations.Test; +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionContextTreeBuilder}. + * + *

    Uses {@link FlowContextHandoverConfigTestHelper} to construct configs with explicit + * allow-lists without touching IdentityConfigParser.

    + */ +public class InFlowExtensionContextTreeBuilderTest { + + // ========================= redirection always enabled ========================= + + @Test + public void testRedirectionAlwaysEnabled() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), + new HashSet<>(), null); + + assertTrue(meta.isRedirectionEnabled(), + "Redirection must always be true regardless of config"); + } + + // ========================= allowReadOnlyClaimsModification ========================= + + @Test + public void testAllowReadOnlyClaimsModificationForRegistration() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationForInvitedUserRegistration() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "INVITED_USER_REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForPasswordRecovery() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "PASSWORD_RECOVERY"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForUnknownFlowType() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "SOME_FUTURE_FLOW"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationTrueForNullFlowType() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + // ========================= flow branch ========================= + + /** + * With an empty allow-list the modifiable fields (claims, credentials, properties) are still + * present but carry only MODIFY — no EXPOSE. Read-only fields (flow branch, user read-only + * scalar) are absent because they have no modify path. + */ + @Test + public void testEmptyAllowListRestrictsExposeOnly() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + + // Modifiable nodes always present. + assertNotNull(findNode(meta, "user"), "user node must be present (has modifiable children)"); + assertNotNull(findNode(meta, "properties"), "properties node must be present (modifiable)"); + + // Read-only-only node absent when nothing configured. + assertNull(findNode(meta, "flow"), "flow node must be absent when no flow attrs configured"); + + // Properties has only MODIFY. + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must not have EXPOSE when not in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must have MODIFY regardless of allow-list"); + + // Claims and credentials carry only MODIFY. + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode, "claims always present"); + assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE"), + "claims must not have EXPOSE when not in allow-list"); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode, "credentials always present"); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), + "credentials must not have EXPOSE when not in allow-list"); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testFlowNodeAppearsWhenFlowAttrsPresent() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain", "flowType")), + new HashSet<>(), null); + + InFlowExtensionContextTreeNode flowNode = findNode(meta, "flow"); + assertNotNull(flowNode, "flow node should be present"); + + // Only the allowed attrs should appear as children. + List children = flowNode.getChildren(); + assertTrue(hasChildKey(children, "tenantDomain"), "tenantDomain expected"); + assertTrue(hasChildKey(children, "flowType"), "flowType expected"); + assertFalse(hasChildKey(children, "applicationId"), "applicationId not in allow-list"); + assertFalse(hasChildKey(children, "callbackUrl"), "callbackUrl not in allow-list"); + assertFalse(hasChildKey(children, "portalUrl"), "portalUrl not in allow-list"); + } + + @Test + public void testFlowNodeAbsentWhenNoFlowAttrsIncluded() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("username")), // only user attr + new HashSet<>(Arrays.asList("username")), null); + + assertNull(findNode(meta, "flow"), "flow node must not appear if no flow attrs present"); + } + + // ========================= user branch ========================= + + @Test + public void testReadOnlyUserFieldsAbsentWhenNoUserAttrsIncluded() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), + new HashSet<>(), null); + + // User node is always present (claims + credentials always included). + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node must be present"); + + List children = userNode.getChildren(); + + // Read-only user fields absent when not configured. + assertFalse(hasChildKey(children, "id"), "userId must not appear"); + assertFalse(hasChildKey(children, "username"), "username must not appear"); + assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain must not appear"); + + // Modifiable fields still present. + assertTrue(hasChildKey(children, "claims"), "claims must always be present"); + assertTrue(hasChildKey(children, "credentials"), "credentials must always be present"); + } + + @Test + public void testUserNodeAppearsWithSelectedUserAttrs() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // no flowUser + new HashSet<>(Arrays.asList("username", "claims")), null); + + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node should be present"); + + List children = userNode.getChildren(); + + // Configured read-only field is exposed. + assertTrue(hasChildKey(children, "username"), "username expected"); + + // Claims configured → EXPOSE+MODIFY. + InFlowExtensionContextTreeNode claimsNode = findChildNode(children, "claims"); + assertNotNull(claimsNode, "claims must be present"); + assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE"), "claims must have EXPOSE"); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY"), "claims must have MODIFY"); + + // Read-only fields not configured → absent. + assertFalse(hasChildKey(children, "id"), "userId not in allow-list"); + assertFalse(hasChildKey(children, "userStoreDomain"), "userStoreDomain not in allow-list"); + + // credentials not configured but always present → MODIFY only, no EXPOSE. + InFlowExtensionContextTreeNode credNode = findChildNode(children, "credentials"); + assertNotNull(credNode, "credentials always present"); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE"), + "credentials must not have EXPOSE when not in allow-list"); + assertTrue(credNode.getAllowedOperations().contains("MODIFY"), + "credentials must have MODIFY regardless"); + } + + @Test + public void testFullUserPassthroughShowsAllUserChildren() { + + // "flowUser" in context attrs → fullUserPassthrough → all user children present. + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("flowUser")), + new HashSet<>(), null); // includedUserAttributes empty — ignored in passthrough + + InFlowExtensionContextTreeNode userNode = findNode(meta, "user"); + assertNotNull(userNode, "user node should be present with full passthrough"); + + List children = userNode.getChildren(); + assertTrue(hasChildKey(children, "id"), "userId must appear in passthrough"); + assertTrue(hasChildKey(children, "username"), "username must appear in passthrough"); + assertTrue(hasChildKey(children, "userStoreDomain"),"userStoreDomain must appear in passthrough"); + assertTrue(hasChildKey(children, "claims"), "claims must appear in passthrough"); + assertTrue(hasChildKey(children, "credentials"), "credentials must appear in passthrough"); + } + + // ========================= properties branch ========================= + + @Test + public void testPropertiesHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // properties not in allow-list + new HashSet<>(), null); + + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertNotNull(propsNode, "properties node must always be present"); + assertFalse(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must not expose when not in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must always allow MODIFY"); + } + + @Test + public void testPropertiesHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("properties")), + new HashSet<>(), null); + + InFlowExtensionContextTreeNode propsNode = findNode(meta, "properties"); + assertNotNull(propsNode, "properties node should be present"); + assertTrue(propsNode.getAllowedOperations().contains("EXPOSE"), + "properties must have EXPOSE when in allow-list"); + assertTrue(propsNode.getAllowedOperations().contains("MODIFY"), + "properties must always have MODIFY"); + } + + @Test + public void testClaimsHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "claims" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testClaimsHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("claims")), null); + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasOnlyModifyWhenNotExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "userCredentials" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasExposeAndModifyWhenExposed() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("userCredentials")), null); + + List userChildren = findNode(meta, "user").getChildren(); + InFlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode); + assertTrue(credNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testFullPassthroughGivesExposeOnClaimsAndCredentials() { + + // flowUser in attrs → full passthrough → all user fields exposed. + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("flowUser")), + new HashSet<>(), null); + + List userChildren = findNode(meta, "user").getChildren(); + assertTrue(findChildNode(userChildren, "claims").getAllowedOperations().contains("EXPOSE")); + assertTrue(findChildNode(userChildren, "credentials").getAllowedOperations().contains("EXPOSE")); + } + + // ========================= flowType metadata field ========================= + + @Test + public void testFlowTypeFieldPreserved() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + + assertEquals(meta.getFlowType(), "REGISTRATION"); + } + + @Test + public void testFlowTypeNullPreserved() { + + InFlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + + assertNull(meta.getFlowType()); + } + + // ========================= resolveAllowReadOnlyClaimsModification (static) ========================= + + @Test + public void testResolveAllowReadOnlyClaimsModificationDirectly() { + + assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification(null)); + assertTrue(InFlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification("REGISTRATION")); + assertTrue(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("INVITED_USER_REGISTRATION")); + assertFalse(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("PASSWORD_RECOVERY")); + assertFalse(InFlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("UNKNOWN_TYPE")); + } + + // ========================= helpers ========================= + + private InFlowExtensionContextTreeMetadata buildWith(Set attrs, + Set userAttrs, + String flowType) { + + FlowContextHandoverConfig cfg = FlowContextHandoverConfigTestHelper.of(attrs, userAttrs); + return new InFlowExtensionContextTreeBuilder(cfg).build(flowType); + } + + private InFlowExtensionContextTreeNode findNode(InFlowExtensionContextTreeMetadata meta, + String key) { + + if (meta.getContextTree() == null) { + return null; + } + for (InFlowExtensionContextTreeNode node : meta.getContextTree()) { + if (key.equals(node.getKey())) { + return node; + } + } + return null; + } + + private boolean hasChildKey(List children, String key) { + + if (children == null) { + return false; + } + for (InFlowExtensionContextTreeNode child : children) { + if (key.equals(child.getKey())) { + return true; + } + } + return false; + } + + private InFlowExtensionContextTreeNode findChildNode(List children, + String key) { + + if (children == null) { + return null; + } + for (InFlowExtensionContextTreeNode child : children) { + if (key.equals(child.getKey())) { + return child; + } + } + return null; + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/AccessConfigTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/AccessConfigTest.java new file mode 100644 index 000000000000..afba6207cf5e --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/AccessConfigTest.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link AccessConfig} and {@link ContextPath}. + */ +public class AccessConfigTest { + + @Test + public void testAccessConfigWithNullLists() { + + AccessConfig config = new AccessConfig(null, null); + assertNull(config.getExpose()); + assertNull(config.getModify()); + assertNotNull(config.getExposePaths()); + assertTrue(config.getExposePaths().isEmpty()); + assertNotNull(config.getModifyPaths()); + assertTrue(config.getModifyPaths().isEmpty()); + } + + @Test + public void testAccessConfigWithEmptyLists() { + + AccessConfig config = new AccessConfig(Collections.emptyList(), Collections.emptyList()); + assertNotNull(config.getExpose()); + assertTrue(config.getExpose().isEmpty()); + assertNotNull(config.getModify()); + assertTrue(config.getModify().isEmpty()); + assertNotNull(config.getExposePaths()); + assertTrue(config.getExposePaths().isEmpty()); + assertNotNull(config.getModifyPaths()); + assertTrue(config.getModifyPaths().isEmpty()); + } + + @Test + public void testGetExposePaths() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/", true), + new ContextPath("/user/credentials/", false) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + List paths = config.getExposePaths(); + assertEquals(paths.size(), 2); + assertEquals(paths.get(0), "/user/claims/"); + assertEquals(paths.get(1), "/user/credentials/"); + } + + @Test + public void testGetModifyPaths() { + + List modifyPaths = Arrays.asList( + new ContextPath("/properties/riskScore", false), + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + List paths = config.getModifyPaths(); + assertEquals(paths.size(), 2); + assertEquals(paths.get(0), "/properties/riskScore"); + assertEquals(paths.get(1), "/user/claims/"); + } + + @Test + public void testIsExposePathEncryptedMatchesLongestPrefix() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/email", true), + new ContextPath("/user/username", false) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + assertTrue(config.isExposePathEncrypted("/user/claims/email")); + assertFalse(config.isExposePathEncrypted("/user/username")); + } + + @Test + public void testIsExposePathEncryptedNoMatch() { + + List exposePaths = Collections.singletonList( + new ContextPath("/user/claims/email", true) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + assertFalse(config.isExposePathEncrypted("/properties/riskScore")); + } + + @Test + public void testIsExposePathEncryptedWithNullExpose() { + + AccessConfig config = new AccessConfig(null, null); + assertFalse(config.isExposePathEncrypted("/user/claims/email")); + } + + @Test + public void testIsModifyPathEncryptedMatchesExactPath() { + + List modifyPaths = Arrays.asList( + new ContextPath("/user/username", false), + new ContextPath("/user/credentials/password", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + assertTrue(config.isModifyPathEncrypted("/user/credentials/password")); + assertFalse(config.isModifyPathEncrypted("/user/username")); + assertFalse(config.isModifyPathEncrypted("/user/credentials/other")); + } + + @Test + public void testIsModifyPathEncryptedWithNullModify() { + + AccessConfig config = new AccessConfig(null, null); + assertFalse(config.isModifyPathEncrypted("/user/credentials/password")); + } + + @Test + public void testIsModifyPathEncryptedWithAnnotatedPaths() { + + List modifyPaths = Arrays.asList( + new ContextPath("/properties/risk{risk: Float, factor: String}", true), + new ContextPath("/properties/tags{[String]}", false) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + // Clean operation path should match annotated modify path after stripping. + assertTrue(config.isModifyPathEncrypted("/properties/risk")); + assertFalse(config.isModifyPathEncrypted("/properties/tags")); + assertFalse(config.isModifyPathEncrypted("/properties/unknown")); + } + + @Test + public void testContextPathGetters() { + + ContextPath path = new ContextPath("/user/claims/email", true); + assertEquals(path.getPath(), "/user/claims/email"); + assertTrue(path.isEncrypted()); + + ContextPath unencrypted = new ContextPath("/properties/", false); + assertEquals(unencrypted.getPath(), "/properties/"); + assertFalse(unencrypted.isEncrypted()); + } + + @Test + public void testExposeListIsUnmodifiable() { + + List exposePaths = Arrays.asList( + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(exposePaths, null); + + try { + config.getExpose().add(new ContextPath("/hack/", false)); + // If no exception thrown, fail the test + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void testModifyListIsUnmodifiable() { + + List modifyPaths = Arrays.asList( + new ContextPath("/user/claims/", true) + ); + AccessConfig config = new AccessConfig(null, modifyPaths); + + try { + config.getModify().add(new ContextPath("/hack/", false)); + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEventTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEventTest.java new file mode 100644 index 000000000000..5ec5326a0ff6 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/InFlowExtensionEventTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.testng.annotations.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionEvent}. + */ +public class InFlowExtensionEventTest { + + @Test + public void testBuilderWithAllFields() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("riskScore", 85); + + InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() + .flowType("REGISTRATION") + .flowId("flow-id-123") + .build(); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flow(flow) + .callbackUrl("https://example.com/callback") + .portalUrl("https://example.com/portal") + .flowProperties(flowProperties) + .build(); + + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowType(), "REGISTRATION"); + assertEquals(event.getFlow().getFlowId(), "flow-id-123"); + assertEquals(event.getCallbackUrl(), "https://example.com/callback"); + assertEquals(event.getPortalUrl(), "https://example.com/portal"); + assertEquals(event.getFlowProperties().get("riskScore"), 85); + } + + @Test + public void testOptionalFieldsDefaultToNull() { + + InFlowExtensionFlow flow = new InFlowExtensionFlow.Builder() + .flowType("LOGIN") + .flowId("flow-id-456") + .build(); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flow(flow) + .flowProperties(null) + .build(); + + assertNotNull(event.getFlow()); + assertEquals(event.getFlow().getFlowType(), "LOGIN"); + assertEquals(event.getFlow().getFlowId(), "flow-id-456"); + assertNull(event.getCallbackUrl()); + assertNull(event.getPortalUrl()); + assertNotNull(event.getFlowProperties()); + assertTrue(event.getFlowProperties().isEmpty()); + } + + @Test + public void testFlowPropertiesAreUnmodifiable() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("key", "value"); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowProperties(flowProperties) + .build(); + + try { + event.getFlowProperties().put("hack", "value"); + assertTrue(false, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected — map is unmodifiable + } + } + + @Test + public void testBuilderDoesNotShareMapReferences() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("score", "original"); + + InFlowExtensionEvent event = new InFlowExtensionEvent.Builder() + .flowProperties(flowProperties) + .build(); + + // Mutating the original map should not affect the event + flowProperties.put("score", "modified"); + assertEquals(event.getFlowProperties().get("score"), "original"); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResultTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResultTest.java new file mode 100644 index 000000000000..b1826424120d --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/OperationExecutionResultTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.model; + +import org.testng.annotations.Test; +import org.wso2.carbon.identity.action.execution.api.model.Operation; +import org.wso2.carbon.identity.action.execution.api.model.PerformableOperation; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +/** + * Unit tests for {@link OperationExecutionResult}. + */ +public class OperationExecutionResultTest { + + private PerformableOperation createOperation(Operation op, String path, Object value) { + + PerformableOperation operation = new PerformableOperation(); + operation.setOp(op); + operation.setPath(path); + operation.setValue(value); + return operation; + } + + @Test + public void testSuccessResult() { + + PerformableOperation operation = createOperation( + Operation.REPLACE, "/user/claims/email", "test@example.com"); + OperationExecutionResult result = new OperationExecutionResult( + operation, OperationExecutionResult.Status.SUCCESS, "Claim updated"); + + assertEquals(result.getOperation(), operation); + assertEquals(result.getStatus(), OperationExecutionResult.Status.SUCCESS); + assertEquals(result.getMessage(), "Claim updated"); + } + + @Test + public void testFailureResult() { + + PerformableOperation operation = createOperation( + Operation.REPLACE, "/user/credentials/password", "newPass"); + OperationExecutionResult result = new OperationExecutionResult( + operation, OperationExecutionResult.Status.FAILURE, "Path not allowed"); + + assertEquals(result.getStatus(), OperationExecutionResult.Status.FAILURE); + assertEquals(result.getMessage(), "Path not allowed"); + assertNotNull(result.getOperation()); + } + +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtilTest.java new file mode 100644 index 000000000000..f1785c5a88c2 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/InFlowExtensionPathUtilTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.wso2.carbon.identity.flow.extension.util; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for {@link InFlowExtensionPathUtil}. + */ +public class InFlowExtensionPathUtilTest { + + // ========================= isReadOnly ========================= + + @DataProvider(name = "readOnlyPaths") + public Object[][] readOnlyPaths() { + + return new Object[][] { + { "/flow/tenantDomain", true }, + { "/flow/applicationId", true }, + { "/flow/", true }, + { "/properties/riskScore", false }, + { "/user/claims/email", false }, + { null, false }, + }; + } + + @Test(dataProvider = "readOnlyPaths") + public void testIsReadOnly(String path, boolean expected) { + + assertEquals(InFlowExtensionPathUtil.isReadOnly(path), expected); + } + + // ========================= anyExposedUnder ========================= + + @Test + public void testAnyExposedUnderMatchesLeafUnderPrefix() { + + List leafPaths = Arrays.asList( + "/user/claims/http://wso2.org/claims/email", + "/properties/riskScore"); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/flow/applicationId"); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNullPrefix() { + + assertFalse(InFlowExtensionPathUtil.anyExposedUnder(null, + Arrays.asList("/user/claims/email"))); + } + + @Test + public void testAnyExposedUnderNullList() { + + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", null)); + } + + @Test + public void testAnyExposedUnderEmptyList() { + + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", + Collections.emptyList())); + } + + @Test + public void testAnyExposedUnderDoesNotMatchShortPath() { + + List leafPaths = Collections.singletonList("/user/userId"); + assertFalse(InFlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + } + + @Test + public void testAnyExposedUnderMultipleLeafsOneMatches() { + + List leafPaths = Arrays.asList( + "/flow/tenantDomain", + "/user/credentials/password"); + assertTrue(InFlowExtensionPathUtil.anyExposedUnder("/user/credentials/", leafPaths)); + } + + // ========================= isExposedPath ========================= + + @Test + public void testIsExposedPathExactMatch() { + + List leafPaths = Arrays.asList( + "/user/claims/http://wso2.org/claims/email", + "/flow/tenantDomain", + "/user/userId"); + assertTrue(InFlowExtensionPathUtil.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + assertTrue(InFlowExtensionPathUtil.isExposedPath("/flow/tenantDomain", leafPaths)); + assertTrue(InFlowExtensionPathUtil.isExposedPath("/user/userId", leafPaths)); + } + + @Test + public void testIsExposedPathNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/user/userId"); + assertFalse(InFlowExtensionPathUtil.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + } + + @Test + public void testIsExposedPathNullPath() { + + assertFalse(InFlowExtensionPathUtil.isExposedPath(null, + Arrays.asList("/user/userId"))); + } + + @Test + public void testIsExposedPathNullList() { + + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", null)); + } + + @Test + public void testIsExposedPathEmptyList() { + + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/userId", + Collections.emptyList())); + } + + @Test + public void testIsExposedPathPrefixNotSufficient() { + + List leafPaths = Collections.singletonList("/user/claims/http://wso2.org/claims/email"); + assertFalse(InFlowExtensionPathUtil.isExposedPath("/user/claims/", leafPaths)); + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/repository/conf/carbon.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/repository/conf/carbon.xml new file mode 100755 index 000000000000..17a93f2ff2ad --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/repository/conf/carbon.xml @@ -0,0 +1,687 @@ + + + + + + + + WSO2 Identity Server + + + IS + + + 5.3.0 + + + localhost + + + localhost + + + local:/${carbon.context}/services/ + + + + + + + IdentityServer + + + + + + + org.wso2.carbon + + + / + + + + + + + + + 15 + + + + + + + + + 0 + + + + + 9999 + + 11111 + + + + + + 10389 + + 8000 + + + + + + 10500 + + + + + + + + + org.wso2.carbon.tomcat.jndi.CarbonJavaURLContextFactory + + + + + + + + + + java + + + + + + + + + + false + + + false + + + 600 + + + + false + + + + + + + + 30 + + + + + + + + + 15 + + + + + + ${carbon.home}/repository/deployment/server/ + + + 15 + + + ${carbon.home}/repository/conf/axis2/axis2.xml + + + 30000 + + + ${carbon.home}/repository/deployment/client/ + + ${carbon.home}/repository/conf/axis2/axis2_client.xml + + true + + + + + + + + + + admin + Default Administrator Role + + + user + Default User Role + + + + + + + + + + + + ${carbon.home}/repository/resources/security/wso2carbon.jks + + JKS + + wso2carbon + + wso2carbon + + wso2carbon + + + + + + ${carbon.home}/repository/resources/security/client-truststore.jks + + JKS + + wso2carbon + + + + + + + + + + + + + + + + + + + UserManager + + + false + + org.wso2.carbon.identity.provider.AttributeCallbackHandler + + + org.wso2.carbon.identity.sts.store.DBTokenStore + + + true + allow + + + + + + + claim_mgt_menu + identity_mgt_emailtemplate_menu + identity_security_questions_menu + + + + ${carbon.home}/tmp/work + + + + + + true + + + 10 + + + 30 + + + + + + 100 + + + + keystore + certificate + * + + org.wso2.carbon.ui.transports.fileupload.AnyFileUploadExecutor + + + + + jarZip + + org.wso2.carbon.ui.transports.fileupload.JarZipUploadExecutor + + + + dbs + + org.wso2.carbon.ui.transports.fileupload.DBSFileUploadExecutor + + + + tools + + org.wso2.carbon.ui.transports.fileupload.ToolsFileUploadExecutor + + + + toolsAny + + org.wso2.carbon.ui.transports.fileupload.ToolsAnyFileUploadExecutor + + + + + + + + + + info + org.wso2.carbon.core.transports.util.InfoProcessor + + + wsdl + org.wso2.carbon.core.transports.util.Wsdl11Processor + + + wsdl2 + org.wso2.carbon.core.transports.util.Wsdl20Processor + + + xsd + org.wso2.carbon.core.transports.util.XsdProcessor + + + + + + false + false + true + svn + http://svnrepo.example.com/repos/ + username + password + true + + + + + + + + + + + + + + + ${require.carbon.servlet} + + + + + true + + + + + + + default repository + http://product-dist.wso2.com/p2/carbon/releases/wilkes/ + + + + + + + + true + + + + + + true + + diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/testng.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/testng.xml new file mode 100644 index 000000000000..7342f9cc1922 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/resources/testng.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension.server.feature/pom.xml b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension.server.feature/pom.xml new file mode 100644 index 000000000000..b403ff74850a --- /dev/null +++ b/features/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension.server.feature/pom.xml @@ -0,0 +1,73 @@ + + + + + org.wso2.carbon.identity.framework + flow-orchestration-framework-feature + 7.11.94-SNAPSHOT + ../pom.xml + + + 4.0.0 + org.wso2.carbon.identity.flow.extension.server.feature + pom + Flow InFlow Extensions Feature + https://wso2.com + This feature contains the bundles required for in-flow extension support in the flow engine. + + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.extension + + + + + + + org.wso2.maven + carbon-p2-plugin + ${carbon.p2.plugin.version} + + + 4-p2-feature-generation + package + + p2-feature-gen + + + org.wso2.carbon.identity.flow.extension.server + ../../etc/feature.properties + + + org.wso2.carbon.p2.category.type:server + + + + + org.wso2.carbon.identity.framework:org.wso2.carbon.identity.flow.extension + + + + + + + + + From 93c87cebbf1b431e48052f1856e2c265a6cc6248 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Sun, 24 May 2026 19:06:30 +0530 Subject: [PATCH 06/17] Address comments --- ...ActionExecutionServiceComponentHolder.java | 22 ------------------- .../impl/ActionExecutorServiceImpl.java | 16 +++++--------- .../management/api/constant/ErrorMessage.java | 3 +-- .../engine/graph/TaskExecutionNode.java | 4 +++- 4 files changed, 9 insertions(+), 36 deletions(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java index 05f2708d7fc9..d5f90106b0d0 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java @@ -18,7 +18,6 @@ package org.wso2.carbon.identity.action.execution.internal.component; -import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService; import org.wso2.carbon.identity.action.management.api.service.ActionManagementService; import org.wso2.carbon.identity.rule.evaluation.api.service.RuleEvaluationService; import org.wso2.carbon.identity.secret.mgt.core.SecretManager; @@ -37,7 +36,6 @@ public class ActionExecutionServiceComponentHolder { private SecretManager secretManager; private SecretResolveManager secretResolveManager; private RealmService realmService; - private ActionExecutorService actionExecutorService; private ActionExecutionServiceComponentHolder() { @@ -117,24 +115,4 @@ public void setRealmService(RealmService realmService) { this.realmService = realmService; } - - /** - * Get the ActionExecutorService instance. - * - * @return ActionExecutorService instance. - */ - public ActionExecutorService getActionExecutorService() { - - return actionExecutorService; - } - - /** - * Set the ActionExecutorService instance. - * - * @param actionExecutorService ActionExecutorService instance. - */ - public void setActionExecutorService(ActionExecutorService actionExecutorService) { - - this.actionExecutorService = actionExecutorService; - } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java index 1936f0d98e33..10c884b4c46e 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java @@ -148,14 +148,12 @@ public ActionExecutionStatus execute(ActionType actionType, String actionId, throw new ActionExecutionException("Action Id cannot be blank."); } - try { - Action action = getActionByActionId(actionType, actionId, tenantDomain); - return execute(action, flowContext, tenantDomain); - } catch (ActionExecutionRuntimeException e) { - LOG.debug("Skip executing action for action type: " + actionType.name(), e); - // Skip executing actions when no action available is considered as action execution being successful. + Action action = getActionByActionId(actionType, actionId, tenantDomain); + if (action == null) { + LOG.debug("No action found for action Id: " + actionId + ". Skipping action execution."); return new SuccessStatus.Builder().setResponseContext(flowContext.getContextData()).build(); } + return execute(action, flowContext, tenantDomain); } private ActionExecutionStatus execute(Action action, FlowContext flowContext, String tenantDomain) @@ -197,13 +195,9 @@ private Action getActionByActionId(ActionType actionType, String actionId, Strin throws ActionExecutionException { try { - Action action = ActionExecutionServiceComponentHolder.getInstance().getActionManagementService() + return ActionExecutionServiceComponentHolder.getInstance().getActionManagementService() .getActionByActionId(Action.ActionTypes.valueOf(actionType.name()).getPathParam(), actionId, tenantDomain); - if (action == null) { - throw new ActionExecutionRuntimeException("No action found for action Id: " + actionId); - } - return action; } catch (ActionMgtException e) { throw new ActionExecutionException("Error occurred while retrieving action by action Id.", e); } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java index ef1ceaf5b84d..02357dd9470c 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java @@ -43,8 +43,7 @@ public enum ErrorMessage { "An unsupported rule has been provided for action version %s."), ERROR_MAXIMUM_ATTRIBUTES_LIMIT_EXCEEDED("60011", "Maximum attributes limit exceeded.", "The number of configured attributes: %s exceeds the maximum allowed limit: %s"), - ERROR_INVALID_ATTRIBUTES("60012", "Invalid attribute provided.", - "The provided %s attribute is not available in the system."), + ERROR_INVALID_ATTRIBUTES("60012", "Invalid attribute provided.", "%s"), ERROR_EMPTY_ATTRIBUTE_VALUE("60013", "Invalid attribute provided.", "Each attribute must be a non-empty string."), ERROR_UNSUPPORTED_ATTRIBUTE("60014", "Unsupported attribute provided.", diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java index 669577f412e5..ccad79f3eae4 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java @@ -22,6 +22,7 @@ import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder; @@ -242,7 +243,8 @@ private void resolveUserIdFromUserStore(FlowUser user, String tenantDomain) { user.setId(userId); } } catch (Exception e) { - LOG.warn("Failed to resolve userId for user '" + username + "' from user store.", e); + LOG.debug("Failed to resolve userId for user '" + + LoggerUtils.getMaskedContent(username) + "' from user store.", e); } } } From dbedb573a9797ee970fd60daead82d38e6a281c9 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Sun, 24 May 2026 21:35:27 +0530 Subject: [PATCH 07/17] remove entitlement change --- components/entitlement/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/entitlement/pom.xml b/components/entitlement/pom.xml index 807c82c73870..c555f126527f 100644 --- a/components/entitlement/pom.xml +++ b/components/entitlement/pom.xml @@ -21,7 +21,7 @@ org.wso2.carbon.identity.framework identity-framework - 7.11.88-SNAPSHOT + 7.7.0-SNAPSHOT ../../pom.xml From ea0efcec1f6141b633e6b15e8116683c57eb0ec7 Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:19:39 +0530 Subject: [PATCH 08/17] Move FLOW_EXTENSION action name uniqueness validation to action management service --- .../impl/ActionExecutorServiceImpl.java | 5 +-- .../api/service/ActionValidator.java | 37 ---------------- .../service/impl/DefaultActionValidator.java | 42 ------------------- .../impl/ActionManagementServiceImpl.java | 32 +++++++++----- 4 files changed, 23 insertions(+), 93 deletions(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java index 10c884b4c46e..6957e2996ad2 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/service/impl/ActionExecutorServiceImpl.java @@ -195,9 +195,8 @@ private Action getActionByActionId(ActionType actionType, String actionId, Strin throws ActionExecutionException { try { - return ActionExecutionServiceComponentHolder.getInstance().getActionManagementService() - .getActionByActionId(Action.ActionTypes.valueOf(actionType.name()).getPathParam(), actionId, - tenantDomain); + return ActionExecutionServiceComponentHolder.getInstance().getActionManagementService().getActionByActionId( + Action.ActionTypes.valueOf(actionType.name()).getPathParam(), actionId, tenantDomain); } catch (ActionMgtException e) { throw new ActionExecutionException("Error occurred while retrieving action by action Id.", e); } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java index 9ae26e0c37ca..517830f1145b 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionValidator.java @@ -20,9 +20,6 @@ import org.wso2.carbon.identity.action.management.api.exception.ActionMgtException; import org.wso2.carbon.identity.action.management.api.model.Action; -import org.wso2.carbon.identity.action.management.api.model.ActionDTO; - -import java.util.List; /** * This interface to the validate action in the Action management service layer. @@ -45,22 +42,6 @@ public interface ActionValidator { void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action) throws ActionMgtException; - /** - * Perform pre validations on action model when creating an action, including tenant-scoped checks. - * Overload that receives the pre-fetched list of existing actions for validations such as name uniqueness. - * Default delegates to {@link #doPreAddActionValidations(Action.ActionTypes, String, Action)} for - * backward compatibility with existing implementations. - * - * @param action Action creation model. - * @param existingActionsOfType Existing actions of the same type in the tenant. - * @throws ActionMgtException if action model is invalid. - */ - default void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, - List existingActionsOfType) throws ActionMgtException { - - doPreAddActionValidations(actionType, actionVersion, action); - } - /** * Perform pre validations on action model when updating an existing action. * This is specifically used during HTTP PATCH operation and only validate non-null and non-empty fields. @@ -70,22 +51,4 @@ default void doPreAddActionValidations(Action.ActionTypes actionType, String act */ void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action) throws ActionMgtException; - - /** - * Perform pre validations on action model when updating an existing action, including tenant-scoped checks. - * Overload that receives the pre-fetched list of existing actions for validations such as name uniqueness. - * Default delegates to {@link #doPreUpdateActionValidations(Action.ActionTypes, String, Action)} for - * backward compatibility. - * - * @param action Action update model. - * @param excludeId Action ID to exclude from uniqueness check. - * @param existingActionsOfType Existing actions of the same type in the tenant. - * @throws ActionMgtException if action model is invalid. - */ - default void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, - String excludeId, List existingActionsOfType) - throws ActionMgtException { - - doPreUpdateActionValidations(actionType, actionVersion, action); - } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java index cb342dad3ae9..fffa032b9bbb 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java @@ -27,7 +27,6 @@ import org.wso2.carbon.identity.action.management.api.exception.ActionMgtException; import org.wso2.carbon.identity.action.management.api.model.Action; import org.wso2.carbon.identity.action.management.api.model.Action.ActionTypes; -import org.wso2.carbon.identity.action.management.api.model.ActionDTO; import org.wso2.carbon.identity.action.management.api.model.Authentication; import org.wso2.carbon.identity.action.management.api.service.ActionValidator; import org.wso2.carbon.identity.action.management.internal.component.ActionMgtServiceComponentHolder; @@ -94,16 +93,6 @@ public void doPreAddActionValidations(Action.ActionTypes actionType, String acti isRulesApplicableForActionVersion(actionVersion, action); } - @Override - public void doPreAddActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, - List existingActionsOfType) throws ActionMgtException { - - doPreAddActionValidations(actionType, actionVersion, action); - if (ActionTypes.FLOW_EXTENSION.equals(actionType)) { - validateActionNameUniqueness(action.getName(), null, existingActionsOfType); - } - } - /** * Perform pre validations on action model when updating an existing action. * This is specifically used during HTTP PATCH operation and only validate non-null and non-empty fields. @@ -133,17 +122,6 @@ public void doPreUpdateActionValidations(Action.ActionTypes actionType, String a isRulesApplicableForActionVersion(actionVersion, action); } - @Override - public void doPreUpdateActionValidations(Action.ActionTypes actionType, String actionVersion, Action action, - String excludeId, List existingActionsOfType) - throws ActionMgtException { - - doPreUpdateActionValidations(actionType, actionVersion, action); - if (action.getName() != null && ActionTypes.FLOW_EXTENSION.equals(actionType)) { - validateActionNameUniqueness(action.getName(), excludeId, existingActionsOfType); - } - } - /** * Perform pre validations on endpoint authentication model. * @@ -271,26 +249,6 @@ private void validateAllowedParameters(List allowedParametersInAction) t } } - /** - * Validate that the action name is unique within the given list of existing actions. - * - * @param name Action name to validate. - * @param excludeId Action ID to exclude (for update). Null for creation. - * @param existing Existing actions of the same type in the tenant. - * @throws ActionMgtClientException If a duplicate name is found. - */ - public void validateActionNameUniqueness(String name, String excludeId, List existing) - throws ActionMgtClientException { - - boolean duplicateExists = existing.stream() - .filter(dto -> excludeId == null || !excludeId.equals(dto.getId())) - .anyMatch(dto -> name.equalsIgnoreCase(dto.getName())); - - if (duplicateExists) { - throw ActionManagementExceptionHandler.handleClientException( - ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name); - } - } /** * Validate whether required fields exist. diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index dcd822a9a4d7..2ecdee35150a 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -42,7 +42,6 @@ import org.wso2.carbon.identity.core.util.IdentityUtil; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; @@ -76,12 +75,9 @@ public Action addAction(String actionType, Action action, String tenantDomain) t int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); String resolvedActionType = getActionTypeFromPath(actionType); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); - List existingActions = ActionTypes.FLOW_EXTENSION.equals(castedActionType) - ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) - : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreAddActionValidations( - castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action, - existingActions); + castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action); + validateActionNameUniqueness(action.getName(), null, castedActionType, tenantId); // Check whether the maximum allowed actions per type is reached. validateMaxActionsPerType(resolvedActionType, tenantDomain); String generatedActionId = UUID.randomUUID().toString(); @@ -165,12 +161,9 @@ public Action updateAction(String actionType, String actionId, Action action, St String resolvedActionType = getActionTypeFromPath(actionType); ActionDTO existingActionDTO = checkIfActionExists(resolvedActionType, actionId, tenantDomain); Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); - List existingActions = ActionTypes.FLOW_EXTENSION.equals(castedActionType) - ? DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId) - : Collections.emptyList(); ActionValidatorFactory.getActionValidator(castedActionType).doPreUpdateActionValidations( - castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action, actionId, - existingActions); + castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action); + validateActionNameUniqueness(action.getName(), actionId, castedActionType, tenantId); ActionDTO updatingActionDTO = buildActionDTOForUpdate(resolvedActionType, actionId, action); DAO_FACADE.updateAction(updatingActionDTO, existingActionDTO, tenantId); @@ -485,4 +478,21 @@ private Action buildAction(String actionType, ActionDTO actionDTO) { .rule(actionDTO.getActionRule()) .build(); } + + private void validateActionNameUniqueness(String name, String excludeId, ActionTypes actionType, int tenantId) + throws ActionMgtException { + + if (name == null || !ActionTypes.FLOW_EXTENSION.equals(actionType)) { + return; + } + List existingActions = DAO_FACADE.getActionsByActionType(actionType.getActionType(), tenantId); + boolean duplicateExists = existingActions.stream() + .filter(dto -> excludeId == null || !excludeId.equals(dto.getId())) + .anyMatch(dto -> name.equalsIgnoreCase(dto.getName())); + + if (duplicateExists) { + throw ActionManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name); + } + } } From 2400b675e29e448d0fa87d752aacdb61625db31c Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:28:20 +0530 Subject: [PATCH 09/17] Default new action status to ACTIVE only for in-flow category actions --- .../service/impl/ActionManagementServiceImpl.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index 2ecdee35150a..107cc941ca02 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -370,11 +370,13 @@ private ActionDTO buildActionDTOForCreation(String actionType, String actionId, throws ActionMgtServerException { Action.ActionTypes resolvedActionType = Action.ActionTypes.valueOf(actionType); - // PRE_POST actions start INACTIVE (require explicit activation). - // IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSION) - // start ACTIVE and can be used immediately. - Action.Status resolvedStatus = resolvedActionType.getCategory() == Action.ActionTypes.Category.PRE_POST ? - Action.Status.INACTIVE : Action.Status.ACTIVE; + // Only IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSION) + // start ACTIVE and can be used immediately. All other categories (e.g., PRE_POST) start + // INACTIVE and require explicit activation. + Action.ActionTypes.Category category = resolvedActionType.getCategory(); + Action.Status resolvedStatus = (category == Action.ActionTypes.Category.IN_FLOW + || category == Action.ActionTypes.Category.IN_FLOW_EXTENSION) + ? Action.Status.ACTIVE : Action.Status.INACTIVE; String actionVersion = ActionManagementConfig.getInstance().getLatestVersion(resolvedActionType); From 1150b2c2355f2de9da0b37464a4bee76a6ab225b Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:29:21 +0530 Subject: [PATCH 10/17] Rename in-flow extension to flow extension --- .../internal/util/ActionExecutorConfig.java | 12 ++++++------ .../identity/action/management/api/model/Action.java | 8 ++++---- .../service/impl/ActionManagementServiceImpl.java | 6 +++--- .../internal/util/ActionManagementConfig.java | 4 ++-- .../action/management/model/ActionTypesTest.java | 8 ++++---- .../flow/extension/InFlowExtensionConstants.java | 2 +- .../extension/executor/InFlowExtensionExecutor.java | 2 +- .../executor/InFlowExtensionExecutorTest.java | 2 +- .../resources/identity.xml.j2 | 8 ++++---- ....carbon.identity.core.server.feature.default.json | 4 ++-- 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java index 66858fda1f04..d05cfe27a653 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/util/ActionExecutorConfig.java @@ -422,12 +422,12 @@ private enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.ActionRequest.AllowedHeaders.Header", "Actions.Types.PreIssueIdToken.ActionRequest.AllowedParameters.Parameter", "Actions.Types.PreIssueIdToken.Version.RetiredUpTo"), - FLOW_EXTENSION("Actions.Types.InFlowExtension.Enable", - "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", - "Actions.Types.InFlowExtension.ActionRequest.ExcludedParameters.Parameter", - "Actions.Types.InFlowExtension.ActionRequest.AllowedHeaders.Header", - "Actions.Types.InFlowExtension.ActionRequest.AllowedParameters.Parameter", - "Actions.Types.InFlowExtension.Version.RetiredUpTo"); + FLOW_EXTENSION("Actions.Types.FlowExtension.Enable", + "Actions.Types.FlowExtension.ActionRequest.ExcludedHeaders.Header", + "Actions.Types.FlowExtension.ActionRequest.ExcludedParameters.Parameter", + "Actions.Types.FlowExtension.ActionRequest.AllowedHeaders.Header", + "Actions.Types.FlowExtension.ActionRequest.AllowedParameters.Parameter", + "Actions.Types.FlowExtension.Version.RetiredUpTo"); private final String actionTypeEnableProperty; private final String excludedHeadersProperty; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java index edee2e5fd252..cd9615cdef92 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/model/Action.java @@ -77,11 +77,11 @@ public enum ActionTypes { "Configure an extension point for modifying ID token via a custom service.", Category.PRE_POST), FLOW_EXTENSION( - "inFlowExtension", + "flowExtension", "FLOW_EXTENSION", - "In-Flow Extension", + "Flow Extension", "Configure an extension point within any flow via a custom service.", - Category.IN_FLOW_EXTENSION); + Category.FLOW_EXTENSION); private final String pathParam; private final String actionType; @@ -138,7 +138,7 @@ public static ActionTypes[] filterByCategory(Category category) { public enum Category { PRE_POST, IN_FLOW, - IN_FLOW_EXTENSION + FLOW_EXTENSION } } diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java index 107cc941ca02..9fe16a09b95f 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/ActionManagementServiceImpl.java @@ -323,7 +323,7 @@ private void validateMaxActionsPerType(String actionType, String tenantDomain) t // In-flow and extension actions are not limited by the maximum actions per action type. Action.ActionTypes.Category category = Action.ActionTypes.valueOf(actionType).getCategory(); if (Action.ActionTypes.Category.IN_FLOW.equals(category) - || Action.ActionTypes.Category.IN_FLOW_EXTENSION.equals(category)) { + || Action.ActionTypes.Category.FLOW_EXTENSION.equals(category)) { return; } Map actionsCountPerType = getActionsCountPerType(tenantDomain); @@ -370,12 +370,12 @@ private ActionDTO buildActionDTOForCreation(String actionType, String actionId, throws ActionMgtServerException { Action.ActionTypes resolvedActionType = Action.ActionTypes.valueOf(actionType); - // Only IN_FLOW and IN_FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSION) + // Only IN_FLOW and FLOW_EXTENSION category actions (e.g., AUTHENTICATION, FLOW_EXTENSION) // start ACTIVE and can be used immediately. All other categories (e.g., PRE_POST) start // INACTIVE and require explicit activation. Action.ActionTypes.Category category = resolvedActionType.getCategory(); Action.Status resolvedStatus = (category == Action.ActionTypes.Category.IN_FLOW - || category == Action.ActionTypes.Category.IN_FLOW_EXTENSION) + || category == Action.ActionTypes.Category.FLOW_EXTENSION) ? Action.Status.ACTIVE : Action.Status.INACTIVE; String actionVersion = ActionManagementConfig.getInstance().getLatestVersion(resolvedActionType); diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java index 4a72a543c2e7..dc848771675c 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java @@ -146,8 +146,8 @@ public enum ActionTypeConfig { "Actions.Types.PreIssueIdToken.Version.Latest" ), FLOW_EXTENSION( - "Actions.Types.InFlowExtension.ActionRequest.ExcludedHeaders.Header", - "Actions.Types.InFlowExtension.Version.Latest" + "Actions.Types.FlowExtension.ActionRequest.ExcludedHeaders.Header", + "Actions.Types.FlowExtension.Version.Latest" ); private final String excludedHeadersProperty; diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java index c1ba35657fb6..153a15103dc4 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/test/java/org/wso2/carbon/identity/action/management/model/ActionTypesTest.java @@ -53,10 +53,10 @@ public Object[][] actionTypesProvider() { "Pre Issue ID Token", "Configure an extension point for modifying ID token via a custom service.", Action.ActionTypes.Category.PRE_POST}, - {Action.ActionTypes.FLOW_EXTENSION, "inFlowExtension", "FLOW_EXTENSION", - "In-Flow Extension", + {Action.ActionTypes.FLOW_EXTENSION, "flowExtension", "FLOW_EXTENSION", + "Flow Extension", "Configure an extension point within any flow via a custom service.", - Action.ActionTypes.Category.IN_FLOW_EXTENSION} + Action.ActionTypes.Category.FLOW_EXTENSION} }; } @@ -81,7 +81,7 @@ public Object[][] filterByCategoryProvider() { Action.ActionTypes.PRE_UPDATE_PASSWORD, Action.ActionTypes.PRE_UPDATE_PROFILE, Action.ActionTypes.PRE_REGISTRATION, Action.ActionTypes.PRE_ISSUE_ID_TOKEN}}, {Action.ActionTypes.Category.IN_FLOW, new Action.ActionTypes[]{Action.ActionTypes.AUTHENTICATION}}, - {Action.ActionTypes.Category.IN_FLOW_EXTENSION, + {Action.ActionTypes.Category.FLOW_EXTENSION, new Action.ActionTypes[]{Action.ActionTypes.FLOW_EXTENSION}} }; } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java index 3fe16c7f98eb..e13db42ff675 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/InFlowExtensionConstants.java @@ -47,7 +47,7 @@ private InFlowExtensionConstants() { // ---- Response info keys (FAILED path) ---- public static final String FAILURE_TYPE_KEY = "failureType"; - public static final String IN_FLOW_EXTENSION_FAILURE_TYPE = "IN_FLOW_EXTENSION_FAILURE"; + public static final String FLOW_EXTENSION_FAILURE_TYPE = "FLOW_EXTENSION_FAILURE"; public static final String FAILURE_MESSAGE_KEY = "failureMessage"; public static final String FAILURE_DESCRIPTION_KEY = "failureDescription"; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java index 0a76dc1b8fde..3ad468106bbe 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java @@ -235,7 +235,7 @@ private void applyRetryMetadata(ExecutorResponse response, String actionId) { additionalInfo = new HashMap<>(); } additionalInfo.put(InFlowExtensionConstants.FAILURE_TYPE_KEY, - InFlowExtensionConstants.IN_FLOW_EXTENSION_FAILURE_TYPE); + InFlowExtensionConstants.FLOW_EXTENSION_FAILURE_TYPE); response.setAdditionalInfo(additionalInfo); if (LOG.isDebugEnabled()) { diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java index 8349ebc88a1b..6027ff0a2785 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java @@ -217,7 +217,7 @@ public void testExecuteFailed() throws Exception { assertEquals(response.getErrorMessage(), "Risk score exceeds threshold"); // Verify failureType metadata is set for RETRY. assertNotNull(response.getAdditionalInfo()); - assertEquals(response.getAdditionalInfo().get("failureType"), "IN_FLOW_EXTENSION_FAILURE"); + assertEquals(response.getAdditionalInfo().get("failureType"), "FLOW_EXTENSION_FAILURE"); } @Test diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 index b7c868c33311..2d4f18dda5d4 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/identity.xml.j2 @@ -2518,12 +2518,12 @@ {{actions.types.authentication.default_userstore}} - - {{actions.types.in_flow_extension.enable}} + + {{actions.types.flow_extension.enable}} - {{actions.types.in_flow_extension.version.latest}} + {{actions.types.flow_extension.version.latest}} - + diff --git a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json index a54b1f536422..8d6b9a4b4186 100644 --- a/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json +++ b/features/identity-core/org.wso2.carbon.identity.core.server.feature/resources/org.wso2.carbon.identity.core.server.feature.default.json @@ -2272,8 +2272,8 @@ "actions.types.pre_update_profile.enable": true, "actions.types.pre_update_profile.version.latest": "v1", "actions.types.pre_update_password.version.latest": "v2", - "actions.types.in_flow_extension.enable": true, - "actions.types.in_flow_extension.version.latest": "v1", + "actions.types.flow_extension.enable": true, + "actions.types.flow_extension.version.latest": "v1", "external_api_client.http_client.connection_timeout": "2000", "external_api_client.http_client.read_timeout": "3000", From 7654677e3d60f74353bd8bf557c5ecb70a86e709 Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:30:01 +0530 Subject: [PATCH 11/17] Remove redundant blank line in ActionManagementService interface --- .../action/management/api/service/ActionManagementService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java index 237a900d8277..ac233ffdb0fc 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java @@ -128,5 +128,4 @@ Action updateAction(String actionType, String actionId, Action action, String te */ Action updateActionEndpointAuthentication(String actionType, String actionId, Authentication authentication, String tenantDomain) throws ActionMgtException; - } From df4857adb8e278942e40f1b38968c0477f1f2830 Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:35:31 +0530 Subject: [PATCH 12/17] Remove new line --- .../management/api/service/impl/DefaultActionValidator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java index fffa032b9bbb..49a323434621 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/impl/DefaultActionValidator.java @@ -249,7 +249,6 @@ private void validateAllowedParameters(List allowedParametersInAction) t } } - /** * Validate whether required fields exist. * From 4db002622de4711cd08a89264af417225bcf4d61 Mon Sep 17 00:00:00 2001 From: ThaminduR Date: Sun, 24 May 2026 22:38:40 +0530 Subject: [PATCH 13/17] Remove unnecessary constructor --- .../management/internal/util/ActionManagementConfig.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java index dc848771675c..6a3130994ee4 100644 --- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java +++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/util/ActionManagementConfig.java @@ -147,6 +147,7 @@ public enum ActionTypeConfig { ), FLOW_EXTENSION( "Actions.Types.FlowExtension.ActionRequest.ExcludedHeaders.Header", + null, "Actions.Types.FlowExtension.Version.Latest" ); @@ -161,11 +162,6 @@ public enum ActionTypeConfig { this.latestVersionProperty = latestVersionProperty; } - ActionTypeConfig(String excludedHeadersProperty, String latestVersionProperty) { - - this(excludedHeadersProperty, null, latestVersionProperty); - } - public String getExcludedHeadersProperty() { return excludedHeadersProperty; From f88134c948970b643eff17bdb26a87cf16afcc6d Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Mon, 25 May 2026 08:13:25 +0530 Subject: [PATCH 14/17] Address review comments --- .../flow/execution/engine/Constants.java | 2 +- .../engine/graph/TaskExecutionNode.java | 31 ------------------- .../engine/model/ExecutorResponse.java | 11 ------- .../flow/execution/engine/model/FlowUser.java | 19 +++--------- .../util/AuthenticationAssertionUtils.java | 2 +- .../AuthenticationAssertionUtilsTest.java | 4 +-- .../engine/util/FlowEngineUtilsTest.java | 4 +-- .../InFlowExtensionRequestBuilder.java | 2 +- .../InFlowExtensionRequestBuilderTest.java | 4 +-- .../InFlowExtensionResponseProcessorTest.java | 2 +- 10 files changed, 15 insertions(+), 66 deletions(-) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java index 3df959dff192..9286b6d524fb 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/Constants.java @@ -167,7 +167,7 @@ public enum ErrorMessages { "Error occurred during policy consent processing.", "Error occurred while processing policy consent for the %s request of flow id: %s."), ERROR_CODE_INFLOW_EXTENSION_ERROR("65034", - "In-Flow Extension error.", + "Error occurred while invoking the flow extension.", "%s"), // Client errors. diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java index ccad79f3eae4..c57ff63bd380 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/graph/TaskExecutionNode.java @@ -19,11 +19,8 @@ package org.wso2.carbon.identity.flow.execution.engine.graph; import org.apache.commons.collections.CollectionUtils; -import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.wso2.carbon.identity.central.log.mgt.utils.LoggerUtils; -import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException; import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder; import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse; @@ -31,7 +28,6 @@ import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser; import org.wso2.carbon.identity.flow.execution.engine.model.NodeResponse; import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; -import org.wso2.carbon.user.core.common.AbstractUserStoreManager; import static org.wso2.carbon.identity.flow.execution.engine.Constants.ErrorMessages.ERROR_CODE_EXECUTOR_FAILURE; import static org.wso2.carbon.identity.flow.execution.engine.Constants.ErrorMessages.ERROR_CODE_EXECUTOR_NOT_FOUND; @@ -208,43 +204,16 @@ private NodeResponse handleCompleteStatus(FlowExecutionContext context, Executor } FlowUser user = context.getFlowUser(); - if (response.getUserId() != null) { - user.setId(response.getUserId()); - } if (response.getUpdatedUserClaims() != null) { response.getUpdatedUserClaims().forEach((key, value) -> user.addClaim(key, String.valueOf(value))); } if (response.getUserCredentials() != null) { user.getUserCredentials().putAll(response.getUserCredentials()); } - if (user.getId() == null) { - resolveUserIdFromUserStore(user, context.getTenantDomain()); - } if (CollectionUtils.isNotEmpty(configs.getEdges())) { configs.setNextNodeId(configs.getEdges().get(0).getTargetNodeId()); } return new NodeResponse.Builder().status(STATUS_COMPLETE).build(); } - - private void resolveUserIdFromUserStore(FlowUser user, String tenantDomain) { - - String username = user.getUsername(); - if (StringUtils.isBlank(username)) { - return; - } - try { - int tenantId = IdentityTenantUtil.getTenantId(tenantDomain); - AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) - FlowExecutionEngineDataHolder.getInstance().getRealmService() - .getTenantUserRealm(tenantId).getUserStoreManager(); - String userId = userStoreManager.getUserIDFromUserName(username); - if (StringUtils.isNotBlank(userId)) { - user.setId(userId); - } - } catch (Exception e) { - LOG.debug("Failed to resolve userId for user '" + - LoggerUtils.getMaskedContent(username) + "' from user store.", e); - } - } } diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java index 5aa8f0591b81..d70ef7b39224 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/ExecutorResponse.java @@ -27,7 +27,6 @@ public class ExecutorResponse { private String result; - private String userId; private List requiredData; private List optionalData; private Map updatedUserClaims; @@ -56,16 +55,6 @@ public void setResult(String result) { this.result = result; } - public String getUserId() { - - return userId; - } - - public void setUserId(String userId) { - - this.userId = userId; - } - public List getRequiredData() { return requiredData; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java index 9fbc31d16e5a..b07022dca958 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/model/FlowUser.java @@ -81,7 +81,7 @@ public class FlowUser implements Serializable { @JsonProperty("username") private String username; - private String id; + private String userId; private String userStoreDomain; @JsonIgnore @@ -123,15 +123,6 @@ public void addClaims(Map claims) { this.claims.putAll(claims); } - - public void setClaims(Map claims) { - - this.claims.clear(); - if (claims != null) { - this.claims.putAll(claims); - } - } - public Object getClaim(String claimUri) { return this.claims.get(claimUri); @@ -153,14 +144,14 @@ public void setUserCredentials(Map credentials) { this.userCredentials.putAll(credentials); } - public String getId() { + public String getUserId() { - return id; + return userId; } - public void setId(String id) { + public void setUserId(String userId) { - this.id = id; + this.userId = userId; } public String getUserStoreDomain() { diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java index 3ab898425641..b59ae3e70500 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtils.java @@ -88,7 +88,7 @@ private static JWTClaimsSet buildUserAssertionClaimSet(FlowExecutionContext cont Date expirationTime = calculateUserAssertionExpiryTime(now); String serverURL = ServiceURLBuilder.create().build(IdentityUtil.getHostName()).getAbsolutePublicURL(); String username = context.getFlowUser().getUsername(); - String userId = context.getFlowUser().getId(); + String userId = context.getFlowUser().getUserId(); List amrValues = context.getCompletedNodes().stream() .map(node -> node.getExecutorConfig().getName()) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java index da425415136d..a8bc1c4082d3 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/AuthenticationAssertionUtilsTest.java @@ -102,7 +102,7 @@ public void setUp() { when(mockContext.getContextIdentifier()).thenReturn(TEST_CONTEXT_IDENTIFIER); when(mockContext.getFlowUser()).thenReturn(mockFlowUser); when(mockFlowUser.getUsername()).thenReturn(TEST_USERNAME); - when(mockFlowUser.getId()).thenReturn(TEST_USER_ID); + when(mockFlowUser.getUserId()).thenReturn(TEST_USER_ID); when(mockFlowUser.getUserStoreDomain()).thenReturn("PRIMARY"); } @@ -338,7 +338,7 @@ private void setupCommonMockBehaviors() { when(mockContext.getContextIdentifier()).thenReturn(TEST_CONTEXT_IDENTIFIER); when(mockContext.getFlowUser()).thenReturn(mockFlowUser); when(mockFlowUser.getUsername()).thenReturn(TEST_USERNAME); - when(mockFlowUser.getId()).thenReturn(TEST_USER_ID); + when(mockFlowUser.getUserId()).thenReturn(TEST_USER_ID); } private void setupBasicMocks(MockedStatic dataHolderMock, diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java index 1f2f31519d2b..75796d3f3754 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/util/FlowEngineUtilsTest.java @@ -322,7 +322,7 @@ public void testAssertionGenerationIntegration() throws Exception { FlowUser flowUser = new FlowUser(); flowUser.setUsername(testUsername); - flowUser.setId(testUserId); + flowUser.setUserId(testUserId); mockContext.setFlowUser(flowUser); @@ -354,7 +354,7 @@ public void testAssertionGenerationWithEmptyCompletedNodes() throws Exception { FlowUser flowUser = new FlowUser(); flowUser.setUsername("testuser"); - flowUser.setId("user123"); + flowUser.setUserId("user123"); mockContext.setFlowUser(flowUser); String expectedAssertion = "minimal.jwt.token"; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java index d337fc0d4afa..561021019c32 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilder.java @@ -574,7 +574,7 @@ private void applyFlowProperties(InFlowExtensionEvent.Builder eventBuilder, Flow private String resolveUserId(FlowUser flowUser, List expose) { if (isLeafExposed(InFlowExtensionConstants.USER_ID_PATH, expose)) { - String userId = flowUser.getId(); + String userId = flowUser.getUserId(); return userId != null ? userId : ""; } return null; diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java index c2d974026d2b..a8f4580e2816 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionRequestBuilderTest.java @@ -661,7 +661,7 @@ public void testExposedUserIdNullYieldsEmptyString() throws ActionExecutionRequestBuilderException { FlowExecutionContext execCtx = createFullFlowExecutionContext(); - execCtx.getFlowUser().setId(null); + execCtx.getFlowUser().setUserId(null); AccessConfig accessConfig = new AccessConfig(Arrays.asList( new ContextPath("/user/id", false)), null); @@ -1030,7 +1030,7 @@ private FlowExecutionContext createFullFlowExecutionContext() { context.setCurrentNode(node); FlowUser flowUser = new FlowUser(); - flowUser.setId("user-456"); + flowUser.setUserId("user-456"); flowUser.setUsername("testuser"); flowUser.setUserStoreDomain("PRIMARY"); flowUser.addClaim("http://wso2.org/claims/email", "test@example.com"); diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java index 8e49ed88a9f1..75748d5bb1e0 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionResponseProcessorTest.java @@ -843,7 +843,7 @@ private FlowExecutionContext createFlowExecutionContext() { context.setContextIdentifier("test-id"); FlowUser flowUser = new FlowUser(); - flowUser.setId("user-1"); + flowUser.setUserId("user-1"); flowUser.setUsername("testuser"); context.setFlowUser(flowUser); From b587758b862951315b7ebcd83521e8fbc808b386 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Mon, 25 May 2026 08:21:59 +0530 Subject: [PATCH 15/17] revert unrelated refactoring --- .../engine/core/FlowExecutionEngine.java | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java index 9b36cd0b707b..abe67392b055 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/core/FlowExecutionEngine.java @@ -35,7 +35,6 @@ import org.wso2.carbon.identity.flow.mgt.model.DataDTO; import org.wso2.carbon.identity.flow.mgt.model.GraphConfig; import org.wso2.carbon.identity.flow.mgt.model.NodeConfig; -import org.wso2.carbon.identity.flow.mgt.model.StepDTO; import java.util.Map; @@ -234,20 +233,19 @@ private NodeResponse triggerNode(NodeConfig nodeConfig, FlowExecutionContext con private FlowExecutionStep resolveStepForPrompt(GraphConfig graph, NodeConfig currentNode, FlowExecutionContext context, NodeResponse nodeResponse) throws FlowEngineServerException { - StepDTO stepDTO = graph.getNodePageMappings().get(currentNode.getId()); + DataDTO dataDTO = graph.getNodePageMappings().get(currentNode.getId()).getData(); - DataDTO.Builder dataDTOBuilder = new DataDTO.Builder() - .requiredParams(nodeResponse.getRequiredData()) - .optionalParams(nodeResponse.getOptionalData()) - .additionalData(nodeResponse.getAdditionalInfo()); - - if (stepDTO != null && stepDTO.getData() != null) { - dataDTOBuilder.components(stepDTO.getData().getComponents()); + DataDTO finalDataDTO = null; + if (dataDTO != null) { + finalDataDTO = new DataDTO.Builder() + .components(dataDTO.getComponents()) + .requiredParams(nodeResponse.getRequiredData()) + .optionalParams(nodeResponse.getOptionalData()) + .additionalData(nodeResponse.getAdditionalInfo()) + .build(); + handleError(finalDataDTO, nodeResponse); } - DataDTO finalDataDTO = dataDTOBuilder.build(); - handleError(finalDataDTO, nodeResponse); - // When the END node is reached, mark the flow status as COMPLETE, set the step type to REDIRECTION, // and assign the redirect URL. Note: all END nodes are expected to be of type PROMPT_ONLY. if (END_NODE_ID.equals(currentNode.getId())) { @@ -256,6 +254,9 @@ private FlowExecutionStep resolveStepForPrompt(GraphConfig graph, NodeConfig cur "end node. Changing the flow status to COMPLETE, step type to REDIRECTION and setting " + "the redirect URL."); } + if (finalDataDTO == null ) { + finalDataDTO = new DataDTO(); + } finalDataDTO.setRedirectURL(FlowExecutionEngineUtils.resolveCompletionRedirectionUrl(context)); return new FlowExecutionStep.Builder() .flowId(context.getContextIdentifier()) From 3c016037f75d8969ba638751d5a44ef6adc91e69 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Mon, 25 May 2026 09:17:41 +0530 Subject: [PATCH 16/17] resolve FlowExtensionExecutor issues --- .../{InFlowExtensionExecutor.java => FlowExtensionExecutor.java} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/{InFlowExtensionExecutor.java => FlowExtensionExecutor.java} (100%) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java similarity index 100% rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutor.java rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java From 7386607beb530fc9ec5281993683751e0b860c84 Mon Sep 17 00:00:00 2001 From: Kumuditha - KD Date: Mon, 25 May 2026 09:18:45 +0530 Subject: [PATCH 17/17] resolve FlowExtensionExecutor --- .../executor/FlowExtensionExecutor.java | 95 +++++++++---------- .../InFlowExtensionServiceComponent.java | 4 +- .../executor/InFlowExtensionExecutorTest.java | 10 +- 3 files changed, 51 insertions(+), 58 deletions(-) diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java index 3ad468106bbe..48c02cb6f1af 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java @@ -57,10 +57,10 @@ * On success, pending context updates (claims, credentials, properties) are forwarded * to the flow engine through the response object. */ -public class InFlowExtensionExecutor implements Executor { +public class FlowExtensionExecutor implements Executor { - private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class); - private static final String EXECUTOR_NAME = "InFlowExtensionExecutor"; + private static final Log LOG = LogFactory.getLog(FlowExtensionExecutor.class); + private static final String EXECUTOR_NAME = "FlowExtensionExecutor"; private static final String CONFIG_PARAM_ACTION_TYPE = "actionType"; private static final String CONFIG_PARAM_ACTION_ID = "actionId"; @@ -78,22 +78,20 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE String actionId = getMetadataValue(context, InFlowExtensionConstants.ACTION_ID_METADATA_KEY); if (actionId == null || actionId.isEmpty()) { triggerDiagnosticFailure(null, - "In-Flow Extension action execution failed: action ID is not configured."); + "Flow Extension action execution failed: action ID is not configured."); return buildErrorResponse("Extension is not configured.", - "The In-Flow Extension action is missing required configuration. " + + "The Flow Extension action is missing required configuration. " + "Contact your administrator."); } if (LOG.isDebugEnabled()) { - LOG.debug("Executing In-Flow Extension action. actionId: " + actionId + LOG.debug("Executing Flow Extension action. actionId: " + actionId + ", flowType: " + context.getFlowType() + ", tenant: " + context.getTenantDomain()); } ActionExecutorService actionExecutorService = getActionExecutorService(); if (actionExecutorService == null) { - triggerDiagnosticFailure(actionId, - "In-Flow Extension action execution failed: ActionExecutorService is unavailable."); throw FlowExecutionEngineUtils.handleServerException( Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR, "ActionExecutorService is not available. actionId: " + actionId); @@ -101,9 +99,9 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE if (!actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)) { triggerDiagnosticFailure(actionId, - "In-Flow Extension action execution failed: action type is disabled."); + "Flow Extension action execution failed: action type is disabled."); return buildErrorResponse("Extension execution is disabled.", - "The In-Flow Extension action type is currently disabled on this server."); + "The Flow Extension action type is currently disabled on this server."); } try { @@ -118,7 +116,7 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE ActionExecutionStatus executionStatus = actionExecutorService.execute( ActionType.FLOW_EXTENSION, actionId, flowContext, context.getTenantDomain()); - ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context); + ExecutorResponse executionResponse = mapExecutionStatus(executionStatus, flowContext, context, actionId); // On success, extract pending context updates collected by the response processor // and forward them to TaskExecutionNode via ExecutorResponse fields. @@ -126,15 +124,10 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE applyPendingContextUpdates(executionResponse, flowContext, actionId); } - if (ExecutorStatus.STATUS_RETRY.equals(executionResponse.getResult())) { - applyRetryMetadata(executionResponse, actionId); - } - return executionResponse; } catch (ActionExecutionException e) { logActionExecutionException(e, actionId); - triggerDiagnosticFailure(actionId, "In-Flow Extension action execution failed: " + e.getMessage()); return buildErrorResponse("An error occurred while processing the extension. Please try again.", "The external extension service could not complete the request. " + "If the problem persists, contact your administrator."); @@ -161,10 +154,11 @@ public ExecutorResponse rollback(FlowExecutionContext context) { * @param executionStatus The status returned by ActionExecutorService. * @param flowContext The action {@link FlowContext} where the response processor stashed the redirect URL. * @param context The engine {@link FlowExecutionContext} (used for OTFI collision-guard). + * @param actionId The action ID for logging retry metadata. * @return The ExecutorResponse for the flow execution engine. */ private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionStatus, - FlowContext flowContext, FlowExecutionContext context) { + FlowContext flowContext, FlowExecutionContext context, String actionId) { ExecutorResponse response = new ExecutorResponse(); @@ -172,7 +166,7 @@ private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionSt response.setResult(ExecutorStatus.STATUS_ERROR); response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); response.setErrorMessage("Extension did not return a response."); - response.setErrorDescription("The In-Flow Extension action did not return a status. Please try again."); + response.setErrorDescription("The Flow Extension action did not return a status. Please try again."); return response; } @@ -183,6 +177,7 @@ private ExecutorResponse mapExecutionStatus(ActionExecutionStatus executionSt case FAILED: handleFailedStatus(response, executionStatus); + applyRetryMetadata(response, actionId); return response; case ERROR: @@ -239,7 +234,7 @@ private void applyRetryMetadata(ExecutorResponse response, String actionId) { response.setAdditionalInfo(additionalInfo); if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension action returned FAILED. actionId: " + actionId + LOG.debug("Flow Extension action returned FAILED. actionId: " + actionId + ", reason: " + additionalInfo.get(InFlowExtensionConstants.FAILURE_MESSAGE_KEY)); } } @@ -261,7 +256,21 @@ private void handleFailedStatus(ExecutorResponse response, ActionExecutionStatus } response.setAdditionalInfo(failureInfo); response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_FAILURE.getCode()); - response.setErrorMessage(buildUserFacingErrorMessage(failure)); + + String reason = failure.getFailureReason(); + String description = failure.getFailureDescription(); + + if (reason != null && !reason.isEmpty()) { + response.setErrorMessage(reason); + } else { + response.setErrorMessage("The operation could not be completed."); + } + + if (description != null && !description.isEmpty()) { + response.setErrorDescription(description); + } else { + response.setErrorDescription(buildUserFacingErrorMessage(failure)); + } } private void handleErrorStatus(ExecutorResponse response, ActionExecutionStatus executionStatus) { @@ -273,8 +282,8 @@ private void handleErrorStatus(ExecutorResponse response, ActionExecutionStatus< return; } - response.setErrorMessage(stripI18nBraces(error.getErrorMessage())); - response.setErrorDescription(stripI18nBraces(error.getErrorDescription())); + response.setErrorMessage(error.getErrorMessage()); + response.setErrorDescription(error.getErrorDescription()); } private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse response, FlowContext flowContext, @@ -283,11 +292,11 @@ private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse respon String redirectUrl = flowContext.getValue(InFlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class); if (redirectUrl == null || redirectUrl.isEmpty()) { // Defensive: response processor should have rejected this earlier. - LOG.debug("In-Flow Extension returned INCOMPLETE without a redirect URL."); - triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, - "In-Flow Extension returned INCOMPLETE without a redirect URL."); + LOG.debug("Flow Extension returned INCOMPLETE without a redirect URL."); + triggerDiagnosticFailure(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "Flow Extension returned INCOMPLETE without a redirect URL."); return buildErrorResponse("Extension returned INCOMPLETE without a redirect URL.", - "The external extension returned an incomplete response. Please try again."); + "The external extension returned an incomplete response. Please try again."); } String otfi = generateUniqueOtfi(context.getContextIdentifier()); @@ -302,10 +311,10 @@ private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse respon response.setResult(ExecutorStatus.STATUS_EXTERNAL_REDIRECTION); triggerDiagnosticSuccess(InFlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, - "In-Flow Extension returned INCOMPLETE with a redirect URL."); + "Flow Extension returned INCOMPLETE with a redirect URL."); if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension returned INCOMPLETE. Redirect initiated and flowId (OTFI) generated."); + LOG.debug("Flow Extension returned INCOMPLETE. Redirect initiated and flowId (OTFI) generated."); } return response; @@ -318,7 +327,7 @@ private ExecutorResponse handleUnknownExecutionStatus(ExecutorResponse response, response.setResult(ExecutorStatus.STATUS_ERROR); response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR.getCode()); response.setErrorMessage("Extension returned an unexpected response."); - response.setErrorDescription("The In-Flow Extension returned an unrecognised status. Please try again."); + response.setErrorDescription("The Flow Extension returned an unrecognised status. Please try again."); return response; } @@ -360,7 +369,7 @@ private void applyPendingContextUpdates(ExecutorResponse response, FlowContext f } if (LOG.isDebugEnabled()) { - LOG.debug("In-Flow Extension action succeeded. actionId: " + actionId + LOG.debug("Flow Extension action succeeded. actionId: " + actionId + ", pendingClaims: " + (pendingClaims != null ? pendingClaims.size() : 0) + ", pendingCredentials: " + (pendingCredentials != null ? pendingCredentials.size() : 0) + ", pendingProperties: " + (pendingProperties != null ? pendingProperties.size() : 0)); @@ -419,36 +428,20 @@ private ActionExecutorService getActionExecutorService() { /** * Log an {@link ActionExecutionException} at the appropriate level based on its root cause. - * Config and contract violations are logged at WARN; infrastructure and unexpected failures at ERROR. + * Config, contract violations, and request builder failures are all treated as errors. */ private void logActionExecutionException(ActionExecutionException e, String actionId) { Throwable cause = e.getCause(); if (cause instanceof ActionExecutionRequestBuilderException) { - LOG.warn("In-Flow Extension action '" + actionId - + "' request build failed. Check action access configuration: " + e.getMessage()); + LOG.error("Flow Extension action '" + actionId + + "' request build failed. Check action access configuration: " + e.getMessage(), e); } else if (cause instanceof ActionExecutionResponseProcessorException) { - LOG.error("In-Flow Extension action '" + actionId + LOG.error("Flow Extension action '" + actionId + "' response processing failed (extension contract violation or internal error).", e); } else { - LOG.error("Error executing In-Flow Extension action '" + actionId + "'.", e); - } - } - - /** - * Strip the {@code {{...}}} wrapper from an i18n key so the JSP error page can resolve it - * via {@code AuthenticationEndpointUtil.i18n(resourceBundle, key)}. Raw text values (without - * the wrapper) and {@code null} are returned unchanged. - */ - private static String stripI18nBraces(String value) { - - if (value == null) { - return null; - } - if (value.startsWith("{{") && value.endsWith("}}") && value.length() > 4) { - return value.substring(2, value.length() - 2); + LOG.error("Error executing Flow Extension action '" + actionId + "'.", e); } - return value; } /** diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java index 51f08128df03..4107dd740131 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/InFlowExtensionServiceComponent.java @@ -37,7 +37,7 @@ import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService; import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService; import org.wso2.carbon.identity.flow.execution.engine.graph.Executor; -import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionExecutor; +import org.wso2.carbon.identity.flow.extension.executor.FlowExtensionExecutor; import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionRequestBuilder; import org.wso2.carbon.identity.flow.extension.executor.InFlowExtensionResponseProcessor; import org.wso2.carbon.identity.flow.extension.management.InFlowExtensionActionConverter; @@ -59,7 +59,7 @@ protected void activate(ComponentContext context) { try { BundleContext bundleContext = context.getBundleContext(); - bundleContext.registerService(Executor.class.getName(), new InFlowExtensionExecutor(), null); + bundleContext.registerService(Executor.class.getName(), new FlowExtensionExecutor(), null); bundleContext.registerService(ActionExecutionRequestBuilder.class.getName(), new InFlowExtensionRequestBuilder(), null); bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(), diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java index 6027ff0a2785..599c5a21e63a 100644 --- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/InFlowExtensionExecutorTest.java @@ -60,11 +60,11 @@ import static org.testng.Assert.assertTrue; /** - * Unit tests for {@link InFlowExtensionExecutor}. + * Unit tests for {@link FlowExtensionExecutor}. */ public class InFlowExtensionExecutorTest { - private InFlowExtensionExecutor executor; + private FlowExtensionExecutor executor; @Mock private ActionExecutorService actionExecutorService; @@ -77,7 +77,7 @@ public class InFlowExtensionExecutorTest { public void setUp() { mocks = MockitoAnnotations.openMocks(this); - executor = new InFlowExtensionExecutor(); + executor = new FlowExtensionExecutor(); // Stub InFlowExtensionDataHolder for action executor service. InFlowExtensionDataHolder holderInstance = mock(InFlowExtensionDataHolder.class); @@ -102,7 +102,7 @@ public void tearDown() throws Exception { @Test public void testGetName() { - assertEquals(executor.getName(), "InFlowExtensionExecutor"); + assertEquals(executor.getName(), "FlowExtensionExecutor"); } // ========================= getInitiationData ========================= @@ -605,7 +605,7 @@ private FlowExecutionContext createContextWithMetadata(Map metad FlowExecutionContext context = new FlowExecutionContext(); context.setTenantDomain("carbon.super"); - ExecutorDTO executorDTO = new ExecutorDTO("InFlowExtensionExecutor", metadata); + ExecutorDTO executorDTO = new ExecutorDTO("FlowExtensionExecutor", metadata); NodeConfig nodeConfig = new NodeConfig.Builder() .executorConfig(executorDTO) .build();