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 a7ff01fc0297..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 @@ -29,7 +29,8 @@ public enum ActionType { PRE_UPDATE_PASSWORD, PRE_UPDATE_PROFILE, AUTHENTICATION, - PRE_ISSUE_ID_TOKEN; + PRE_ISSUE_ID_TOKEN, + 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..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,9 +18,18 @@ 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; import java.util.List; +import java.util.Map; /** * This class models the User. @@ -30,9 +39,11 @@ 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; + 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 @@ -65,6 +76,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; @@ -72,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; @@ -84,6 +98,11 @@ public List getClaims() { return Collections.unmodifiableList(claims); } + public Map getUserCredentials() { + + return Collections.unmodifiableMap(userCredentials); + } + public List getGroups() { return Collections.unmodifiableList(groups); @@ -99,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; @@ -126,9 +152,11 @@ 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; + private UserStore userStoreDomain; private String sharedUserId; private String userType; private String federatedIdP; @@ -163,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; @@ -187,9 +221,24 @@ 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); } } + + 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..86b044057f49 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 @@ -154,6 +154,12 @@ public ActionExecutionStatus execute(ActionType actionType, String actionId, } 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.ActionTypes.Category category = Action.ActionTypes.valueOf(actionType.toString()).getCategory(); + if (Action.ActionTypes.Category.FLOW_EXTENSION.equals(category)) { + throw new ActionExecutionException( + "Failed to execute flow extension action with id: " + actionId + + " for action type: " + actionType.name(), e); + } return new SuccessStatus.Builder().setResponseContext(flowContext.getContextData()).build(); } } 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..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 @@ -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 FLOW_EXTENSION: + return isActionTypeEnabled(ActionTypeConfig.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 FLOW_EXTENSION: + return getVersion(ActionTypeConfig.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"), + 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/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..5bf7b0c8fdc8 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 @@ -45,6 +45,14 @@ public enum ErrorMessage { "The number of configured attributes: %s exceeds the maximum allowed limit: %s"), 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.", + "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 action type '%s'."), + ERROR_ACTION_NAME_BLANK("60016", "Invalid action name.", + "An action name must be a non-empty string."), // 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..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 @@ -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), + FLOW_EXTENSION( + "flowExtension", + "FLOW_EXTENSION", + "Flow Extension", + "Configure an extension point within any flow via a custom service.", + Category.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, + FLOW_EXTENSION } } 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..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,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 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/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..765536c1c9e3 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; @@ -76,6 +77,7 @@ public Action addAction(String actionType, Action action, String tenantDomain) t Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType); ActionValidatorFactory.getActionValidator(castedActionType).doPreAddActionValidations( 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(); @@ -161,6 +163,7 @@ 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); + validateActionNameUniqueness(action.getName(), actionId, castedActionType, tenantId); ActionDTO updatingActionDTO = buildActionDTOForUpdate(resolvedActionType, actionId, action); DAO_FACADE.updateAction(updatingActionDTO, existingActionDTO, tenantId); @@ -317,8 +320,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.FLOW_EXTENSION.equals(category)) { return; } Map actionsCountPerType = getActionsCountPerType(tenantDomain); @@ -365,8 +370,13 @@ 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; + // 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.FLOW_EXTENSION) + ? Action.Status.ACTIVE : Action.Status.INACTIVE; String actionVersion = ActionManagementConfig.getInstance().getLatestVersion(resolvedActionType); @@ -470,4 +480,25 @@ 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 (!ActionTypes.FLOW_EXTENSION.equals(actionType)) { + return; + } + if (StringUtils.isBlank(name)) { + throw ActionManagementExceptionHandler.handleClientException( + ErrorMessage.ERROR_ACTION_NAME_BLANK); + } + 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, actionType.getActionType()); + } + } } 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..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 @@ -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 FLOW_EXTENSION: + return getVersion( + ActionTypeConfig.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" + ), + FLOW_EXTENSION( + "Actions.Types.FlowExtension.ActionRequest.ExcludedHeaders.Header", + null, + "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 250805692876..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 @@ -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.FLOW_EXTENSION, "flowExtension", "FLOW_EXTENSION", + "Flow Extension", + "Configure an extension point within any flow via a custom service.", + Action.ActionTypes.Category.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.FLOW_EXTENSION, + new Action.ActionTypes[]{Action.ActionTypes.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..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 @@ -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,7 +165,16 @@ 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, @@ -185,6 +194,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..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 @@ -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", + "Error occurred while invoking the flow extension.", + "%s"), // Client errors. ERROR_CODE_INVALID_FLOW_ID("60001", @@ -215,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 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/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.extension/pom.xml b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/pom.xml new file mode 100644 index 000000000000..623e65982697 --- /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 Flow Extension + WSO2 flow engine flow extension + 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/extension/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/FlowExtensionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/FlowExtensionConstants.java new file mode 100644 index 000000000000..52c3fdc58041 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/FlowExtensionConstants.java @@ -0,0 +1,153 @@ +/* + * 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. + */ +public class FlowExtensionConstants { + + private FlowExtensionConstants() { + + } + + 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"; + + public static final String FAILURE_TYPE_KEY = "failureType"; + 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"; + + public static final String ACTION_ID_METADATA_KEY = "actionId"; + + 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() { + + } + } + + public static final class Log { + + public static final String COMPONENT_ID = "inflow-extension"; + + private Log() { + + } + + 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() { + + } + } + } + + /** + * Default handover policy: which {@code FlowExecutionContext} and {@code FlowUser} fields + * are forwarded to the action framework. Serves as the documented defaults for the + * toml-based dynamic config in {@code identity.xml.j2}. + */ + public static final class HandoverPolicy { + + public static final String ATTR_FLOW_USER = "flowUser"; + public static final String ATTR_CONTEXT_IDENTIFIER = "contextIdentifier"; + public static final String ATTR_USER_CREDENTIALS = "userCredentials"; + public static final Set INCLUDED_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "contextIdentifier", + "tenantDomain", + "applicationId", + "flowType", + "callbackUrl", + "portalUrl", + "flowUser" + ))); + + public static final Set INCLUDED_USER_ATTRIBUTES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "id", + "username", + "userStoreDomain", + "claims", + "userCredentials" + ))); + + private HandoverPolicy() { + + } + } + + /** + * JSON-pointer-style path constants for the In-Flow Extension context tree. + */ + public static final class FlowContextPaths { + + 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"; + + private FlowContextPaths() { + + } + } +} 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 new file mode 100644 index 000000000000..8381c53f27b0 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutor.java @@ -0,0 +1,470 @@ +/* + * 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.FlowExtensionConstants; +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.FlowExtensionDataHolder; +import org.wso2.carbon.identity.flow.extension.model.FlowContextHandoverConfig; +import org.wso2.carbon.identity.flow.extension.util.FlowExtensionContextFilterUtil; +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 FlowExtensionExecutor implements Executor { + + 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"; + + + + @Override + public String getName() { + + return EXECUTOR_NAME; + } + + @Override + public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineException { + + String actionId = getMetadataValue(context, FlowExtensionConstants.ACTION_ID_METADATA_KEY); + if (actionId == null || actionId.isEmpty()) { + triggerDiagnosticFailure(null, + "Flow Extension action execution failed: action ID is not configured."); + return buildErrorResponse("Extension is not configured.", + "The Flow Extension action is missing required configuration. " + + "Contact your administrator."); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Executing Flow Extension action. actionId: " + actionId + + ", flowType: " + context.getFlowType() + + ", tenant: " + context.getTenantDomain()); + } + + ActionExecutorService actionExecutorService = getActionExecutorService(); + if (actionExecutorService == null) { + throw FlowExecutionEngineUtils.handleServerException( + Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_ERROR, + "ActionExecutorService is not available. actionId: " + actionId); + } + + if (!actionExecutorService.isExecutionEnabled(ActionType.FLOW_EXTENSION)) { + triggerDiagnosticFailure(actionId, + "Flow Extension action execution failed: action type is disabled."); + return buildErrorResponse("Extension execution is disabled.", + "The 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 = FlowExtensionContextFilterUtil.filter( + context, FlowContextHandoverConfig.defaultPolicy()); + + FlowContext flowContext = FlowContext.create() + .add(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, filteredContext); + + ActionExecutionStatus executionStatus = actionExecutorService.execute( + ActionType.FLOW_EXTENSION, actionId, flowContext, context.getTenantDomain()); + + 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. + if (ExecutorStatus.STATUS_COMPLETE.equals(executionResponse.getResult())) { + applyPendingContextUpdates(executionResponse, flowContext, actionId); + } + + return executionResponse; + + } catch (ActionExecutionException e) { + logActionExecutionException(e, actionId); + 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). + * @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, String actionId) { + + 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 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); + applyRetryMetadata(response, actionId); + 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(FlowExtensionConstants.FAILURE_TYPE_KEY, + FlowExtensionConstants.FLOW_EXTENSION_FAILURE_TYPE); + response.setAdditionalInfo(additionalInfo); + + if (LOG.isDebugEnabled()) { + LOG.debug("Flow Extension action returned FAILED. actionId: " + actionId + + ", reason: " + additionalInfo.get(FlowExtensionConstants.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(FlowExtensionConstants.FAILURE_MESSAGE_KEY, failure.getFailureReason()); + } + if (failure.getFailureDescription() != null) { + failureInfo.put(FlowExtensionConstants.FAILURE_DESCRIPTION_KEY, failure.getFailureDescription()); + } + response.setAdditionalInfo(failureInfo); + response.setErrorCode(Constants.ErrorMessages.ERROR_CODE_INFLOW_EXTENSION_FAILURE.getCode()); + + 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) { + + 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(error.getErrorMessage()); + response.setErrorDescription(error.getErrorDescription()); + } + + private ExecutorResponse handleIncompleteExecutionStatus(ExecutorResponse response, FlowContext flowContext, + FlowExecutionContext context) { + + String redirectUrl = flowContext.getValue(FlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class); + if (redirectUrl == null || redirectUrl.isEmpty()) { + // Defensive: response processor should have rejected this earlier. + LOG.debug("Flow Extension returned INCOMPLETE without a redirect URL."); + triggerDiagnosticFailure(FlowExtensionConstants.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."); + } + + 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(FlowExtensionConstants.Log.ActionIDs.PROCESS_RESPONSE, null, + "Flow Extension returned INCOMPLETE with a redirect URL."); + + if (LOG.isDebugEnabled()) { + LOG.debug("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 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(FlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class); + if (pendingClaims != null && !pendingClaims.isEmpty()) { + response.setUpdatedUserClaims(pendingClaims); + } + + Map pendingCredentials = + flowContext.getValue(FlowExtensionConstants.PENDING_CREDENTIALS_KEY, Map.class); + if (pendingCredentials != null && !pendingCredentials.isEmpty()) { + response.setUserCredentials(pendingCredentials); + } + + Map pendingProperties = + flowContext.getValue(FlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class); + if (pendingProperties != null && !pendingProperties.isEmpty()) { + response.setContextProperty(pendingProperties); + } + + if (LOG.isDebugEnabled()) { + 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)); + } + } + + private void triggerDiagnosticFailure(String actionId, String resultMessage) { + + triggerDiagnosticFailure(FlowExtensionConstants.Log.ActionIDs.EXECUTE, actionId, resultMessage); + } + + private void triggerDiagnosticFailure(String diagnosticActionId, String actionId, String resultMessage) { + + if (!LoggerUtils.isDiagnosticLogsEnabled()) { + return; + } + + DiagnosticLog.DiagnosticLogBuilder builder = new DiagnosticLog.DiagnosticLogBuilder( + FlowExtensionConstants.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( + FlowExtensionConstants.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 FlowExtensionDataHolder.getInstance().getActionExecutorService(); + } + + /** + * Log an {@link ActionExecutionException} at the appropriate level based on its root cause. + * 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.error("Flow Extension action '" + actionId + + "' request build failed. Check action access configuration: " + e.getMessage(), e); + } else if (cause instanceof ActionExecutionResponseProcessorException) { + LOG.error("Flow Extension action '" + actionId + + "' response processing failed (extension contract violation or internal error).", e); + } else { + LOG.error("Error executing Flow Extension action '" + actionId + "'.", e); + } + } + + /** + * 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/FlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionRequestBuilder.java new file mode 100644 index 000000000000..ac1a107c6fb0 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionRequestBuilder.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.FlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.util.FlowExtensionPathUtil; +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 FlowExtensionEvent} model. + * Modify paths from the access config are converted to a single REPLACE {@link AllowedOperation}.

+ */ +public class FlowExtensionRequestBuilder implements ActionExecutionRequestBuilder { + + private static final Log LOG = LogFactory.getLog(FlowExtensionRequestBuilder.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(FlowExtensionConstants.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()); + + FlowExtensionEvent 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 FlowExtensionConstants#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 FlowExtensionEvent} 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 FlowExtensionEvent. + */ + private FlowExtensionEvent buildEvent(FlowExecutionContext context, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + FlowExtensionEvent.Builder eventBuilder = new FlowExtensionEvent.Builder(); + FlowExtensionFlow.Builder flowBuilder = new FlowExtensionFlow.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(FlowExtensionConstants.FlowContextPaths.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( + FlowExtensionConstants.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 FlowExtensionAction) { + FlowExtensionAction ext = (FlowExtensionAction) rawAction; + return new ResolvedActionConfig(ext.resolveAccessConfig(flowType), ext.getEncryption(), false); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("No FlowExtensionAction 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(FlowExtensionConstants.MODIFY_PATHS_KEY, Collections.emptyList()); + List allowedOperations = buildAllowedOperations(null, flowContext); + triggerFallbackDiagnostic(execCtx); + + FlowExtensionEvent event = new FlowExtensionEvent.Builder() + .flow(new FlowExtensionFlow.Builder() + .flowId(execCtx.getContextIdentifier()) + .build()) + .build(); + return buildRequestPayload(event, allowedOperations); + } + + private ActionExecutionRequest buildRequestPayload(FlowExtensionEvent 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 FlowExtensionAction 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(FlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + } + + private void addRedirectOperation(List allowedOperations) { + + AllowedOperation redirectOp = new AllowedOperation(); + redirectOp.setOp(Operation.REDIRECT); + allowedOperations.add(redirectOp); + } + + private void applyTenant(FlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(FlowExtensionConstants.FlowContextPaths.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(FlowExtensionEvent.Builder eventBuilder, List expose) { + + if (!isAreaExposed(FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.ORGANIZATION_ID_PATH, expose)) { + orgBuilder.id(coreOrg.getId()); + } + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.ORGANIZATION_NAME_PATH, expose)) { + orgBuilder.name(coreOrg.getName()); + } + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.ORGANIZATION_HANDLE_PATH, expose)) { + orgBuilder.orgHandle(coreOrg.getOrganizationHandle()); + } + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.ORGANIZATION_DEPTH_PATH, expose)) { + orgBuilder.depth(coreOrg.getDepth()); + } + + eventBuilder.organization(orgBuilder.build()); + } + + private void applyApplication(FlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose) { + + if (!isLeafExposed(FlowExtensionConstants.FlowContextPaths.FLOW_APP_ID_PATH, expose)) { + return; + } + + String appId = context.getApplicationId(); + eventBuilder.application(new Application(appId != null ? appId : "", null)); + } + + private void applyUserAndUserStore(FlowExtensionFlow.Builder flowBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(FlowExtensionConstants.FlowContextPaths.USER_PREFIX, expose)) { + return; + } + + FlowUser flowUser = context.getFlowUser(); + if (flowUser == null) { + return; + } + + flowBuilder.user(buildUser(flowUser, expose, accessConfig, certificatePEM)); + } + + private void applyFlowMetadata(FlowExtensionFlow.Builder flowBuilder, + FlowExtensionEvent.Builder eventBuilder, + FlowExecutionContext context, List expose) { + + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.FLOW_TYPE_PATH, expose)) { + flowBuilder.flowType(context.getFlowType() != null ? context.getFlowType() : ""); + } + + flowBuilder.flowId(context.getContextIdentifier()); + + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.FLOW_CALLBACK_URL_PATH, expose)) { + eventBuilder.callbackUrl(context.getCallbackUrl() != null ? context.getCallbackUrl() : ""); + } + + if (isLeafExposed(FlowExtensionConstants.FlowContextPaths.FLOW_PORTAL_URL_PATH, expose)) { + eventBuilder.portalUrl(context.getPortalUrl() != null ? context.getPortalUrl() : ""); + } + } + + private void applyFlowProperties(FlowExtensionEvent.Builder eventBuilder, FlowExecutionContext context, + List expose, AccessConfig accessConfig, + String certificatePEM) throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(FlowExtensionConstants.FlowContextPaths.PROPERTIES_PATH_PREFIX, expose)) { + return; + } + + Map properties = context.getProperties(); + Map filteredProperties = new HashMap<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(FlowExtensionConstants.FlowContextPaths.PROPERTIES_PATH_PREFIX)) { + continue; + } + String propKey = exposePath.substring(FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.USER_ID_PATH, expose)) { + String userId = flowUser.getUserId(); + return userId != null ? userId : ""; + } + return null; + } + + private List buildFilteredClaims(FlowUser flowUser, List expose, + AccessConfig accessConfig, String certificatePEM) + throws ActionExecutionRequestBuilderException { + + if (!isAreaExposed(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_PATH_PREFIX, expose)) { + return Collections.emptyList(); + } + + Map claims = flowUser.getClaims(); + List userClaims = new ArrayList<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_PATH_PREFIX)) { + continue; + } + String claimKey = exposePath.substring(FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.USER_CREDENTIALS_PATH_PREFIX, expose)) { + return Collections.emptyMap(); + } + + Map credentials = flowUser.getUserCredentials(); + Map filteredCredentials = new HashMap<>(); + + for (String exposePath : expose) { + if (!exposePath.startsWith(FlowExtensionConstants.FlowContextPaths.USER_CREDENTIALS_PATH_PREFIX)) { + continue; + } + String credKey = exposePath.substring(FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_PATH_PREFIX)) { + String claimUri = internalPath.substring( + FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_PATH_PREFIX.length()); + return FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX + claimUri + + FlowExtensionConstants.FlowContextPaths.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 FlowExtensionPathUtil.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 FlowExtensionPathUtil.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/FlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionResponseProcessor.java new file mode 100644 index 000000000000..74c923d6bc3b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionResponseProcessor.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.FlowExtensionDataHolder; +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.FlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.model.AccessConfig; +import org.wso2.carbon.identity.flow.extension.util.FlowExtensionPathUtil; +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 FlowExtensionResponseProcessor implements ActionExecutionResponseProcessor { + + private static final Log LOG = LogFactory.getLog(FlowExtensionResponseProcessor.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( + FlowExtensionConstants.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( + FlowExtensionConstants.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( + FlowExtensionConstants.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(FlowExtensionConstants.PENDING_CLAIMS_KEY, pendingClaims); + } + if (!pendingCredentials.isEmpty()) { + flowContext.add(FlowExtensionConstants.PENDING_CREDENTIALS_KEY, pendingCredentials); + } + if (!pendingProperties.isEmpty()) { + flowContext.add(FlowExtensionConstants.PENDING_PROPERTIES_KEY, pendingProperties); + } + + logOperationExecutionResults(results); + + return new SuccessStatus.Builder() + .setSuccess(new FlowExtensionSuccess()) + .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 (FlowExtensionPathUtil.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(FlowExtensionConstants.FlowContextPaths.PROPERTIES_PATH_PREFIX)) { + return handlePropertyOperation(operation, pathTypeAnnotations, pendingProperties); + } else if (path.startsWith(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX)) { + return handleUserClaimOperation(operation, pendingClaims, tenantDomain); + } else if (path.startsWith(FlowExtensionConstants.FlowContextPaths.USER_CREDENTIALS_PATH_PREFIX)) { + return handleUserCredentialOperation(operation, pendingCredentials); + } + + return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE, + "Unknown path prefix. Supported: " + FlowExtensionConstants.FlowContextPaths.PROPERTIES_PATH_PREFIX + + ", " + FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX + "" + + FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_SUFFIX + + ", " + FlowExtensionConstants.FlowContextPaths.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(), + FlowExtensionConstants.FlowContextPaths.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 = + FlowExtensionDataHolder.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(), + FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX) + && path.endsWith(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_SUFFIX)) { + return path.substring( + FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX.length(), + path.length() - FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX) + && externalPath.endsWith(FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_SUFFIX)) { + String claimUri = externalPath.substring( + FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_PREFIX.length(), + externalPath.length() - FlowExtensionConstants.FlowContextPaths.USER_CLAIMS_SELECTOR_SUFFIX.length()); + return FlowExtensionConstants.FlowContextPaths.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(FlowExtensionConstants.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( + FlowExtensionConstants.Log.COMPONENT_ID, + FlowExtensionConstants.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( + FlowExtensionConstants.Log.COMPONENT_ID, + FlowExtensionConstants.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 FlowExtensionSuccess 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( + FlowExtensionConstants.Log.COMPONENT_ID, + FlowExtensionConstants.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( + FlowExtensionConstants.Log.COMPONENT_ID, + FlowExtensionConstants.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..57d935e4477a --- /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,284 @@ +/* + * 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 for parsing and coercing path type annotations. + */ +public final class PathTypeAnnotationUtil { + + static final Pattern ANNOTATION_PATTERN = Pattern.compile("\\{([^}]*)}$"); + static final String LOCAL_CLAIM_DIALECT_PREFIX = "http://wso2.org/claims/"; + static final String IDENTITY_CLAIM_URI_PREFIX = "http://wso2.org/claims/identity/"; + static final int MAX_ATTRIBUTES_PER_OBJECT = 10; + static final int MAX_ARRAY_ITEMS = 10; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private PathTypeAnnotationUtil() { + } + + /** + * Strip a trailing annotation from a raw path. + * + * @return {@code [cleanPath, annotation]}; annotation is {@code null} if absent. + */ + public static String[] stripAnnotation(String rawPath) { + + if (rawPath == null) { + return new String[]{null, null}; + } + Matcher matcher = ANNOTATION_PATTERN.matcher(rawPath); + if (matcher.find()) { + return new String[]{rawPath.substring(0, matcher.start()), matcher.group(1)}; + } + return new String[]{rawPath, null}; + } + + /** + * Coerce a value based on its path annotation: complex returned as-is, + * primary arrays become {@code List}, others become String. + */ + @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) { + return String.valueOf(value); + } + if (annotation.startsWith("[")) { + String inner = annotation.substring(1, annotation.length() - 1); + if (inner.contains(":")) { + return tryParseJsonString(value); + } + Object resolved = tryParseJsonString(value); + if (resolved instanceof List) { + List stringList = new ArrayList<>(); + for (Object item : (List) resolved) { + stringList.add(item == null ? null : String.valueOf(item)); + } + return stringList; + } + List singleList = new ArrayList<>(); + singleList.add(String.valueOf(value)); + return singleList; + } + if (annotation.contains(":")) { + return tryParseJsonString(value); + } + return String.valueOf(value); + } + + /** + * Validate a complex annotation does not exceed {@link #MAX_ATTRIBUTES_PER_OBJECT}. + * Returns {@code true} for non-complex or empty annotations. + */ + public static boolean validateAnnotationLimits(String annotation) { + + if (annotation == null || annotation.isEmpty()) { + return true; + } + String inner = annotation; + if (inner.startsWith("[") && inner.endsWith("]")) { + inner = inner.substring(1, inner.length() - 1); + } + if (!inner.contains(":")) { + return true; + } + return parseAnnotationAttributes(inner).size() <= MAX_ATTRIBUTES_PER_OBJECT; + } + + /** + * Validate a complex object/array value against its schema annotation. + * Enforces {@link #MAX_ATTRIBUTES_PER_OBJECT} and {@link #MAX_ARRAY_ITEMS}. + * Returns {@code true} for non-complex annotations. + */ + @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; + if (!inner.contains(":")) { + return true; + } + Map schema = parseAnnotationAttributes(inner); + Object resolved = tryParseJsonString(value); + if (!isArray) { + return validateSingleComplexObject(resolved, schema); + } + if (!(resolved instanceof List)) { + return false; + } + List items = (List) resolved; + if (items.size() > MAX_ARRAY_ITEMS) { + return false; + } + for (Object item : items) { + if (!validateSingleComplexObject(item, schema)) { + return false; + } + } + return true; + } + + /** + * Enforce {@link #MAX_ARRAY_ITEMS} on array-typed values. + */ + @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; + } + + /** + * Parse a String as JSON if it starts with {@code [} or {@code {}; otherwise return as-is. + * Handles serialized JSON arriving from external services (e.g. after JWE decryption). + */ + 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 name-to-type map. + */ + private static Map parseAnnotationAttributes(String inner) { + + Map attributes = new HashMap<>(); + for (String part : inner.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + int colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + attributes.put(trimmed.substring(0, colonIndex).trim(), + trimmed.substring(colonIndex + 1).trim()); + } + } + return attributes; + } + + /** + * Validate a single complex object: must be a Map with schema-only keys, + * attribute count within limits, and only single-level nesting. + */ + @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/FlowExtensionDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/FlowExtensionDataHolder.java new file mode 100644 index 000000000000..34790e19b915 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/FlowExtensionDataHolder.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 FlowExtensionDataHolder { + + private static final FlowExtensionDataHolder instance = new FlowExtensionDataHolder(); + + private ActionExecutorService actionExecutorService; + private ActionManagementService actionManagementService; + private CertificateManagementService certificateManagementService; + private ClaimMetadataManagementService claimMetadataManagementService; + + private FlowExtensionDataHolder() { + + } + + public static FlowExtensionDataHolder 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/FlowExtensionServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/FlowExtensionServiceComponent.java new file mode 100644 index 000000000000..ff1737044f1a --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/internal/FlowExtensionServiceComponent.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.FlowExtensionExecutor; +import org.wso2.carbon.identity.flow.extension.executor.FlowExtensionRequestBuilder; +import org.wso2.carbon.identity.flow.extension.executor.FlowExtensionResponseProcessor; +import org.wso2.carbon.identity.flow.extension.management.FlowExtensionActionConverter; +import org.wso2.carbon.identity.flow.extension.management.FlowExtensionActionDTOModelResolver; + +/** + * OSGi declarative services component which registers the In-Flow Extension services. + */ +@Component( + name = "flow.extension.component", + immediate = true) +public class FlowExtensionServiceComponent { + + private static final Log LOG = LogFactory.getLog(FlowExtensionServiceComponent.class); + + @Activate + protected void activate(ComponentContext context) { + + try { + BundleContext bundleContext = context.getBundleContext(); + + bundleContext.registerService(Executor.class.getName(), new FlowExtensionExecutor(), null); + bundleContext.registerService(ActionExecutionRequestBuilder.class.getName(), + new FlowExtensionRequestBuilder(), null); + bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(), + new FlowExtensionResponseProcessor(), null); + + bundleContext.registerService(ActionConverter.class.getName(), + new FlowExtensionActionConverter(), null); + bundleContext.registerService(ActionDTOModelResolver.class.getName(), + new FlowExtensionActionDTOModelResolver( + FlowExtensionDataHolder.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."); + FlowExtensionDataHolder.getInstance().setActionManagementService(actionManagementService); + } + + protected void unsetActionManagementService(ActionManagementService actionManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionManagementService in the In-Flow Extension component. Service: " + + actionManagementService); + } + FlowExtensionDataHolder.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."); + FlowExtensionDataHolder.getInstance().setActionExecutorService(actionExecutorService); + } + + protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ActionExecutorService in the In-Flow Extension component. Service: " + + actionExecutorService); + } + FlowExtensionDataHolder.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."); + FlowExtensionDataHolder.getInstance() + .setCertificateManagementService(certificateManagementService); + } + + protected void unsetCertificateManagementService( + CertificateManagementService certificateManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the CertificateManagementService in the In-Flow Extension component. Service: " + + certificateManagementService); + } + FlowExtensionDataHolder.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."); + FlowExtensionDataHolder.getInstance() + .setClaimMetadataManagementService(claimMetadataManagementService); + } + + protected void unsetClaimMetadataManagementService( + ClaimMetadataManagementService claimMetadataManagementService) { + + if (LOG.isDebugEnabled()) { + LOG.debug("Unsetting the ClaimMetadataManagementService in the In-Flow Extension component. Service: " + + claimMetadataManagementService); + } + FlowExtensionDataHolder.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/FlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/FlowExtensionActionConverter.java new file mode 100644 index 000000000000..464e53e8eba2 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/FlowExtensionActionConverter.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.ContextPath; +import org.wso2.carbon.identity.flow.extension.model.Encryption; +import org.wso2.carbon.identity.flow.extension.model.FlowExtensionAction; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ICON_URL; + +/** + * ActionConverter implementation for Flow Extension actions. + *

+ * Handles the conversion between {@link FlowExtensionAction} (domain model) and + * {@link ActionDTO} (data transfer object) by mapping the {@link AccessConfig} fields, + * encryption configuration, and per-flow-type overrides to/from action properties. + *

+ */ +public class FlowExtensionActionConverter implements ActionConverter { + + @Override + public Action.ActionTypes getSupportedActionType() { + + return Action.ActionTypes.FLOW_EXTENSION; + } + + /** + * Converts a {@link FlowExtensionAction} 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 FlowExtensionAction to convert. + * @return ActionDTO with access config properties. + */ + @Override + public ActionDTO buildActionDTO(Action action) { + + if (!(action instanceof FlowExtensionAction flowExtensionAction)) { + return new ActionDTO.Builder(action).build(); + } + + Map properties = new HashMap<>(); + putDefaultAccessConfigProperties(properties, flowExtensionAction.getAccessConfig()); + putEncryptionProperty(properties, flowExtensionAction.getEncryption()); + if (flowExtensionAction.getIconUrl() != null) { + properties.put(ICON_URL, + new ActionProperty.BuilderForService(flowExtensionAction.getIconUrl()).build()); + } + putFlowTypeOverrideProperties(properties, flowExtensionAction.getFlowTypeOverrides()); + + return new ActionDTO.Builder(flowExtensionAction) + .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 a {@link FlowExtensionAction}. + * Reconstructs the default {@link AccessConfig} and per-flow-type overrides from the DTO's properties map. + * + * @param actionDTO The ActionDTO to convert. + * @return FlowExtensionAction 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 FlowExtensionAction.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/FlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/FlowExtensionActionDTOModelResolver.java new file mode 100644 index 000000000000..91543318b9ab --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/management/FlowExtensionActionDTOModelResolver.java @@ -0,0 +1,520 @@ +/* + * 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.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.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_EXPOSE_PREFIX; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ACCESS_CONFIG_MODIFY_PREFIX; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.CERTIFICATE; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.CERTIFICATE_NAME_PREFIX; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.ICON_URL; +import static org.wso2.carbon.identity.flow.extension.FlowExtensionConstants.ActionManagement.MAX_EXPOSE_PATHS; + +/** + * ActionDTOModelResolver implementation for Flow Extension actions. + */ +public class FlowExtensionActionDTOModelResolver implements ActionDTOModelResolver { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> CONTEXT_PATH_LIST_TYPE_REF = + new TypeReference>() { }; + + private final CertificateManagementService certificateManagementService; + + public FlowExtensionActionDTOModelResolver(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); + if (exposeValue != null) { + List validatedExpose = validateExpose(exposeValue); + properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose)); + } + + Object modifyValue = actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY); + if (modifyValue != null) { + List validatedModify = validateExpose(modifyValue); + properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(validatedModify)); + } + + handleCertificateAdd(actionDTO, properties, tenantDomain); + + Object iconUrlValue = actionDTO.getPropertyValue(ICON_URL); + if (iconUrlValue instanceof String iconUrlStr && !iconUrlStr.isEmpty()) { + properties.put(ICON_URL, new ActionProperty.BuilderForDAO(iconUrlStr).build()); + } + + 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<>(); + + 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())); + } + + handleCertificateGet(actionDTO, properties, tenantDomain); + + 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; + } + + @Override + public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDTO existingActionDTO, + String tenantDomain) throws ActionDTOModelResolverException { + + Map properties = new HashMap<>(); + + // DAO layer treats action property updates as PUT, so unchanged properties must be re-sent. + 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)); + } + + 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 { + + handleCertificateDelete(deletingActionDTO, tenantDomain); + } + + @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(); + } + + @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)); + } + } + } + + 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); + + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(certificateId).build()); + } catch (CertificateMgtException e) { + throw new ActionDTOModelResolverException("Error storing certificate for action: " + + actionDTO.getId(), e); + } + } + + 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); + } + } + + 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) { + 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) { + String certificatePEM = extractCertificatePEM(newCertValue); + try { + String existingCertId = extractCertificateId(existingCertValue); + certificateManagementService.updateCertificateContent( + existingCertId, certificatePEM, tenantDomain); + 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) { + handleCertificateAdd(updatingActionDTO, properties, tenantDomain); + } else if (existingCertValue != null) { + // Carry forward existing certificate ID — PUT semantics. + properties.put(CERTIFICATE, + new ActionProperty.BuilderForDAO(extractCertificateId(existingCertValue)).build()); + } + } + + 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); + } + } + + // Handles both raw UUID strings and Certificate objects, since the GET resolver replaces + // the stored UUID with the full Certificate. + private String extractCertificateId(Object certValue) { + + if (certValue instanceof Certificate certificate) { + return certificate.getId(); + } + return certValue.toString(); + } + + 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'."); + } + + 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); + } + } + + // Jackson deserializes JSON true as Boolean but JSON "true" as String — handle both. + 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/FlowExtensionContextTreeBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeBuilder.java new file mode 100644 index 000000000000..f5d98824e88b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeBuilder.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 FlowExtensionContextTreeBuilder { + + // 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 FlowExtensionContextTreeBuilder(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 FlowExtensionContextTreeMetadata 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). + FlowExtensionContextTreeNode flowNode = buildFlowNode(attrs); + if (flowNode != null) { + tree.add(flowNode); + } + + return new FlowExtensionContextTreeMetadata( + 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 FlowExtensionContextTreeNode 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(FlowExtensionContextTreeNode.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(FlowExtensionContextTreeNode.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(FlowExtensionContextTreeNode.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(FlowExtensionContextTreeNode.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(FlowExtensionContextTreeNode.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 FlowExtensionContextTreeNode.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 FlowExtensionContextTreeNode 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 FlowExtensionContextTreeNode.builder() + .key("flow") + .title("Flow") + .path("/flow/") + .dataType("") + .nodeType(NODE_OBJECT) + .allowedOperations(Collections.singletonList(OP_EXPOSE)) + .readOnly(true) + .children(children) + .build(); + } + + private FlowExtensionContextTreeNode flowLeaf(String key, String title, String path) { + + return FlowExtensionContextTreeNode.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 FlowExtensionContextTreeNode buildPropertiesNode(Set attrs) { + + List ops = attrs.contains("properties") + ? Arrays.asList(OP_EXPOSE, OP_MODIFY) + : Collections.singletonList(OP_MODIFY); + return FlowExtensionContextTreeNode.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/FlowExtensionContextTreeMetadata.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeMetadata.java new file mode 100644 index 000000000000..735126bd566c --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeMetadata.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 FlowExtensionContextTreeMetadata { + + private final String flowType; + private final List contextTree; + private final boolean redirectionEnabled; + private final boolean allowReadOnlyClaimsModification; + + public FlowExtensionContextTreeMetadata(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/FlowExtensionContextTreeNode.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeNode.java new file mode 100644 index 000000000000..ca7304d0e06d --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeNode.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 FlowExtensionContextTreeNode { + + 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 FlowExtensionContextTreeNode(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 FlowExtensionContextTreeNode build() { + + return new FlowExtensionContextTreeNode(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeService.java new file mode 100644 index 000000000000..b5b9ea1a6ae1 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeService.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 FlowExtensionContextTreeService { + + private static final FlowExtensionContextTreeService INSTANCE = new FlowExtensionContextTreeService(); + + private FlowExtensionContextTreeService() { + + } + + public static FlowExtensionContextTreeService 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 FlowExtensionContextTreeMetadata buildContextTree(String flowType) { + + return new FlowExtensionContextTreeBuilder( + 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..2b442c96f69e --- /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,137 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Configures flow context access for external services. + * * - expose: Paths sent to the service; 'encrypted' flag toggles outbound JWE. + * - modify: Paths the service can REPLACE; 'encrypted' flag toggles inbound JWE. + * * Note: External certificates are managed on {@link FlowExtensionAction#getCertificate()}, not here. + */ +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(new ArrayList<>(expose)) : null; + this.modify = modify != null ? Collections.unmodifiableList(new ArrayList<>(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..7dfff952fa42 --- /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.FlowExtensionConstants; + +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 FlowExtensionConstants.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 FlowExtensionConstants.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( + FlowExtensionConstants.HandoverPolicy.ATTR_FLOW_USER); + return new FlowContextHandoverConfig(resolvedAttrs, resolvedUserAttrs, fullPassthrough); + } + + /** + * Returns the default handover policy built from compile-time constants defined in + * {@link FlowExtensionConstants.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 FlowExtensionConstants.HandoverPolicy}.

+ * + * @return a new {@link FlowContextHandoverConfig} reflecting the default policy. + */ + public static FlowContextHandoverConfig defaultPolicy() { + + return of( + FlowExtensionConstants.HandoverPolicy.INCLUDED_ATTRIBUTES, + FlowExtensionConstants.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 FlowExtensionConstants.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/FlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionAction.java new file mode 100644 index 000000000000..b0a21c261288 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionAction.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; + +/** + * 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 FlowExtensionAction extends Action { + + private final AccessConfig accessConfig; + private final Encryption encryption; + private final Map flowTypeOverrides; + private final String iconUrl; + + public FlowExtensionAction(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 FlowExtensionAction(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 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 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 FlowExtensionAction. + * 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 FlowExtensionAction build() { + + return new FlowExtensionAction(this); + } + } + + /** + * Request Builder for FlowExtensionAction. + * 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 FlowExtensionAction build() { + + return new FlowExtensionAction(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionEvent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionEvent.java new file mode 100644 index 000000000000..0d310f9cf244 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionEvent.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 FlowExtensionEvent extends Event { + + private final FlowExtensionFlow flow; + private final String callbackUrl; + private final String portalUrl; + private final Map flowProperties; + + private FlowExtensionEvent(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 FlowExtensionFlow 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 FlowExtensionEvent. + */ + public static class Builder { + + private FlowExtensionFlow 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(FlowExtensionFlow 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 FlowExtensionEvent build() { + + return new FlowExtensionEvent(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionFlow.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionFlow.java new file mode 100644 index 000000000000..633ba666cb26 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionFlow.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 FlowExtensionFlow { + + private final String flowType; + private final String flowId; + private final User user; + + private FlowExtensionFlow(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 FlowExtensionFlow build() { + + return new FlowExtensionFlow(this); + } + } +} diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionRequest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionRequest.java new file mode 100644 index 000000000000..ed992c9a54ad --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionRequest.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 FlowExtensionEvent}. 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 FlowExtensionRequest extends Request { + + public FlowExtensionRequest() { + + 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/FlowExtensionContextFilterUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionContextFilterUtil.java new file mode 100644 index 000000000000..d678305e8850 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionContextFilterUtil.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.FlowExtensionConstants.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 FlowExtensionContextFilterUtil { + + private static final Log LOG = LogFactory.getLog(FlowExtensionContextFilterUtil.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 FlowExtensionContextFilterUtil() { + + } + + /** + * 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/FlowExtensionPathUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionPathUtil.java new file mode 100644 index 000000000000..7afe43985972 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/main/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionPathUtil.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.FlowExtensionConstants; + +import java.util.List; + +/** + * Path-matching utilities for In-Flow Extension access control. + */ +public final class FlowExtensionPathUtil { + + private FlowExtensionPathUtil() { + + } + + /** + * 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(FlowExtensionConstants.FlowContextPaths.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/FlowExtensionTestUtils.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/FlowExtensionTestUtils.java new file mode 100644 index 000000000000..6310d36da76b --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/FlowExtensionTestUtils.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 FlowExtensionTestUtils { + + /** + * 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 FlowExtensionTestUtils() { + + } + + /** + * 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/FlowExtensionExecutorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutorTest.java new file mode 100644 index 000000000000..af43a8afe1d1 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionExecutorTest.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.FlowExtensionConstants; +import org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus; +import org.wso2.carbon.identity.flow.extension.internal.FlowExtensionDataHolder; +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 FlowExtensionExecutor}. + */ +public class FlowExtensionExecutorTest { + + private FlowExtensionExecutor executor; + + @Mock + private ActionExecutorService actionExecutorService; + + private AutoCloseable mocks; + private MockedStatic holderMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + mocks = MockitoAnnotations.openMocks(this); + executor = new FlowExtensionExecutor(); + + // Stub FlowExtensionDataHolder for action executor service. + FlowExtensionDataHolder holderInstance = mock(FlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(actionExecutorService); + holderMock = mockStatic(FlowExtensionDataHolder.class); + holderMock.when(FlowExtensionDataHolder::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(), "FlowExtensionExecutor"); + } + + // ========================= 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"), "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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(); + + FlowExtensionDataHolder holderInstance = mock(FlowExtensionDataHolder.class); + when(holderInstance.getActionExecutorService()).thenReturn(null); + + holderMock = mockStatic(FlowExtensionDataHolder.class); + holderMock.when(FlowExtensionDataHolder::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("FlowExtensionExecutor", 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/FlowExtensionRequestBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionRequestBuilderTest.java new file mode 100644 index 000000000000..5d74d3da7775 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionRequestBuilderTest.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.FlowExtensionConstants; +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 FlowExtensionRequestBuilder}. + */ +public class FlowExtensionRequestBuilderTest { + + private FlowExtensionRequestBuilder requestBuilder; + private MockedStatic identityTenantUtilMock; + private MockedStatic loggerUtilsMock; + + @BeforeMethod + public void setUp() { + + requestBuilder = new FlowExtensionRequestBuilder(); + 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(FlowExtensionConstants.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(FlowExtensionConstants.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. + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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( + FlowExtensionConstants.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(FlowExtensionConstants.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( + FlowExtensionConstants.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(FlowExtensionConstants.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( + FlowExtensionConstants.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(FlowExtensionConstants.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( + FlowExtensionConstants.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(FlowExtensionConstants.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. + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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/id", false)), null); + + FlowContext flowContext = FlowContext.create() + .add(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, encryption)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + ActionExecutionRequest request = requestBuilder.buildActionExecutionRequest( + flowContext, mockReqCtx(accessConfig, null)); + + FlowExtensionEvent event = (FlowExtensionEvent) 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(FlowExtensionConstants.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 FlowExtensionAction + * configured with the given access config and encryption. + */ + private ActionExecutionRequestContext mockReqCtx(AccessConfig accessConfig, Encryption encryption) { + + FlowExtensionAction action = mock(FlowExtensionAction.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.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionResponseProcessorTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionResponseProcessorTest.java new file mode 100644 index 000000000000..470bbbed2e2c --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/executor/FlowExtensionResponseProcessorTest.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.FlowExtensionConstants; +import org.wso2.carbon.identity.flow.extension.internal.FlowExtensionDataHolder; +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 FlowExtensionResponseProcessor}. + */ +public class FlowExtensionResponseProcessorTest { + + private FlowExtensionResponseProcessor responseProcessor; + private MockedStatic loggerUtilsMock; + private MockedStatic holderMock; + private FlowExtensionDataHolder holderInstance; + private FlowContext capturedFlowContext; + + @BeforeMethod + public void setUp() throws Exception { + + responseProcessor = new FlowExtensionResponseProcessor(); + loggerUtilsMock = mockStatic(LoggerUtils.class); + loggerUtilsMock.when(LoggerUtils::isDiagnosticLogsEnabled).thenReturn(false); + + holderInstance = mock(FlowExtensionDataHolder.class); + holderMock = mockStatic(FlowExtensionDataHolder.class); + holderMock.when(FlowExtensionDataHolder::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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(FlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(capturedFlowContext.getValue(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.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(FlowExtensionConstants.PENDING_REDIRECT_URL_KEY, String.class), + "https://example.com/step-up"); + assertNull(flowContext.getValue(FlowExtensionConstants.PENDING_PROPERTIES_KEY, Map.class)); + assertNull(flowContext.getValue(FlowExtensionConstants.PENDING_CLAIMS_KEY, Map.class)); + assertNull(flowContext.getValue(FlowExtensionConstants.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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(FlowExtensionConstants.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(FlowExtensionConstants.FLOW_EXECUTION_CONTEXT_KEY, execCtx); + + if (pathTypeAnnotations != null && !pathTypeAnnotations.isEmpty()) { + flowContext.add(FlowExtensionConstants.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations); + } + if (modifyPaths != null) { + flowContext.add(FlowExtensionConstants.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..3f9f2bbd63fe --- /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.FlowExtensionTestUtils; + +import java.util.Set; + +/** + * Test-only helper that delegates to {@link FlowExtensionTestUtils#configOf} to + * instantiate {@link FlowContextHandoverConfig} with explicit allow-lists. + */ +final class FlowContextHandoverConfigTestHelper { + + private FlowContextHandoverConfigTestHelper() { + + } + + static FlowContextHandoverConfig of(Set attrs, Set userAttrs) { + + return FlowExtensionTestUtils.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/FlowExtensionContextTreeBuilderTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeBuilderTest.java new file mode 100644 index 000000000000..015f39224ee6 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/metadata/FlowExtensionContextTreeBuilderTest.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 FlowExtensionContextTreeBuilder}. + * + *

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

+ */ +public class FlowExtensionContextTreeBuilderTest { + + // ========================= redirection always enabled ========================= + + @Test + public void testRedirectionAlwaysEnabled() { + + FlowExtensionContextTreeMetadata 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationForInvitedUserRegistration() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "INVITED_USER_REGISTRATION"); + assertTrue(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForPasswordRecovery() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "PASSWORD_RECOVERY"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationFalseForUnknownFlowType() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "SOME_FUTURE_FLOW"); + assertFalse(meta.isAllowReadOnlyClaimsModification()); + } + + @Test + public void testAllowReadOnlyClaimsModificationTrueForNullFlowType() { + + FlowExtensionContextTreeMetadata 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() { + + FlowExtensionContextTreeMetadata 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. + FlowExtensionContextTreeNode 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(); + FlowExtensionContextTreeNode 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")); + + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain", "flowType")), + new HashSet<>(), null); + + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), + new HashSet<>(), null); + + // User node is always present (claims + credentials always included). + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // no flowUser + new HashSet<>(Arrays.asList("username", "claims")), null); + + FlowExtensionContextTreeNode 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. + FlowExtensionContextTreeNode 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. + FlowExtensionContextTreeNode 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. + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("flowUser")), + new HashSet<>(), null); // includedUserAttributes empty — ignored in passthrough + + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("tenantDomain")), // properties not in allow-list + new HashSet<>(), null); + + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(Arrays.asList("properties")), + new HashSet<>(), null); + + FlowExtensionContextTreeNode 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "claims" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + FlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertFalse(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testClaimsHasExposeAndModifyWhenExposed() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("claims")), null); + + List userChildren = findNode(meta, "user").getChildren(); + FlowExtensionContextTreeNode claimsNode = findChildNode(userChildren, "claims"); + assertNotNull(claimsNode); + assertTrue(claimsNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(claimsNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasOnlyModifyWhenNotExposed() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(), null); // "userCredentials" not in userAttrs + + List userChildren = findNode(meta, "user").getChildren(); + FlowExtensionContextTreeNode credNode = findChildNode(userChildren, "credentials"); + assertNotNull(credNode); + assertFalse(credNode.getAllowedOperations().contains("EXPOSE")); + assertTrue(credNode.getAllowedOperations().contains("MODIFY")); + } + + @Test + public void testCredentialsHasExposeAndModifyWhenExposed() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), + new HashSet<>(Arrays.asList("userCredentials")), null); + + List userChildren = findNode(meta, "user").getChildren(); + FlowExtensionContextTreeNode 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. + FlowExtensionContextTreeMetadata 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() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), "REGISTRATION"); + + assertEquals(meta.getFlowType(), "REGISTRATION"); + } + + @Test + public void testFlowTypeNullPreserved() { + + FlowExtensionContextTreeMetadata meta = buildWith( + new HashSet<>(), new HashSet<>(), null); + + assertNull(meta.getFlowType()); + } + + // ========================= resolveAllowReadOnlyClaimsModification (static) ========================= + + @Test + public void testResolveAllowReadOnlyClaimsModificationDirectly() { + + assertTrue(FlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification(null)); + assertTrue(FlowExtensionContextTreeBuilder.resolveAllowReadOnlyClaimsModification("REGISTRATION")); + assertTrue(FlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("INVITED_USER_REGISTRATION")); + assertFalse(FlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("PASSWORD_RECOVERY")); + assertFalse(FlowExtensionContextTreeBuilder + .resolveAllowReadOnlyClaimsModification("UNKNOWN_TYPE")); + } + + // ========================= helpers ========================= + + private FlowExtensionContextTreeMetadata buildWith(Set attrs, + Set userAttrs, + String flowType) { + + FlowContextHandoverConfig cfg = FlowContextHandoverConfigTestHelper.of(attrs, userAttrs); + return new FlowExtensionContextTreeBuilder(cfg).build(flowType); + } + + private FlowExtensionContextTreeNode findNode(FlowExtensionContextTreeMetadata meta, + String key) { + + if (meta.getContextTree() == null) { + return null; + } + for (FlowExtensionContextTreeNode 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 (FlowExtensionContextTreeNode child : children) { + if (key.equals(child.getKey())) { + return true; + } + } + return false; + } + + private FlowExtensionContextTreeNode findChildNode(List children, + String key) { + + if (children == null) { + return null; + } + for (FlowExtensionContextTreeNode 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/FlowExtensionEventTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionEventTest.java new file mode 100644 index 000000000000..2259ea41da01 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/model/FlowExtensionEventTest.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 FlowExtensionEvent}. + */ +public class FlowExtensionEventTest { + + @Test + public void testBuilderWithAllFields() { + + Map flowProperties = new HashMap<>(); + flowProperties.put("riskScore", 85); + + FlowExtensionFlow flow = new FlowExtensionFlow.Builder() + .flowType("REGISTRATION") + .flowId("flow-id-123") + .build(); + + FlowExtensionEvent event = new FlowExtensionEvent.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() { + + FlowExtensionFlow flow = new FlowExtensionFlow.Builder() + .flowType("LOGIN") + .flowId("flow-id-456") + .build(); + + FlowExtensionEvent event = new FlowExtensionEvent.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"); + + FlowExtensionEvent event = new FlowExtensionEvent.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"); + + FlowExtensionEvent event = new FlowExtensionEvent.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/FlowExtensionPathUtilTest.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionPathUtilTest.java new file mode 100644 index 000000000000..6df0b478f295 --- /dev/null +++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.extension/src/test/java/org/wso2/carbon/identity/flow/extension/util/FlowExtensionPathUtilTest.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 FlowExtensionPathUtil}. + */ +public class FlowExtensionPathUtilTest { + + // ========================= 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(FlowExtensionPathUtil.isReadOnly(path), expected); + } + + // ========================= anyExposedUnder ========================= + + @Test + public void testAnyExposedUnderMatchesLeafUnderPrefix() { + + List leafPaths = Arrays.asList( + "/user/claims/http://wso2.org/claims/email", + "/properties/riskScore"); + assertTrue(FlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertTrue(FlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/flow/applicationId"); + assertFalse(FlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + assertFalse(FlowExtensionPathUtil.anyExposedUnder("/properties/", leafPaths)); + } + + @Test + public void testAnyExposedUnderNullPrefix() { + + assertFalse(FlowExtensionPathUtil.anyExposedUnder(null, + Arrays.asList("/user/claims/email"))); + } + + @Test + public void testAnyExposedUnderNullList() { + + assertFalse(FlowExtensionPathUtil.anyExposedUnder("/user/claims/", null)); + } + + @Test + public void testAnyExposedUnderEmptyList() { + + assertFalse(FlowExtensionPathUtil.anyExposedUnder("/user/claims/", + Collections.emptyList())); + } + + @Test + public void testAnyExposedUnderDoesNotMatchShortPath() { + + List leafPaths = Collections.singletonList("/user/userId"); + assertFalse(FlowExtensionPathUtil.anyExposedUnder("/user/claims/", leafPaths)); + } + + @Test + public void testAnyExposedUnderMultipleLeafsOneMatches() { + + List leafPaths = Arrays.asList( + "/flow/tenantDomain", + "/user/credentials/password"); + assertTrue(FlowExtensionPathUtil.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(FlowExtensionPathUtil.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + assertTrue(FlowExtensionPathUtil.isExposedPath("/flow/tenantDomain", leafPaths)); + assertTrue(FlowExtensionPathUtil.isExposedPath("/user/userId", leafPaths)); + } + + @Test + public void testIsExposedPathNoMatch() { + + List leafPaths = Arrays.asList("/flow/tenantDomain", "/user/userId"); + assertFalse(FlowExtensionPathUtil.isExposedPath( + "/user/claims/http://wso2.org/claims/email", leafPaths)); + } + + @Test + public void testIsExposedPathNullPath() { + + assertFalse(FlowExtensionPathUtil.isExposedPath(null, + Arrays.asList("/user/userId"))); + } + + @Test + public void testIsExposedPathNullList() { + + assertFalse(FlowExtensionPathUtil.isExposedPath("/user/userId", null)); + } + + @Test + public void testIsExposedPathEmptyList() { + + assertFalse(FlowExtensionPathUtil.isExposedPath("/user/userId", + Collections.emptyList())); + } + + @Test + public void testIsExposedPathPrefixNotSufficient() { + + List leafPaths = Collections.singletonList("/user/claims/http://wso2.org/claims/email"); + assertFalse(FlowExtensionPathUtil.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..8c805a186f3c --- /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/components/flow-orchestration-framework/pom.xml b/components/flow-orchestration-framework/pom.xml index 6a5ce82da046..78079bf12046 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.extension 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 + + + + + + + + + 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..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 @@ -43,6 +43,11 @@ org.wso2.carbon.identity.flow.execution.engine.server.feature zip + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.extension.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.extension.server.feature + diff --git a/features/flow-orchestration-framework/pom.xml b/features/flow-orchestration-framework/pom.xml index f7f70d5960ba..6b397286fb2a 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.extension.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..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,6 +2518,12 @@ {{actions.types.authentication.default_userstore}} + + {{actions.types.flow_extension.enable}} + + {{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 8c359b7a6700..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,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.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", diff --git a/pom.xml b/pom.xml index efbbd1ddcf5f..848a14b83ccb 100644 --- a/pom.xml +++ b/pom.xml @@ -854,6 +854,12 @@ zip ${project.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.flow.extension.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.extension + ${project.version} + org.wso2.carbon.identity.framework org.wso2.carbon.identity.servlet.mgt