From 256f3c7b69e754e73ef1c1bfcc195a922c1a8bff Mon Sep 17 00:00:00 2001
From: ThejithaR
Date: Thu, 5 Feb 2026 17:26:13 +0530
Subject: [PATCH 01/41] Add In-Flow Extension Action Execution Support
- Introduced InFlowExtensionExecutor to handle execution of In-Flow Extension actions during flow execution.
- Created InFlowExtensionRequestBuilder to build action execution requests from FlowContext.
- Implemented InFlowExtensionResponseProcessor to process responses from In-Flow Extension actions, handling context updates for properties, user claims, and user inputs.
- Added OperationExecutionResult class to encapsulate the result of operation executions.
- Updated ActionExecutorConfig to include configuration for In-Flow Extension actions.
- Enhanced Action model to support In-Flow Extension action type.
- Modified ActionManagementConfig to manage versions and properties for In-Flow Extension actions.
---
.../execution/api/model/ActionType.java | 3 +-
.../ActionExecutionServiceComponent.java | 23 +-
...ActionExecutionServiceComponentHolder.java | 22 +
.../executor/InFlowExtensionEvent.java | 182 +++++++
.../executor/InFlowExtensionExecutor.java | 333 +++++++++++++
.../InFlowExtensionRequestBuilder.java | 279 +++++++++++
.../InFlowExtensionResponseProcessor.java | 453 ++++++++++++++++++
.../executor/OperationExecutionResult.java | 62 +++
.../internal/util/ActionExecutorConfig.java | 12 +-
.../action/management/api/model/Action.java | 8 +-
.../internal/util/ActionManagementConfig.java | 11 +-
11 files changed, 1382 insertions(+), 6 deletions(-)
create mode 100644 components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionEvent.java
create mode 100644 components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionExecutor.java
create mode 100644 components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionRequestBuilder.java
create mode 100644 components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionResponseProcessor.java
create mode 100644 components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/OperationExecutionResult.java
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java
index a7ff01fc0297..203edb9ae14d 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/api/model/ActionType.java
@@ -29,7 +29,8 @@ public enum ActionType {
PRE_UPDATE_PASSWORD,
PRE_UPDATE_PROFILE,
AUTHENTICATION,
- PRE_ISSUE_ID_TOKEN;
+ PRE_ISSUE_ID_TOKEN,
+ IN_FLOW_EXTENSION;
public String getDisplayName() {
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponent.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponent.java
index 185114e5da7c..253b8118c540 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponent.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponent.java
@@ -33,12 +33,16 @@
import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService;
import org.wso2.carbon.identity.action.execution.api.service.ActionInvocationResponseClassProvider;
import org.wso2.carbon.identity.action.execution.api.service.ActionVersioningHandler;
+import org.wso2.carbon.identity.action.execution.internal.executor.InFlowExtensionExecutor;
+import org.wso2.carbon.identity.action.execution.internal.executor.InFlowExtensionRequestBuilder;
+import org.wso2.carbon.identity.action.execution.internal.executor.InFlowExtensionResponseProcessor;
import org.wso2.carbon.identity.action.execution.internal.service.impl.ActionExecutionRequestBuilderFactory;
import org.wso2.carbon.identity.action.execution.internal.service.impl.ActionExecutionResponseProcessorFactory;
import org.wso2.carbon.identity.action.execution.internal.service.impl.ActionExecutorServiceImpl;
import org.wso2.carbon.identity.action.execution.internal.service.impl.ActionInvocationResponseClassFactory;
import org.wso2.carbon.identity.action.execution.internal.service.impl.ActionVersioningHandlerFactory;
import org.wso2.carbon.identity.action.management.api.service.ActionManagementService;
+import org.wso2.carbon.identity.flow.execution.engine.graph.Executor;
import org.wso2.carbon.identity.rule.evaluation.api.service.RuleEvaluationService;
import org.wso2.carbon.identity.secret.mgt.core.SecretManager;
import org.wso2.carbon.identity.secret.mgt.core.SecretResolveManager;
@@ -60,8 +64,23 @@ protected void activate(ComponentContext context) {
try {
BundleContext bundleCtx = context.getBundleContext();
- bundleCtx.registerService(ActionExecutorService.class.getName(), ActionExecutorServiceImpl.getInstance(),
- null);
+
+ // Register ActionExecutorService
+ ActionExecutorService actionExecutorService = ActionExecutorServiceImpl.getInstance();
+ bundleCtx.registerService(ActionExecutorService.class.getName(), actionExecutorService, null);
+
+ // Store reference for internal use
+ ActionExecutionServiceComponentHolder.getInstance().setActionExecutorService(actionExecutorService);
+
+ // Register In-Flow Extension services
+ bundleCtx.registerService(ActionExecutionRequestBuilder.class.getName(),
+ new InFlowExtensionRequestBuilder(), null);
+ bundleCtx.registerService(ActionExecutionResponseProcessor.class.getName(),
+ new InFlowExtensionResponseProcessor(), null);
+
+ // Register InFlowExtensionExecutor for the flow engine
+ bundleCtx.registerService(Executor.class.getName(), new InFlowExtensionExecutor(), null);
+
LOG.debug("Action execution bundle is activated.");
} catch (Throwable e) {
LOG.error("Error while initializing Action execution service component.", e);
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java
index d5f90106b0d0..05f2708d7fc9 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/component/ActionExecutionServiceComponentHolder.java
@@ -18,6 +18,7 @@
package org.wso2.carbon.identity.action.execution.internal.component;
+import org.wso2.carbon.identity.action.execution.api.service.ActionExecutorService;
import org.wso2.carbon.identity.action.management.api.service.ActionManagementService;
import org.wso2.carbon.identity.rule.evaluation.api.service.RuleEvaluationService;
import org.wso2.carbon.identity.secret.mgt.core.SecretManager;
@@ -36,6 +37,7 @@ public class ActionExecutionServiceComponentHolder {
private SecretManager secretManager;
private SecretResolveManager secretResolveManager;
private RealmService realmService;
+ private ActionExecutorService actionExecutorService;
private ActionExecutionServiceComponentHolder() {
@@ -115,4 +117,24 @@ public void setRealmService(RealmService realmService) {
this.realmService = realmService;
}
+
+ /**
+ * Get the ActionExecutorService instance.
+ *
+ * @return ActionExecutorService instance.
+ */
+ public ActionExecutorService getActionExecutorService() {
+
+ return actionExecutorService;
+ }
+
+ /**
+ * Set the ActionExecutorService instance.
+ *
+ * @param actionExecutorService ActionExecutorService instance.
+ */
+ public void setActionExecutorService(ActionExecutorService actionExecutorService) {
+
+ this.actionExecutorService = actionExecutorService;
+ }
}
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionEvent.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionEvent.java
new file mode 100644
index 000000000000..ee6598cb897e
--- /dev/null
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionEvent.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2025, 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.action.execution.internal.executor;
+
+import org.wso2.carbon.identity.action.execution.api.model.Application;
+import org.wso2.carbon.identity.action.execution.api.model.Event;
+import org.wso2.carbon.identity.action.execution.api.model.Organization;
+import org.wso2.carbon.identity.action.execution.api.model.Request;
+import org.wso2.carbon.identity.action.execution.api.model.Tenant;
+import org.wso2.carbon.identity.action.execution.api.model.User;
+import org.wso2.carbon.identity.action.execution.api.model.UserStore;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * This class models the In-Flow Extension Event.
+ * It represents the event sent to the In-Flow Extension action over the Action Execution Request.
+ * Contains flow-specific context such as flow type, current node, user inputs, and flow properties.
+ */
+public class InFlowExtensionEvent extends Event {
+
+ private final String flowType;
+ private final String currentNodeId;
+ private final Map userInputs;
+ private final Map flowProperties;
+
+ private InFlowExtensionEvent(Builder builder) {
+
+ this.request = builder.request;
+ this.tenant = builder.tenant;
+ this.organization = builder.organization;
+ this.user = builder.user;
+ this.userStore = builder.userStore;
+ this.application = builder.application;
+ this.flowType = builder.flowType;
+ this.currentNodeId = builder.currentNodeId;
+ this.userInputs = builder.userInputs != null ?
+ Collections.unmodifiableMap(new HashMap<>(builder.userInputs)) : Collections.emptyMap();
+ this.flowProperties = builder.flowProperties != null ?
+ Collections.unmodifiableMap(new HashMap<>(builder.flowProperties)) : Collections.emptyMap();
+ }
+
+ /**
+ * Get the flow type.
+ *
+ * @return The flow type (e.g., "REGISTRATION", "PASSWORD_RESET").
+ */
+ public String getFlowType() {
+
+ return flowType;
+ }
+
+ /**
+ * Get the current node ID in the flow.
+ *
+ * @return The current node ID.
+ */
+ public String getCurrentNodeId() {
+
+ return currentNodeId;
+ }
+
+ /**
+ * Get the user inputs collected during the flow.
+ *
+ * @return Unmodifiable map of user inputs.
+ */
+ public Map getUserInputs() {
+
+ return userInputs;
+ }
+
+ /**
+ * Get the flow properties/context data.
+ *
+ * @return Unmodifiable map of flow properties.
+ */
+ public Map getFlowProperties() {
+
+ return flowProperties;
+ }
+
+ /**
+ * Builder for the InFlowExtensionEvent.
+ */
+ public static class Builder {
+
+ private Request request;
+ private Tenant tenant;
+ private Organization organization;
+ private User user;
+ private UserStore userStore;
+ private Application application;
+ private String flowType;
+ private String currentNodeId;
+ private Map userInputs;
+ private Map flowProperties;
+
+ public Builder request(Request request) {
+
+ this.request = request;
+ return this;
+ }
+
+ public Builder tenant(Tenant tenant) {
+
+ this.tenant = tenant;
+ return this;
+ }
+
+ public Builder organization(Organization organization) {
+
+ this.organization = organization;
+ return this;
+ }
+
+ public Builder user(User user) {
+
+ this.user = user;
+ return this;
+ }
+
+ public Builder userStore(UserStore userStore) {
+
+ this.userStore = userStore;
+ return this;
+ }
+
+ public Builder application(Application application) {
+
+ this.application = application;
+ return this;
+ }
+
+ public Builder flowType(String flowType) {
+
+ this.flowType = flowType;
+ return this;
+ }
+
+ public Builder currentNodeId(String currentNodeId) {
+
+ this.currentNodeId = currentNodeId;
+ return this;
+ }
+
+ public Builder userInputs(Map userInputs) {
+
+ this.userInputs = userInputs;
+ return this;
+ }
+
+ public Builder flowProperties(Map flowProperties) {
+
+ this.flowProperties = flowProperties;
+ return this;
+ }
+
+ public InFlowExtensionEvent build() {
+
+ return new InFlowExtensionEvent(this);
+ }
+ }
+}
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionExecutor.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionExecutor.java
new file mode 100644
index 000000000000..678211b3b46b
--- /dev/null
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionExecutor.java
@@ -0,0 +1,333 @@
+/*
+ * Copyright (c) 2025, 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.action.execution.internal.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.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.action.execution.internal.component.ActionExecutionServiceComponentHolder;
+import org.wso2.carbon.identity.flow.execution.engine.exception.FlowEngineException;
+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.List;
+import java.util.Map;
+
+/**
+ * This class is responsible for executing In-Flow Extension actions during flow execution.
+ * It integrates with the ActionExecutorService to call external services and process their responses.
+ */
+public class InFlowExtensionExecutor implements Executor {
+
+ private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class);
+ private static final String EXECUTOR_NAME = "ExtensionExecutor";
+// private static final String FLOW_EXECUTION_CONTEXT_KEY = "flowExecutionContext";
+ protected static final String CORRELATION_ID_KEY = "correlationId";
+ protected static final String FLOW_USER_INPUT_DATA_KEY = "userInputData";
+ protected static final String FLOW_USER_KEY = "flowUser";
+ protected static final String FLOW_PROPERTIES_KEY = "flowProperties";
+ protected static final String FLOW_CONTEXT_UPDATES_KEY = "contextUpdates";
+ protected static final String ACTION_ID_METADATA_KEY = "actionId";
+ protected static final String ALLOWED_OPERATIONS_METADATA_KEY = "allowedOperations";
+ protected static final String ALLOWED_OPERATIONS_KEY = "allowedOperations";
+ protected static final String TENET_DOMAIN_KEY = "tenetDomain";
+ protected static final String APPLICATION_ID_KEY = "applicationId";
+ protected static final String FLOW_TYPE_KEY = "flowType";
+ protected static final String CURRENT_NODE_KEY = "currentNode";
+
+ @Override
+ public String getName() {
+
+ return EXECUTOR_NAME;
+ }
+
+ @Override
+ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineException {
+
+ ExecutorResponse response = new ExecutorResponse();
+
+ try {
+ // Get the action ID from the executor metadata configuration
+ String actionId = getActionIdFromMetadata(context);
+ if (actionId == null || actionId.isEmpty()) {
+ LOG.warn("No action ID configured for In-Flow Extension executor. Skipping execution.");
+ response.setResult(ExecutorResult.COMPLETE.name());
+ return response;
+ }
+
+ LOG.debug("Executing In-Flow Extension action with ID: " + actionId);
+
+ // Build the flow context for ActionExecutorService
+ FlowContext flowContext = buildFlowContext(context); // passing the whole flow execution context as an object for now
+
+ // Get the ActionExecutorService
+ ActionExecutorService actionExecutorService = getActionExecutorService();
+ if (actionExecutorService == null) {
+ throw new FlowEngineException("ActionExecutorService is not available.");
+ }
+
+ // Check if execution is enabled for this action type
+ if (!actionExecutorService.isExecutionEnabled(ActionType.IN_FLOW_EXTENSION)) {
+ LOG.debug("In-Flow Extension action execution is disabled. Skipping.");
+ response.setResult(ExecutorResult.COMPLETE.name());
+ return response;
+ }
+
+ // Execute the action
+ ActionExecutionStatus> executionStatus = actionExecutorService.execute(
+ ActionType.IN_FLOW_EXTENSION,
+ actionId,
+ flowContext,
+ context.getTenantDomain());
+
+ // Process the result
+ return processExecutionStatus(executionStatus, context);
+
+ } catch (ActionExecutionException e) {
+ LOG.error("Error executing In-Flow Extension action.", e);
+ response.setResult(ExecutorResult.ERROR.name());
+ response.setErrorMessage("Action execution failed");
+ response.setErrorDescription(e.getMessage());
+ response.setThrowable(e);
+ return response;
+ }
+ }
+
+ @Override
+ public List getInitiationData() {
+
+ return Collections.emptyList();
+ }
+
+ @Override
+ public ExecutorResponse rollback(FlowExecutionContext context) throws FlowEngineException {
+
+ ExecutorResponse response = new ExecutorResponse();
+ response.setResult(ExecutorResult.COMPLETE.name());
+ return response;
+ }
+
+ /**
+ * Build the FlowContext for ActionExecutorService from FlowExecutionContext.
+ *
+ * @param context The FlowExecutionContext.
+ * @return The FlowContext for action execution.
+ */
+ private FlowContext buildFlowContext(FlowExecutionContext context) {
+
+ FlowContext flowContext = FlowContext.create();
+ flowContext.add(CORRELATION_ID_KEY, context.getCorrelationId());
+ flowContext.add(FLOW_USER_KEY, context.getFlowUser());
+ flowContext.add(FLOW_USER_INPUT_DATA_KEY, context.getUserInputData());
+ flowContext.add(FLOW_PROPERTIES_KEY, context.getProperties());
+ flowContext.add(TENET_DOMAIN_KEY, context.getTenantDomain());
+ flowContext.add(APPLICATION_ID_KEY, context.getApplicationId());
+ flowContext.add(FLOW_TYPE_KEY, context.getFlowType());
+ flowContext.add(CURRENT_NODE_KEY, context.getCurrentNode());
+
+ // Add allowed operations from executor metadata
+ String allowedOperationsJson = getAllowedOperationsFromMetadata(context);
+ if (allowedOperationsJson != null) {
+ flowContext.add(ALLOWED_OPERATIONS_KEY, allowedOperationsJson);
+ }
+
+ return flowContext;
+ }
+
+ /**
+ * Process the ActionExecutionStatus and map it to ExecutorResponse.
+ *
+ * @param executionStatus The status returned by ActionExecutorService.
+ * @param context The FlowExecutionContext to update with response data.
+ * @return The ExecutorResponse.
+ */
+ @SuppressWarnings("unchecked")
+ private ExecutorResponse processExecutionStatus(ActionExecutionStatus> executionStatus,
+ FlowExecutionContext context) {
+
+ ExecutorResponse response = new ExecutorResponse();
+
+ if (executionStatus == null) {
+ response.setResult(ExecutorResult.USER_INPUT_REQUIRED.name());
+ return response;
+ }
+
+ switch (executionStatus.getStatus()) {
+ case SUCCESS:
+ response.setResult(ExecutorResult.COMPLETE.name());
+ // Extract context updates from response and apply them
+ applyContextUpdates(executionStatus.getResponseContext(), context, response);
+ break;
+
+ case FAILED:
+ response.setResult(ExecutorResult.USER_ERROR.name());
+ Failure failure = (Failure) executionStatus.getResponse();
+ if (failure != null) {
+ response.setErrorMessage(failure.getFailureReason());
+ response.setErrorDescription(failure.getFailureDescription());
+ }
+ break;
+
+ case ERROR:
+ response.setResult(ExecutorResult.ERROR.name());
+ Error error = (Error) executionStatus.getResponse();
+ if (error != null) {
+ response.setErrorMessage(error.getErrorMessage());
+ response.setErrorDescription(error.getErrorDescription());
+ }
+ break;
+
+ case INCOMPLETE:
+ // INCOMPLETE status indicates the flow should wait for external input
+ response.setResult(ExecutorResult.USER_INPUT_REQUIRED.name());
+ break;
+
+ default:
+ LOG.warn("Unknown execution status: " + executionStatus.getStatus());
+ response.setResult(ExecutorResult.COMPLETE.name());
+ }
+
+ return response;
+ }
+
+ /**
+ * Apply context updates from the action response to the FlowExecutionContext.
+ *
+ * @param responseContext The response context from action execution.
+ * @param flowContext The FlowExecutionContext to update.
+ * @param response The ExecutorResponse to populate with context properties.
+ */
+ @SuppressWarnings("unchecked")
+ private void applyContextUpdates(Map responseContext,
+ FlowExecutionContext flowContext,
+ ExecutorResponse response) {
+
+ if (responseContext == null || responseContext.isEmpty()) {
+ return;
+ }
+
+ // Extract context updates from the response
+ Object contextUpdatesObj = responseContext.get(FLOW_CONTEXT_UPDATES_KEY);
+ if (contextUpdatesObj instanceof Map) {
+ Map contextUpdates = (Map) contextUpdatesObj;
+
+ // Apply updates to FlowExecutionContext properties
+ for (Map.Entry entry : contextUpdates.entrySet()) {
+ flowContext.setProperty(entry.getKey(), entry.getValue());
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Applied context update: " + entry.getKey());
+ }
+ }
+
+ // Also set the context properties in the executor response
+ response.setContextProperty(contextUpdates);
+ }
+ }
+
+ /**
+ * Get the ActionExecutorService instance.
+ *
+ * @return The ActionExecutorService.
+ */
+ private ActionExecutorService getActionExecutorService() {
+
+ return ActionExecutionServiceComponentHolder.getInstance().getActionExecutorService();
+ }
+
+ /**
+ * Extract the action ID from the executor metadata configuration.
+ * The action ID should be configured in the flow JSON as part of the executor's meta object.
+ *
+ * @param context The FlowExecutionContext containing the current node configuration.
+ * @return The action ID if configured, null otherwise.
+ */
+ private String getActionIdFromMetadata(FlowExecutionContext context) {
+
+ NodeConfig currentNode = context.getCurrentNode();
+ if (currentNode == null) {
+ LOG.debug("Current node is null, cannot extract action ID from metadata.");
+ return null;
+ }
+
+ ExecutorDTO executorConfig = currentNode.getExecutorConfig();
+ if (executorConfig == null) {
+ LOG.debug("Executor config is null, cannot extract action ID from metadata.");
+ return null;
+ }
+
+ Map metadata = executorConfig.getMetadata();
+ if (metadata == null || metadata.isEmpty()) {
+ LOG.debug("Executor metadata is null or empty, cannot extract action ID.");
+ return null;
+ }
+
+ return metadata.get(ACTION_ID_METADATA_KEY);
+ }
+
+ /**
+ * Extract the allowed operations configuration from the executor metadata.
+ * The allowed operations should be configured in the flow JSON as part of the executor's meta object.
+ *
+ * @param context The FlowExecutionContext containing the current node configuration.
+ * @return The allowed operations JSON string if configured, null otherwise.
+ */
+ private String getAllowedOperationsFromMetadata(FlowExecutionContext context) {
+
+ 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(ALLOWED_OPERATIONS_METADATA_KEY);
+ }
+
+ /**
+ * Enum representing the possible results of executor execution.
+ * These values must match the expected status values in the flow execution engine.
+ * @see org.wso2.carbon.identity.flow.execution.engine.Constants.ExecutorStatus
+ */
+ public enum ExecutorResult {
+ COMPLETE, // Executor completed successfully
+ ERROR, // Server-side error occurred
+ USER_ERROR, // User-related error (e.g., validation failure)
+ USER_INPUT_REQUIRED,// Additional user input needed
+ EXTERNAL_REDIRECTION,// Redirect to external service
+ RETRY // Retry the current step
+ }
+}
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionRequestBuilder.java b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionRequestBuilder.java
new file mode 100644
index 000000000000..8a3cb9d3a961
--- /dev/null
+++ b/components/action-mgt/org.wso2.carbon.identity.action.execution/src/main/java/org/wso2/carbon/identity/action/execution/internal/executor/InFlowExtensionRequestBuilder.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (c) 2025, 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.action.execution.internal.executor;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException;
+import org.wso2.carbon.identity.action.execution.api.model.*;
+import org.wso2.carbon.identity.action.execution.api.service.ActionExecutionRequestBuilder;
+import org.wso2.carbon.identity.core.util.IdentityTenantUtil;
+import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser;
+import org.wso2.carbon.identity.flow.mgt.model.NodeConfig;
+
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * This class is responsible for building the Action Execution Request for In-Flow Extension actions.
+ * It converts the FlowExecutionContext into the standard ActionExecutionRequest format.
+ */
+public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuilder {
+
+ private static final Log LOG = LogFactory.getLog(InFlowExtensionRequestBuilder.class);
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+ private static final TypeReference>> OPERATION_LIST_TYPE_REF =
+ new TypeReference>>() { };
+
+ @Override
+ public ActionType getSupportedActionType() {
+
+ return ActionType.IN_FLOW_EXTENSION;
+ }
+
+ @Override
+ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContext,
+ ActionExecutionRequestContext actionExecutionContext)
+ throws ActionExecutionRequestBuilderException {
+
+// FlowExecutionContext flowExecutionContext = flowContext.getValue(FLOW_EXECUTION_CONTEXT_KEY,
+// FlowExecutionContext.class);
+
+// if (flowExecutionContext == null) {
+// throw new ActionExecutionRequestBuilderException("FlowExecutionContext not found in flow context.");
+// }
+
+ InFlowExtensionEvent event = buildEvent(flowContext);
+
+ // Build allowed operations from metadata configuration
+ List allowedOperations = buildAllowedOperations(flowContext);
+
+ return new ActionExecutionRequest.Builder()
+ .actionType(ActionType.IN_FLOW_EXTENSION)
+ .flowId(flowContext.getValue(InFlowExtensionExecutor.CORRELATION_ID_KEY, String.class))
+ .event(event)
+ .allowedOperations(allowedOperations)
+ .build();
+ }
+
+ /**
+ * Build allowed operations from the flow context.
+ * Parses the allowedOperations JSON from executor metadata.
+ *
+ * @param flowContext The flow context containing allowed operations configuration.
+ * @return List of AllowedOperation objects.
+ */
+ private List buildAllowedOperations(FlowContext flowContext) {
+
+ String allowedOperationsJson = flowContext.getValue(
+ InFlowExtensionExecutor.ALLOWED_OPERATIONS_KEY, String.class);
+
+ if (allowedOperationsJson == null || allowedOperationsJson.isEmpty()) {
+ LOG.debug("No allowed operations configured in executor metadata. Using empty list.");
+ return Collections.emptyList();
+ }
+
+ try {
+ List
+ */
+public class AccessConfigFlowUpdateInterceptor implements FlowUpdateInterceptor {
+
+ private static final Log LOG = LogFactory.getLog(AccessConfigFlowUpdateInterceptor.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final TypeReference> MAP_TYPE_REF =
+ new TypeReference>() { };
+
+ private static final String ACTION_ID_METADATA_KEY = "actionId";
+ private static final String EXPOSE_FIELD = "expose";
+ private static final String ALLOWED_OPERATIONS_FIELD = "allowedOperations";
+
+ @Override
+ public void onFlowUpdate(String flowType, GraphConfig graphConfig, int tenantId)
+ throws FlowMgtFrameworkException {
+
+ if (graphConfig == null || graphConfig.getNodeConfigs() == null) {
+ return;
+ }
+
+ ActionManagementService actionMgtService =
+ FlowExecutionEngineDataHolder.getInstance().getActionManagementService();
+ if (actionMgtService == null) {
+ LOG.warn("ActionManagementService is not available. Skipping access config override processing.");
+ return;
+ }
+
+ String tenantDomain = IdentityTenantUtil.getTenantDomain(tenantId);
+
+ for (NodeConfig node : graphConfig.getNodeConfigs().values()) {
+ processNode(node, flowType, tenantDomain, actionMgtService);
+ }
+ }
+
+ /**
+ * Process a single node: extract unified accessConfig from executor metadata, save as action
+ * properties, and strip the accessConfig key from metadata before DAO persistence.
+ */
+ @SuppressWarnings("unchecked")
+ private void processNode(NodeConfig node, String flowType, String tenantDomain,
+ ActionManagementService actionMgtService) throws FlowMgtFrameworkException {
+
+ ExecutorDTO executorConfig = node.getExecutorConfig();
+ if (executorConfig == null || executorConfig.getMetadata() == null) {
+ return;
+ }
+
+ Map metadata = executorConfig.getMetadata();
+ String actionId = metadata.get(ACTION_ID_METADATA_KEY);
+ if (actionId == null || actionId.isEmpty()) {
+ return;
+ }
+
+ String accessConfigJson = metadata.get(ACCESS_CONFIG_METADATA_KEY);
+ if (accessConfigJson == null) {
+ return;
+ }
+
+ // Always strip the accessConfig key from metadata — it must NOT be persisted
+ // to executor metadata (VARCHAR(255) column size limitation).
+ metadata.remove(ACCESS_CONFIG_METADATA_KEY);
+
+ try {
+ // Parse the unified accessConfig JSON: {"expose": [...], "allowedOperations": [...]}
+ Map accessConfigMap = OBJECT_MAPPER.readValue(accessConfigJson, MAP_TYPE_REF);
+
+ List expose = accessConfigMap.containsKey(EXPOSE_FIELD)
+ ? (List) accessConfigMap.get(EXPOSE_FIELD) : null;
+ List> allowedOps = accessConfigMap.containsKey(ALLOWED_OPERATIONS_FIELD)
+ ? (List>) accessConfigMap.get(ALLOWED_OPERATIONS_FIELD) : null;
+ AccessConfig overrideConfig = new AccessConfig(expose, allowedOps);
+
+ // Retrieve the existing action to merge overrides (preserving other flow types).
+ Action currentAction = actionMgtService.getActionByActionId(
+ Action.ActionTypes.IN_FLOW_EXTENSION.getPathParam(), actionId, tenantDomain);
+ if (!(currentAction instanceof InFlowExtensionAction)) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Action " + actionId + " is not an InFlowExtensionAction. Skipping override.");
+ }
+ return;
+ }
+
+ InFlowExtensionAction existingAction = (InFlowExtensionAction) currentAction;
+
+ // Merge: preserve other flow types, add/replace this one.
+ Map overrides = new HashMap<>(existingAction.getFlowTypeOverrides());
+ overrides.put(flowType, overrideConfig);
+
+ InFlowExtensionAction updatedAction = new InFlowExtensionAction.RequestBuilder(existingAction)
+ .accessConfig(existingAction.getAccessConfig())
+ .flowTypeOverrides(overrides)
+ .build();
+
+ actionMgtService.updateAction(
+ Action.ActionTypes.IN_FLOW_EXTENSION.getPathParam(),
+ actionId, updatedAction, tenantDomain);
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Updated access config override for action " + actionId
+ + " in flow type " + flowType + " and stripped from executor metadata.");
+ }
+
+ } catch (ActionMgtException e) {
+ throw new FlowMgtServerException("FLOW-60010",
+ "Error updating access config override.",
+ "Error updating access config override for action: " + actionId, e);
+ } catch (JsonProcessingException e) {
+ throw new FlowMgtServerException("FLOW-60011",
+ "Error parsing access config metadata.",
+ "Error parsing access config metadata for action: " + actionId, e);
+ }
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
new file mode 100644
index 000000000000..2dbdaae25e70
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.management;
+
+/**
+ * Constants for In-Flow Extension action management.
+ */
+public class InFlowExtensionActionConstants {
+
+ /**
+ * Property key for the expose path prefixes stored in IDN_ACTION_PROPERTIES.
+ * Stored as a BLOB (JSON-serialized list of strings).
+ */
+ public static final String ACCESS_CONFIG_EXPOSE = "ACCESS_CONFIG_EXPOSE";
+
+ /**
+ * Property key for the allowed operations stored in IDN_ACTION_PROPERTIES.
+ * Stored as a BLOB (JSON-serialized list of operation descriptors).
+ */
+ public static final String ACCESS_CONFIG_ALLOWED_OPERATIONS = "ACCESS_CONFIG_ALLOWED_OPERATIONS";
+
+ /**
+ * Separator used between property key and flow type in override keys.
+ * For example: "ACCESS_CONFIG_EXPOSE:REGISTRATION"
+ */
+ public static final String OVERRIDE_KEY_SEPARATOR = ":";
+
+ /**
+ * Prefix for flow-type-specific expose override properties.
+ * Full key format: "ACCESS_CONFIG_EXPOSE:<FLOW_TYPE>"
+ */
+ public static final String ACCESS_CONFIG_EXPOSE_PREFIX = ACCESS_CONFIG_EXPOSE + OVERRIDE_KEY_SEPARATOR;
+
+ /**
+ * Prefix for flow-type-specific allowed operations override properties.
+ * Full key format: "ACCESS_CONFIG_ALLOWED_OPERATIONS:<FLOW_TYPE>"
+ */
+ public static final String ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX =
+ ACCESS_CONFIG_ALLOWED_OPERATIONS + OVERRIDE_KEY_SEPARATOR;
+
+ /**
+ * Metadata key used in executor metadata (flow configuration) to pass a unified access config
+ * object containing both {@code expose} and {@code allowedOperations}.
+ *
+ * The value is a JSON object: {@code {"expose": [...], "allowedOperations": [...]}}
+ *
+ *
+ * This key is extracted by the {@code AccessConfigFlowUpdateInterceptor} during flow updates,
+ * stored as per-flow-type action properties, and stripped from executor metadata before
+ * the flow graph is persisted (to avoid VARCHAR(255) column size limitations).
+ *
+ */
+ public static final String ACCESS_CONFIG_METADATA_KEY = "accessConfig";
+
+ /**
+ * Maximum number of expose path prefixes allowed per action.
+ */
+ public static final int MAX_EXPOSE_PATHS = 50;
+
+ private InFlowExtensionActionConstants() {
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
new file mode 100644
index 000000000000..fea0f774718d
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.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.flow.execution.engine.inflow.extension.model.AccessConfig;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
+
+/**
+ * ActionConverter implementation for In-Flow Extension actions.
+ *
+ * Handles the conversion between {@link InFlowExtensionAction} (domain model) and
+ * {@link ActionDTO} (data transfer object) by mapping the {@link AccessConfig} fields
+ * to/from action properties.
+ *
+ */
+public class InFlowExtensionActionConverter implements ActionConverter {
+
+ @Override
+ public Action.ActionTypes getSupportedActionType() {
+
+ return Action.ActionTypes.IN_FLOW_EXTENSION;
+ }
+
+ /**
+ * Converts an {@link InFlowExtensionAction} to an {@link ActionDTO} for persistence.
+ * Maps the access config fields (expose, allowedOperations) and flow type overrides
+ * into the DTO's properties map using prefixed keys.
+ *
+ * @param action The InFlowExtensionAction to convert.
+ * @return ActionDTO with access config properties.
+ */
+ @Override
+ public ActionDTO buildActionDTO(Action action) {
+
+ if (!(action instanceof InFlowExtensionAction)) {
+ return new ActionDTO.Builder(action).build();
+ }
+
+ InFlowExtensionAction inFlowExtensionAction = (InFlowExtensionAction) action;
+ AccessConfig accessConfig = inFlowExtensionAction.getAccessConfig();
+
+ Map properties = new HashMap<>();
+ // Default access config (no prefix).
+ if (accessConfig != null) {
+ if (accessConfig.getExpose() != null) {
+ properties.put(ACCESS_CONFIG_EXPOSE,
+ new ActionProperty.BuilderForService(accessConfig.getExpose()).build());
+ }
+ if (accessConfig.getAllowedOperations() != null) {
+ properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS,
+ new ActionProperty.BuilderForService(accessConfig.getAllowedOperations()).build());
+ }
+ }
+
+ // Per-flow-type overrides using prefixed keys.
+ Map overrides = inFlowExtensionAction.getFlowTypeOverrides();
+ if (overrides != null) {
+ 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.getAllowedOperations() != null) {
+ properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX + flowType,
+ new ActionProperty.BuilderForService(overrideConfig.getAllowedOperations()).build());
+ }
+ }
+ }
+
+ return new ActionDTO.Builder(inFlowExtensionAction)
+ .properties(properties)
+ .build();
+ }
+
+ /**
+ * Converts an {@link ActionDTO} back to an {@link InFlowExtensionAction}.
+ * Reconstructs the default {@link AccessConfig} and per-flow-type overrides from the DTO's properties map.
+ *
+ * @param actionDTO The ActionDTO to convert.
+ * @return InFlowExtensionAction with access config and overrides populated.
+ */
+ @SuppressWarnings("unchecked")
+ @Override
+ public Action buildAction(ActionDTO actionDTO) {
+
+ // Default access config.
+ List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
+ List> allowedOperations =
+ (List>) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+
+ AccessConfig accessConfig = null;
+ if (expose != null || allowedOperations != null) {
+ accessConfig = new AccessConfig(expose, allowedOperations);
+ }
+
+ // 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.getAllowedOperations()));
+ } else if (propertyKey.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
+ String flowType = propertyKey.substring(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX.length());
+ AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, new AccessConfig(null, null));
+ flowTypeOverrides.put(flowType, new AccessConfig(
+ existing.getExpose(),
+ (List>) actionDTO.getPropertyValue(propertyKey)));
+ }
+ }
+ }
+
+ return new InFlowExtensionAction.ResponseBuilder()
+ .id(actionDTO.getId())
+ .type(actionDTO.getType())
+ .name(actionDTO.getName())
+ .description(actionDTO.getDescription())
+ .status(actionDTO.getStatus())
+ .actionVersion(actionDTO.getActionVersion())
+ .createdAt(actionDTO.getCreatedAt())
+ .updatedAt(actionDTO.getUpdatedAt())
+ .endpoint(actionDTO.getEndpoint())
+ .accessConfig(accessConfig)
+ .flowTypeOverrides(flowTypeOverrides)
+ .rule(actionDTO.getActionRule())
+ .build();
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
new file mode 100644
index 000000000000..ba420ce5af44
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.management;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverClientException;
+import org.wso2.carbon.identity.action.management.api.exception.ActionDTOModelResolverException;
+import org.wso2.carbon.identity.action.management.api.model.Action;
+import org.wso2.carbon.identity.action.management.api.model.ActionDTO;
+import org.wso2.carbon.identity.action.management.api.model.ActionProperty;
+import org.wso2.carbon.identity.action.management.api.model.BinaryObject;
+import org.wso2.carbon.identity.action.management.api.service.ActionDTOModelResolver;
+
+import 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.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.MAX_EXPOSE_PATHS;
+
+/**
+ * ActionDTOModelResolver implementation for In-Flow Extension actions.
+ *
+ * Responsible for validating and transforming access config properties (expose paths and
+ * allowed operations) between the service layer representation and the DAO layer BLOB format.
+ *
+ *
+ *
+ *
Add operation: Validates expose paths and allowed operations, then serializes
+ * them to JSON {@link BinaryObject}s for BLOB storage in IDN_ACTION_PROPERTIES.
+ *
Get operation: Deserializes BLOBs back to service-layer list objects.
Delete operation: No-op (properties are cascade-deleted with the action).
+ *
+ */
+public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelResolver {
+
+ private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final TypeReference> STRING_LIST_TYPE_REF =
+ new TypeReference>() { };
+ private static final TypeReference>> MAP_LIST_TYPE_REF =
+ new TypeReference>>() { };
+
+ @Override
+ public Action.ActionTypes getSupportedActionType() {
+
+ return Action.ActionTypes.IN_FLOW_EXTENSION;
+ }
+
+ @Override
+ public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain)
+ throws ActionDTOModelResolverException {
+
+ Map properties = new HashMap<>();
+
+ Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
+ // Expose is an optional field.
+ if (exposeValue != null) {
+ List validatedExpose = validateExpose(exposeValue);
+ properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose));
+ }
+
+ Object allowedOpsValue = actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ // Allowed operations is an optional field.
+ if (allowedOpsValue != null) {
+ List> validatedOps = validateAllowedOperations(allowedOpsValue);
+ properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(validatedOps));
+ }
+
+ // Handle 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)) {
+ Object overrideExpose = actionDTO.getPropertyValue(key);
+ if (overrideExpose != null) {
+ List validatedOverrideExpose = validateExpose(overrideExpose);
+ properties.put(key, createBlobProperty(validatedOverrideExpose));
+ }
+ } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
+ Object overrideOps = actionDTO.getPropertyValue(key);
+ if (overrideOps != null) {
+ List> validatedOverrideOps = validateAllowedOperations(overrideOps);
+ properties.put(key, createBlobProperty(validatedOverrideOps));
+ }
+ }
+ }
+ }
+
+ return new ActionDTO.Builder(actionDTO)
+ .properties(properties)
+ .build();
+ }
+
+ @Override
+ public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain)
+ throws ActionDTOModelResolverException {
+
+ Map properties = new HashMap<>();
+
+ // Default access config properties.
+ if (actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) {
+ properties.put(ACCESS_CONFIG_EXPOSE, deserializeExposeProperty(
+ ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)).getJSONString()));
+ }
+
+ if (actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
+ properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, deserializeAllowedOpsProperty(
+ ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS)).getJSONString()));
+ }
+
+ // 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)
+ && actionDTO.getPropertyValue(key) != null) {
+ properties.put(key, deserializeExposeProperty(
+ ((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString()));
+ } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)
+ && actionDTO.getPropertyValue(key) != null) {
+ properties.put(key, deserializeAllowedOpsProperty(
+ ((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString()));
+ }
+ }
+ }
+
+ return new ActionDTO.Builder(actionDTO)
+ .properties(properties)
+ .build();
+ }
+
+ @Override
+ public List resolveForGetOperation(List actionDTOList, String tenantDomain)
+ throws ActionDTOModelResolverException {
+
+ List resolvedList = new ArrayList<>();
+ for (ActionDTO actionDTO : actionDTOList) {
+ resolvedList.add(resolveForGetOperation(actionDTO, tenantDomain));
+ }
+ return resolvedList;
+ }
+
+ /**
+ * Resolves the actionDTO for the update operation.
+ * When properties are updated, the existing properties are replaced with the new properties.
+ * When properties are not updated, the existing properties should be sent to the upstream component.
+ *
+ * @param updatingActionDTO ActionDTO that needs to be updated.
+ * @param existingActionDTO Existing ActionDTO.
+ * @param tenantDomain Tenant domain.
+ * @return Resolved ActionDTO.
+ * @throws ActionDTOModelResolverException ActionDTOModelResolverException.
+ */
+ @Override
+ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDTO existingActionDTO,
+ String tenantDomain) throws ActionDTOModelResolverException {
+
+ Map properties = new HashMap<>();
+
+ // Action Properties updating operation is treated as a PUT in DAO layer. Therefore if no properties are
+ // updated the existing properties should be sent to the DAO layer.
+
+ // Handle default access config properties.
+ List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO);
+ if (!expose.isEmpty()) {
+ properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose));
+ }
+
+ List> allowedOps =
+ getResolvedUpdatingAllowedOps(updatingActionDTO, existingActionDTO);
+ if (!allowedOps.isEmpty()) {
+ properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(allowedOps));
+ }
+
+ // Handle per-flow-type override properties. Since DAO treats update as PUT (full replace),
+ // we must carry forward all existing overrides that are not explicitly being updated.
+ // First, carry forward all existing per-flow-type overrides.
+ if (existingActionDTO.getProperties() != null) {
+ for (Map.Entry entry : existingActionDTO.getProperties().entrySet()) {
+ String key = entry.getKey();
+ if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)
+ || key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
+ properties.put(key, createBlobProperty(existingActionDTO.getPropertyValue(key)));
+ }
+ }
+ }
+ // Then, overlay with any explicitly updated per-flow-type overrides.
+ if (updatingActionDTO.getProperties() != null) {
+ 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) {
+ List validatedOverrideExpose = validateExpose(overrideExpose);
+ properties.put(key, createBlobProperty(validatedOverrideExpose));
+ }
+ } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
+ Object overrideOps = updatingActionDTO.getPropertyValue(key);
+ if (overrideOps != null) {
+ List> validatedOverrideOps = validateAllowedOperations(overrideOps);
+ properties.put(key, createBlobProperty(validatedOverrideOps));
+ }
+ }
+ }
+ }
+
+ return new ActionDTO.Builder(updatingActionDTO)
+ .properties(properties)
+ .build();
+ }
+
+ @Override
+ public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenantDomain)
+ throws ActionDTOModelResolverException {
+ // No-op: properties are cascade-deleted with the action in IDN_ACTION_PROPERTIES.
+ }
+
+ // ---- Update helpers ----
+
+ @SuppressWarnings("unchecked")
+ private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO)
+ throws ActionDTOModelResolverException {
+
+ if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) {
+ return validateExpose(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE));
+ } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE) != null) {
+ return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
+ }
+ return emptyList();
+ }
+
+ @SuppressWarnings("unchecked")
+ private List> getResolvedUpdatingAllowedOps(ActionDTO updatingActionDTO,
+ ActionDTO existingActionDTO)
+ throws ActionDTOModelResolverException {
+
+ if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
+ return validateAllowedOperations(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS));
+ } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
+ return (List>) existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ }
+ return emptyList();
+ }
+
+ // ---- Validation ----
+
+ @SuppressWarnings("unchecked")
+ private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException {
+
+ if (!(exposeValue instanceof List>)) {
+ throw new ActionDTOModelResolverClientException("Invalid expose format.",
+ "Expose should be provided as a list of strings.");
+ }
+
+ List> exposeList = (List>) exposeValue;
+ for (Object item : exposeList) {
+ if (!(item instanceof String)) {
+ throw new ActionDTOModelResolverClientException("Invalid expose format.",
+ "Expose must contain only string values.");
+ }
+ }
+
+ List expose = (List) exposeValue;
+ validateExposeCount(expose);
+ validateExposePathFormat(expose);
+ return expose;
+ }
+
+ 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 validateExposePathFormat(List expose) throws ActionDTOModelResolverClientException {
+
+ Set seen = new HashSet<>();
+ for (String path : expose) {
+ 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 (!seen.add(path)) {
+ throw new ActionDTOModelResolverClientException("Duplicate expose path.",
+ String.format("The expose path '%s' is duplicated.", path));
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private List> validateAllowedOperations(Object allowedOpsValue)
+ throws ActionDTOModelResolverClientException {
+
+ if (!(allowedOpsValue instanceof List>)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operations format.",
+ "Allowed operations should be provided as a list.");
+ }
+
+ List> opsList = (List>) allowedOpsValue;
+ for (Object item : opsList) {
+ if (!(item instanceof Map)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operations format.",
+ "Each allowed operation must be an object with 'op' and 'paths' keys.");
+ }
+ Map, ?> opMap = (Map, ?>) item;
+ if (!opMap.containsKey("op") || !(opMap.get("op") instanceof String)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
+ "Each allowed operation must contain an 'op' field with a string value.");
+ }
+ if (!opMap.containsKey("paths") || !(opMap.get("paths") instanceof List)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
+ "Each allowed operation must contain a 'paths' field with a list of strings.");
+ }
+ }
+
+ return (List>) allowedOpsValue;
+ }
+
+ // ---- Serialization helpers ----
+
+ private ActionProperty createBlobProperty(Object value) throws ActionDTOModelResolverException {
+
+ try {
+ BinaryObject binaryObject = BinaryObject.fromJsonString(OBJECT_MAPPER.writeValueAsString(value));
+ return new ActionProperty.BuilderForDAO(binaryObject).build();
+ } catch (JsonProcessingException e) {
+ throw new ActionDTOModelResolverException("Failed to serialize access config property to JSON.", e);
+ }
+ }
+
+ private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException {
+
+ try {
+ List expose = OBJECT_MAPPER.readValue(jsonString, STRING_LIST_TYPE_REF);
+ return new ActionProperty.BuilderForService(expose).build();
+ } catch (IOException e) {
+ throw new ActionDTOModelResolverException("Error reading expose values from storage.", e);
+ }
+ }
+
+ private ActionProperty deserializeAllowedOpsProperty(String jsonString)
+ throws ActionDTOModelResolverException {
+
+ try {
+ List> allowedOps = OBJECT_MAPPER.readValue(jsonString, MAP_LIST_TYPE_REF);
+ return new ActionProperty.BuilderForService(allowedOps).build();
+ } catch (IOException e) {
+ throw new ActionDTOModelResolverException("Error reading allowed operations from storage.", e);
+ }
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
new file mode 100644
index 000000000000..77164398e91d
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Access Configuration for In-Flow Extension actions.
+ *
+ * Defines which parts of the flow context are exposed to the external service
+ * and what operations the service is allowed to perform.
+ *
+ *
+ *
+ *
{@code expose} – hierarchical path prefixes that control which context data is sent
+ * in the action execution request (e.g. {@code "/user/claims/"}, {@code "/flow/properties/"}).
+ *
{@code allowedOperations} – a structured list of operation descriptors, where each entry
+ * specifies an operation type ({@code "op"}) and the paths it applies to ({@code "paths"}).
+ *
+ */
+public class AccessConfig {
+
+ private final List expose;
+ private final List> allowedOperations;
+
+ /**
+ * Constructs an AccessConfig with the given expose paths and allowed operations.
+ *
+ * @param expose List of hierarchical path prefixes to expose. May be {@code null}.
+ * @param allowedOperations List of operation descriptors. May be {@code null}.
+ */
+ public AccessConfig(List expose, List> allowedOperations) {
+
+ this.expose = expose != null ? Collections.unmodifiableList(expose) : null;
+ this.allowedOperations = allowedOperations != null ? Collections.unmodifiableList(allowedOperations) : null;
+ }
+
+ /**
+ * Returns the list of hierarchical path prefixes that define what context data is exposed.
+ *
+ * @return Unmodifiable list of expose path prefixes, or {@code null} if not configured.
+ */
+ public List getExpose() {
+
+ return expose;
+ }
+
+ /**
+ * Returns the list of allowed operation descriptors.
+ * Each entry is a map with at least {@code "op"} (operation type) and {@code "paths"} keys.
+ *
+ * @return Unmodifiable list of operation descriptors, or {@code null} if not configured.
+ */
+ public List> getAllowedOperations() {
+
+ return allowedOperations;
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java
new file mode 100644
index 000000000000..2abf96831962
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
+
+import org.wso2.carbon.identity.action.management.api.model.Action;
+import org.wso2.carbon.identity.action.management.api.model.EndpointConfig;
+
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * In-Flow Extension Action.
+ *
+ * Extends the base {@link Action} with an {@link AccessConfig} that defines what flow context data
+ * is exposed to the external service and which operations are allowed.
+ *
+ */
+public class InFlowExtensionAction extends Action {
+
+ private final AccessConfig accessConfig;
+ private final Map flowTypeOverrides;
+
+ public InFlowExtensionAction(ResponseBuilder responseBuilder) {
+
+ super(responseBuilder);
+ this.accessConfig = responseBuilder.accessConfig;
+ this.flowTypeOverrides = responseBuilder.flowTypeOverrides != null
+ ? Collections.unmodifiableMap(new HashMap<>(responseBuilder.flowTypeOverrides))
+ : Collections.emptyMap();
+ }
+
+ public InFlowExtensionAction(RequestBuilder requestBuilder) {
+
+ super(requestBuilder);
+ this.accessConfig = requestBuilder.accessConfig;
+ this.flowTypeOverrides = requestBuilder.flowTypeOverrides != null
+ ? Collections.unmodifiableMap(new HashMap<>(requestBuilder.flowTypeOverrides))
+ : Collections.emptyMap();
+ }
+
+ /**
+ * Returns the default access configuration for this In-Flow Extension action.
+ *
+ * @return The access config, or {@code null} if not configured.
+ */
+ public AccessConfig getAccessConfig() {
+
+ return accessConfig;
+ }
+
+ /**
+ * Returns the per-flow-type access config overrides.
+ * Keys are flow type strings (e.g., "REGISTRATION", "LOGIN").
+ *
+ * @return Unmodifiable map of flow type to AccessConfig overrides.
+ */
+ public Map getFlowTypeOverrides() {
+
+ return flowTypeOverrides;
+ }
+
+ /**
+ * Resolves the effective access config for the given flow type.
+ * Returns the flow-type-specific override if present, otherwise falls back to the default access config.
+ *
+ * @param flowType The flow type (e.g., "REGISTRATION").
+ * @return The resolved AccessConfig, or {@code null} if neither override nor default is configured.
+ */
+ public AccessConfig resolveAccessConfig(String flowType) {
+
+ if (flowType != null && flowTypeOverrides.containsKey(flowType)) {
+ return flowTypeOverrides.get(flowType);
+ }
+ return accessConfig;
+ }
+
+ /**
+ * Response Builder for InFlowExtensionAction.
+ * Used when building from persisted data (DAO → service layer).
+ */
+ public static class ResponseBuilder extends ActionResponseBuilder {
+
+ private AccessConfig accessConfig;
+ private Map flowTypeOverrides;
+
+ @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 flowTypeOverrides(Map flowTypeOverrides) {
+
+ this.flowTypeOverrides = flowTypeOverrides;
+ return this;
+ }
+
+ @Override
+ public InFlowExtensionAction build() {
+
+ return new InFlowExtensionAction(this);
+ }
+ }
+
+ /**
+ * Request Builder for InFlowExtensionAction.
+ * Used when building from REST API request (API → service layer).
+ */
+ public static class RequestBuilder extends ActionRequestBuilder {
+
+ private AccessConfig accessConfig;
+ private Map flowTypeOverrides;
+
+ 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 flowTypeOverrides(Map flowTypeOverrides) {
+
+ this.flowTypeOverrides = flowTypeOverrides;
+ return this;
+ }
+
+ @Override
+ public InFlowExtensionAction build() {
+
+ return new InFlowExtensionAction(this);
+ }
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
index ed14a2f04d39..323458a451b6 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
@@ -19,6 +19,7 @@
package org.wso2.carbon.identity.flow.execution.engine.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.application.mgt.ApplicationManagementService;
import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService;
import org.wso2.carbon.identity.event.services.IdentityEventService;
@@ -49,6 +50,7 @@ public class FlowExecutionEngineDataHolder {
private FederatedAssociationManager federatedAssociationManager;
private IdentityEventService identityEventService;
private ActionExecutorService actionExecutorService;
+ private ActionManagementService actionManagementService;
private List flowExecutionListeners = new ArrayList<>();
private FlowExecutionEngineDataHolder() {
@@ -240,4 +242,24 @@ public void setActionExecutorService(ActionExecutorService actionExecutorService
this.actionExecutorService = actionExecutorService;
}
+
+ /**
+ * Get the ActionManagementService instance.
+ *
+ * @return ActionManagementService instance.
+ */
+ public ActionManagementService getActionManagementService() {
+
+ return actionManagementService;
+ }
+
+ /**
+ * Set the ActionManagementService instance.
+ *
+ * @param actionManagementService ActionManagementService instance.
+ */
+ public void setActionManagementService(ActionManagementService actionManagementService) {
+
+ this.actionManagementService = actionManagementService;
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
index 59521b9b6739..ed25143e86e7 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
@@ -31,6 +31,9 @@
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.application.mgt.ApplicationManagementService;
import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService;
import org.wso2.carbon.identity.event.services.IdentityEventService;
@@ -39,9 +42,13 @@
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor.InFlowExtensionExecutor;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor.InFlowExtensionRequestBuilder;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor.InFlowExtensionResponseProcessor;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.AccessConfigFlowUpdateInterceptor;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConverter;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionDTOModelResolver;
import org.wso2.carbon.identity.flow.execution.engine.listener.FlowExecutionListener;
import org.wso2.carbon.identity.flow.execution.engine.validation.InputProcessingListener;
import org.wso2.carbon.identity.flow.mgt.FlowMgtService;
+import org.wso2.carbon.identity.flow.mgt.FlowUpdateInterceptor;
import org.wso2.carbon.identity.input.validation.mgt.services.InputValidationManagementService;
import org.wso2.carbon.identity.user.profile.mgt.association.federation.FederatedAssociationManager;
import org.wso2.carbon.user.core.service.RealmService;
@@ -93,6 +100,16 @@ protected void activate(ComponentContext context) {
bundleContext.registerService(ActionExecutionResponseProcessor.class.getName(),
new InFlowExtensionResponseProcessor(), null);
+ // Register In-Flow Extension action management services
+ bundleContext.registerService(ActionConverter.class.getName(),
+ new InFlowExtensionActionConverter(), null);
+ bundleContext.registerService(ActionDTOModelResolver.class.getName(),
+ new InFlowExtensionActionDTOModelResolver(), null);
+
+ // Register flow update interceptor for access config overrides
+ bundleContext.registerService(FlowUpdateInterceptor.class.getName(),
+ new AccessConfigFlowUpdateInterceptor(), null);
+
LOG.debug("Flow Engine service successfully activated.");
} catch (Throwable e) {
LOG.error("Error while initiating Flow Engine service", e);
@@ -237,6 +254,25 @@ public void unsetFederatedAssociationManager(FederatedAssociationManager federat
FlowExecutionEngineDataHolder.getInstance().setFederatedAssociationManager(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 Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance().setActionExecutorService(actionExecutorService);
+ }
+
+ protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) {
+
+ LOG.debug("Unsetting the ActionExecutorService in the Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance().setActionExecutorService(null);
+ }
+
@Reference(
name = "ClaimMetadataManagementService",
service = ClaimMetadataManagementService.class,
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowMgtService.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowMgtService.java
index 50e370f5e1ca..b1c2ea36c13b 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowMgtService.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowMgtService.java
@@ -91,7 +91,17 @@ public void updateFlow(FlowDTO flowDTO, int tenantID)
clearFlowResolveCache(flowDTO.getFlowType(), tenantID);
GraphConfig flowConfig = new GraphBuilder().withSteps(flowDTO.getSteps()).build();
+
+ // Notify interceptors BEFORE persistence. Interceptors may extract and strip
+ // metadata from the graph (e.g., access config overrides) so that the stripped
+ // data is NOT persisted to executor metadata but is stored elsewhere.
+ for (FlowUpdateInterceptor interceptor :
+ FlowMgtServiceDataHolder.getInstance().getFlowUpdateInterceptors()) {
+ interceptor.onFlowUpdate(flowDTO.getFlowType(), flowConfig, tenantID);
+ }
+
FLOW_DAO.updateFlow(flowDTO.getFlowType(), flowConfig, tenantID, DEFAULT_FLOW_NAME);
+
AuditLog.AuditLogBuilder auditLogBuilder =
new AuditLog.AuditLogBuilder(getInitiatorId(), LoggerUtils.getInitiatorType(getInitiatorId()),
flowConfig.getId(),
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowUpdateInterceptor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowUpdateInterceptor.java
new file mode 100644
index 000000000000..a418cdf2bbc4
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/FlowUpdateInterceptor.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2025, 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.mgt;
+
+import org.wso2.carbon.identity.flow.mgt.exception.FlowMgtFrameworkException;
+import org.wso2.carbon.identity.flow.mgt.model.GraphConfig;
+
+/**
+ * Interceptor interface for flow update operations.
+ *
+ * Implementations can hook into the flow update lifecycle to perform additional
+ * processing when a flow is updated. Interceptors are invoked before the
+ * graph is persisted to the database, allowing them to:
+ *
+ *
Extract data from executor metadata for external storage (e.g., access config overrides
+ * stored as action properties instead of executor metadata).
+ *
Strip extracted keys from executor metadata so they are not persisted to the
+ * flow executor metadata table (e.g., to avoid column size limitations).
+ *
+ *
+ */
+public interface FlowUpdateInterceptor {
+
+ /**
+ * Called after the flow graph has been built but before it is persisted to the database.
+ * Implementations may modify the {@code graphConfig} (e.g., stripping metadata keys) and the
+ * modifications will be reflected in the persisted graph.
+ *
+ * @param flowType The flow type (e.g., "REGISTRATION", "LOGIN").
+ * @param graphConfig The built graph configuration containing node and executor details.
+ * Mutable — interceptors may modify executor metadata.
+ * @param tenantId The tenant ID.
+ * @throws FlowMgtFrameworkException If an error occurs during interception.
+ */
+ void onFlowUpdate(String flowType, GraphConfig graphConfig, int tenantId) throws FlowMgtFrameworkException;
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceComponent.java
index d10eaa7b501d..d1cfbfc71df0 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceComponent.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceComponent.java
@@ -32,6 +32,7 @@
import org.wso2.carbon.identity.configuration.mgt.core.ConfigurationManager;
import org.wso2.carbon.identity.flow.mgt.FlowAIService;
import org.wso2.carbon.identity.flow.mgt.FlowMgtService;
+import org.wso2.carbon.identity.flow.mgt.FlowUpdateInterceptor;
import org.wso2.carbon.identity.organization.management.service.OrganizationManager;
import org.wso2.carbon.identity.organization.resource.hierarchy.traverse.service.OrgResourceResolverService;
@@ -130,4 +131,20 @@ protected void unsetCompatibilitySettingsManager(CompatibilitySettingsManager co
FlowMgtServiceDataHolder.getInstance().setCompatibilitySettingsManager(null);
}
+
+ @Reference(
+ name = "flow.update.interceptor",
+ service = FlowUpdateInterceptor.class,
+ cardinality = ReferenceCardinality.MULTIPLE,
+ policy = ReferencePolicy.DYNAMIC,
+ unbind = "unsetFlowUpdateInterceptor")
+ protected void setFlowUpdateInterceptor(FlowUpdateInterceptor interceptor) {
+
+ FlowMgtServiceDataHolder.getInstance().addFlowUpdateInterceptor(interceptor);
+ }
+
+ protected void unsetFlowUpdateInterceptor(FlowUpdateInterceptor interceptor) {
+
+ FlowMgtServiceDataHolder.getInstance().removeFlowUpdateInterceptor(interceptor);
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceDataHolder.java
index 22109cfcf196..7e21dde44e80 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceDataHolder.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.mgt/src/main/java/org/wso2/carbon/identity/flow/mgt/internal/FlowMgtServiceDataHolder.java
@@ -20,9 +20,14 @@
import org.wso2.carbon.identity.compatibility.settings.core.CompatibilitySettingsManager;
import org.wso2.carbon.identity.configuration.mgt.core.ConfigurationManager;
+import org.wso2.carbon.identity.flow.mgt.FlowUpdateInterceptor;
import org.wso2.carbon.identity.organization.management.service.OrganizationManager;
import org.wso2.carbon.identity.organization.resource.hierarchy.traverse.service.OrgResourceResolverService;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
/**
* A singleton class to hold the data of the flow management service.
*/
@@ -32,6 +37,7 @@ public class FlowMgtServiceDataHolder {
private OrgResourceResolverService orgResourceResolverService;
private ConfigurationManager configurationManager;
private CompatibilitySettingsManager compatibilitySettingsManager;
+ private final List flowUpdateInterceptors = new ArrayList<>();
private static final FlowMgtServiceDataHolder INSTANCE = new FlowMgtServiceDataHolder();
@@ -83,4 +89,19 @@ public void setCompatibilitySettingsManager(CompatibilitySettingsManager compati
this.compatibilitySettingsManager = compatibilitySettingsManager;
}
+
+ public List getFlowUpdateInterceptors() {
+
+ return Collections.unmodifiableList(flowUpdateInterceptors);
+ }
+
+ public void addFlowUpdateInterceptor(FlowUpdateInterceptor interceptor) {
+
+ flowUpdateInterceptors.add(interceptor);
+ }
+
+ public void removeFlowUpdateInterceptor(FlowUpdateInterceptor interceptor) {
+
+ flowUpdateInterceptors.remove(interceptor);
+ }
}
From 8dcf6eb7999e7d2dcc424cca82755e8d0e022293 Mon Sep 17 00:00:00 2001
From: ThejithaR
Date: Wed, 11 Mar 2026 11:23:20 +0530
Subject: [PATCH 07/41] Enhance In-Flow Extension Model with Encryption and
Path Management
- Hierarchical paths with optional encryption.
- Created Encryption class to hold external service's X.509 public certificate for outbound JWE encryption.
- Updated InFlowExtensionAction to include encryption configuration.
- Enhanced FlowExecutionEngineDataHolder to manage CertificateManagementService.
- Registered CertificateManagementService in FlowExecutionEngineServiceComponent.
- Added support for ACTIONS inbound protocol in IdentityKeyStoreResolverConstants.
---
.../pom.xml | 10 +
.../executor/InFlowExtensionExecutor.java | 53 ++-
.../InFlowExtensionRequestBuilder.java | 287 ++++++++++-----
.../InFlowExtensionResponseProcessor.java | 66 ++++
.../extension/executor/JWEEncryptionUtil.java | 234 +++++++++++++
.../AccessConfigFlowUpdateInterceptor.java | 109 +++++-
.../InFlowExtensionActionConstants.java | 13 +
.../InFlowExtensionActionConverter.java | 36 +-
...InFlowExtensionActionDTOModelResolver.java | 330 +++++++++++++++---
.../inflow/extension/model/AccessConfig.java | 136 +++++++-
.../extension/model/AllowedOperation.java | 87 +++++
.../inflow/extension/model/Encryption.java | 60 ++++
.../inflow/extension/model/ExposePath.java | 64 ++++
.../model/InFlowExtensionAction.java | 27 ++
.../inflow/extension/model/OperationPath.java | 70 ++++
.../FlowExecutionEngineDataHolder.java | 22 ++
.../FlowExecutionEngineServiceComponent.java | 64 ++--
.../IdentityKeyStoreResolverConstants.java | 6 +-
18 files changed, 1477 insertions(+), 197 deletions(-)
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/Encryption.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
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 178d4f046935..952d0b0db8bd 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
@@ -115,6 +115,10 @@
org.wso2.carbon.identity.frameworkorg.wso2.carbon.identity.user.action
+
+ org.wso2.carbon.identity.framework
+ org.wso2.carbon.identity.certificate.management
+
@@ -184,6 +188,12 @@
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}",
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
index 5b238cc749fb..39a4d520bc1e 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
@@ -38,6 +38,7 @@
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AccessConfig;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
import org.wso2.carbon.identity.flow.execution.engine.model.ExecutorResponse;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.Encryption;
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;
@@ -66,10 +67,6 @@
* Context updates are already applied directly to the {@link FlowExecutionContext}
* by the response processor.
*
- *
- *
Note: Access config is never read from executor metadata. It is stored exclusively
- * in action properties (default and per-flow-type overrides in {@code IDN_ACTION_PROPERTIES}).
- * Executor metadata only provides the {@code actionId}.
*/
public class InFlowExtensionExecutor implements Executor {
@@ -81,6 +78,8 @@ public class InFlowExtensionExecutor implements Executor {
public static final String EXPOSE_KEY = "expose";
public static final String ALLOWED_OPERATIONS_KEY = "allowedOperations";
public static final String PATH_TYPE_ANNOTATIONS_KEY = "pathTypeAnnotations";
+ public static final String ACCESS_CONFIG_KEY = "accessConfig";
+ public static final String ENCRYPTION_KEY = "encryption";
private static final String ACTION_ID_METADATA_KEY = "actionId";
@Override
@@ -106,15 +105,14 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE
LOG.debug("Executing In-Flow Extension action with ID: " + actionId);
}
- // Resolve access config exclusively from the action (with flow-type override support).
- // Access config is NOT stored in executor metadata — it is stored in action properties.
AccessConfig resolvedConfig = resolveAccessConfigFromAction(actionId, context);
+ Encryption encryption = resolveEncryptionFromAction(actionId, context);
List expose;
String allowedOpsJson = null;
if (resolvedConfig != null && resolvedConfig.getExpose() != null) {
- expose = resolvedConfig.getExpose();
+ expose = resolvedConfig.getExposePaths();
} else {
// No access config on action — use system defaults.
expose = new ArrayList<>(HierarchicalPrefixMatcher.DEFAULT_EXPOSE);
@@ -136,6 +134,17 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE
flowContext.add(ALLOWED_OPERATIONS_KEY, allowedOpsJson);
}
+ // Pass the full AccessConfig so request builder and response processor can access
+ // per-path encryption flags for JWE encryption/decryption.
+ if (resolvedConfig != null) {
+ flowContext.add(ACCESS_CONFIG_KEY, resolvedConfig);
+ }
+
+ // Pass the Encryption config (certificate) separately.
+ if (encryption != null) {
+ flowContext.add(ENCRYPTION_KEY, encryption);
+ }
+
ActionExecutorService actionExecutorService = getActionExecutorService();
if (actionExecutorService == null) {
throw new FlowEngineException("ActionExecutorService is not available.");
@@ -291,7 +300,7 @@ private AccessConfig resolveAccessConfigFromAction(String actionId, FlowExecutio
try {
Action action = actionMgtService.getActionByActionId(
- Action.ActionTypes.IN_FLOW_EXTENSION.getActionType(),
+ Action.ActionTypes.IN_FLOW_EXTENSION.getPathParam(),
actionId, context.getTenantDomain());
if (action instanceof InFlowExtensionAction) {
@@ -306,6 +315,34 @@ private AccessConfig resolveAccessConfigFromAction(String actionId, FlowExecutio
return null;
}
+ /**
+ * Resolve the encryption configuration (certificate) from the action.
+ *
+ * @param actionId The action ID.
+ * @param context The flow execution context.
+ * @return The Encryption config, or {@code null} if none configured.
+ */
+ private Encryption resolveEncryptionFromAction(String actionId, FlowExecutionContext context) {
+
+ ActionManagementService actionMgtService = getActionManagementService();
+ if (actionMgtService == null) {
+ return null;
+ }
+
+ try {
+ Action action = actionMgtService.getActionByActionId(
+ Action.ActionTypes.IN_FLOW_EXTENSION.getPathParam(),
+ actionId, context.getTenantDomain());
+
+ if (action instanceof InFlowExtensionAction) {
+ return ((InFlowExtensionAction) action).getEncryption();
+ }
+ } catch (ActionMgtException e) {
+ LOG.error("Error retrieving encryption config for action " + actionId, e);
+ }
+ return null;
+ }
+
/**
* Read a single metadata value from the current node's executor configuration.
*
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
index f949217b9990..71f6a648c222 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
@@ -37,6 +37,8 @@
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.core.util.IdentityTenantUtil;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AccessConfig;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.Encryption;
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;
@@ -99,16 +101,31 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex
expose = HierarchicalPrefixMatcher.DEFAULT_EXPOSE;
}
+ // Read access config for encryption metadata.
+ AccessConfig accessConfig = flowContext.getValue(
+ InFlowExtensionExecutor.ACCESS_CONFIG_KEY, AccessConfig.class);
+
+ // Read encryption configuration (certificate) separately.
+ Encryption encryption = flowContext.getValue(
+ InFlowExtensionExecutor.ENCRYPTION_KEY, Encryption.class);
+
// Build allowed operations first so REPLACE paths can augment the expose list.
List allowedOperations = buildAllowedOperations(
flowContext.getValue(InFlowExtensionExecutor.ALLOWED_OPERATIONS_KEY, String.class),
- flowContext);
+ flowContext, accessConfig);
- // Augment expose with REPLACE paths so the external service can see the current values
- // it may replace.
- expose = augmentExposeWithReplacePaths(expose, allowedOperations);
+ // Augment expose with REPLACE and REMOVE paths so the external service can see
+ // the current values it may replace or remove.
+ expose = augmentExposeWithOperationPaths(expose, allowedOperations);
+
+ // Determine certificate PEM for outbound encryption (if any paths are encrypted).
+ String certificatePEM = null;
+ if (accessConfig != null && accessConfig.hasAnyEncryptedPath()
+ && encryption != null && encryption.getCertificate() != null) {
+ certificatePEM = encryption.getCertificate().getCertificateContent();
+ }
- InFlowExtensionEvent event = buildEvent(execCtx, expose);
+ InFlowExtensionEvent event = buildEvent(execCtx, expose, accessConfig, certificatePEM);
return new ActionExecutionRequest.Builder()
.actionType(ActionType.IN_FLOW_EXTENSION)
@@ -120,17 +137,23 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex
/**
* Parse the allowed-operations JSON into typed {@link AllowedOperation} objects.
- * Strips path type annotations (e.g., "[]", "[schema]") from paths before creating
- * AllowedOperation objects so that {@code OperationComparator} receives clean paths.
+ * Handles the nested format where each entry is {@code {op, paths: [{path, encrypted}]}}.
+ * Groups entries by operation type and strips path type annotations.
* The original annotations are stored in the FlowContext under
* {@link InFlowExtensionExecutor#PATH_TYPE_ANNOTATIONS_KEY} for the response processor.
+ *
+ * Encryption metadata is NOT extracted from the JSON. Instead, the {@link AccessConfig}
+ * (already in FlowContext) is the single source of truth for per-path encryption flags.
+ * The response processor uses {@code AccessConfig.isOperationPathEncrypted()} directly.
*
* @param allowedOperationsJson The JSON string (maybe null).
- * @param flowContext The FlowContext to store path annotations in.
+ * @param flowContext The FlowContext to store path annotations.
+ * @param accessConfig The access config (may be null). Reserved for future use.
* @return List of AllowedOperation objects with clean (annotation-free) paths.
*/
private List buildAllowedOperations(String allowedOperationsJson,
- FlowContext flowContext) {
+ FlowContext flowContext,
+ AccessConfig accessConfig) {
if (allowedOperationsJson == null || allowedOperationsJson.isEmpty()) {
LOG.debug("No allowed operations configured. Using empty list.");
@@ -142,14 +165,69 @@ private List buildAllowedOperations(String allowedOperationsJs
allowedOperationsJson, OPERATION_LIST_TYPE_REF);
// Map to store path type annotations: cleanPath -> annotation content.
- // "" means simple string array (from []), non-empty means schema (from [schema]).
Map pathTypeAnnotations = new HashMap<>();
- List allowedOperations = new ArrayList<>();
+ // Group entries by operation type, handling nested paths [{path, encrypted}].
+ Map> groupedByOp = new HashMap<>();
for (Map config : operationConfigs) {
- AllowedOperation operation = createAllowedOperationFromConfig(config, pathTypeAnnotations);
- if (operation != null) {
- allowedOperations.add(operation);
+ String opString = (String) config.get("op");
+ Object pathsObj = config.get("paths");
+
+ if (opString == null) {
+ LOG.warn("Invalid allowed operation config: missing 'op'.");
+ continue;
+ }
+ if (!(pathsObj instanceof List)) {
+ LOG.warn("Invalid allowed operation config: missing or invalid 'paths'.");
+ continue;
+ }
+
+ List> pathsList = (List>) pathsObj;
+ for (Object pathEntry : pathsList) {
+ String rawPath;
+
+ if (pathEntry instanceof Map) {
+ // Nested format: {path, encrypted} — only extract path here.
+ // Encryption metadata is resolved from AccessConfig at runtime.
+ Map, ?> pathMap = (Map, ?>) pathEntry;
+ rawPath = (String) pathMap.get("path");
+ } else if (pathEntry instanceof String) {
+ rawPath = (String) pathEntry;
+ } else {
+ LOG.warn("Invalid path entry in allowed operation.");
+ continue;
+ }
+
+ if (rawPath == null) {
+ continue;
+ }
+
+ // Strip annotations and accumulate paths into groups.
+ Matcher matcher = PATH_ANNOTATION_PATTERN.matcher(rawPath);
+ String cleanPath;
+ if (matcher.find()) {
+ cleanPath = rawPath.substring(0, matcher.start());
+ pathTypeAnnotations.put(cleanPath, matcher.group(1));
+ } else {
+ cleanPath = rawPath;
+ }
+
+ groupedByOp.computeIfAbsent(opString.toUpperCase(Locale.ENGLISH),
+ k -> new ArrayList<>()).add(cleanPath);
+ }
+ }
+
+ // Build AllowedOperation objects from grouped entries.
+ List allowedOperations = new ArrayList<>();
+ for (Map.Entry> entry : groupedByOp.entrySet()) {
+ try {
+ Operation operation = Operation.valueOf(entry.getKey());
+ AllowedOperation allowedOperation = new AllowedOperation();
+ allowedOperation.setOp(operation);
+ allowedOperation.setPaths(entry.getValue());
+ allowedOperations.add(allowedOperation);
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Unknown operation type: " + entry.getKey());
}
}
@@ -169,75 +247,20 @@ private List buildAllowedOperations(String allowedOperationsJs
}
}
- /**
- * Create an AllowedOperation from a configuration map.
- * Strips path type annotations and records them in the annotations map.
- *
- * @param config The raw configuration map from JSON.
- * @param pathTypeAnnotations Map to record extracted annotations (cleanPath → annotation).
- * @return The AllowedOperation with clean paths, or null if invalid.
- */
- @SuppressWarnings("unchecked")
- private AllowedOperation createAllowedOperationFromConfig(Map config,
- Map pathTypeAnnotations) {
-
- String opString = (String) config.get("op");
- Object pathsObj = config.get("paths");
-
- if (opString == null || pathsObj == null) {
- LOG.warn("Invalid allowed operation config: missing 'op' or 'paths'.");
- return null;
- }
-
- Operation operation;
- try {
- operation = Operation.valueOf(opString.toUpperCase(Locale.ENGLISH));
- } catch (IllegalArgumentException e) {
- LOG.warn("Unknown operation type: " + opString);
- return null;
- }
-
- List paths;
- if (pathsObj instanceof List) {
- paths = (List) pathsObj;
- } else {
- LOG.warn("Invalid 'paths' type in allowed operation config.");
- return null;
- }
-
- // Strip type annotations from paths and record them.
- List cleanPaths = new ArrayList<>();
- for (String rawPath : paths) {
- Matcher matcher = PATH_ANNOTATION_PATTERN.matcher(rawPath);
- if (matcher.find()) {
- String cleanPath = rawPath.substring(0, matcher.start());
- String annotationContent = matcher.group(1); // "" for [], or schema content for [schema]
- cleanPaths.add(cleanPath);
- pathTypeAnnotations.put(cleanPath, annotationContent);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Path annotation extracted: " + rawPath + " -> clean: " + cleanPath +
- ", annotation: [" + annotationContent + "]");
- }
- } else {
- cleanPaths.add(rawPath);
- }
- }
-
- AllowedOperation allowedOperation = new AllowedOperation();
- allowedOperation.setOp(operation);
- allowedOperation.setPaths(cleanPaths);
- return allowedOperation;
- }
-
/**
* Build the {@link InFlowExtensionEvent} from the {@link FlowExecutionContext},
* filtering data according to the expose configuration.
+ * Values for expose paths with {@code encrypted: true} are JWE-encrypted using the
+ * external service's certificate before being included in the event.
*
- * @param context The FlowExecutionContext (full source of truth).
- * @param expose The expose prefix list controlling which data is included.
+ * @param context The FlowExecutionContext (full source of truth).
+ * @param expose The expose prefix list controlling which data is included.
+ * @param accessConfig The access config (may be null if no encryption).
+ * @param certificatePEM The external service's certificate PEM for JWE encryption (may be null).
* @return The InFlowExtensionEvent.
*/
- private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose) {
+ private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose,
+ AccessConfig accessConfig, String certificatePEM) {
InFlowExtensionEvent.Builder eventBuilder = new InFlowExtensionEvent.Builder();
@@ -264,7 +287,7 @@ private InFlowExtensionEvent buildEvent(FlowExecutionContext context, List expose) {
+ private User buildUser(FlowUser flowUser, List expose,
+ AccessConfig accessConfig, String certificatePEM) {
String userId = isExposed(HierarchicalPrefixMatcher.USER_ID_PATH, expose)
? flowUser.getUserId() : null;
@@ -331,7 +358,13 @@ private User buildUser(FlowUser flowUser, List expose) {
for (Map.Entry claim : claims.entrySet()) {
if (!hasSpecificFilter || isExposed(
HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX + claim.getKey(), expose)) {
- userClaims.add(new UserClaim(claim.getKey(), claim.getValue()));
+ String claimValue = claim.getValue();
+ // Encrypt claim value if the expose path is marked as encrypted.
+ String claimPath = HierarchicalPrefixMatcher.USER_CLAIMS_PREFIX + claim.getKey();
+ if (shouldEncrypt(claimPath, accessConfig, certificatePEM)) {
+ claimValue = encryptValue(claimValue, certificatePEM);
+ }
+ userClaims.add(new UserClaim(claim.getKey(), claimValue));
}
}
if (!userClaims.isEmpty()) {
@@ -343,8 +376,20 @@ private User buildUser(FlowUser flowUser, List expose) {
if (isExposed(HierarchicalPrefixMatcher.USER_CREDENTIALS_PREFIX, expose)) {
Map credentials = flowUser.getUserCredentials();
if (credentials != null && !credentials.isEmpty()) {
-
- userBuilder.userCredentials(new HashMap<>(credentials));
+ // Check if credentials expose path is encrypted.
+ if (shouldEncrypt(HierarchicalPrefixMatcher.USER_CREDENTIALS_PREFIX, accessConfig,
+ certificatePEM)) {
+ // Encrypt each credential value and store as char[] of the JWE compact string.
+ Map encryptedCredentials = new HashMap<>();
+ for (Map.Entry entry : credentials.entrySet()) {
+ String plaintext = new String(entry.getValue());
+ String encrypted = encryptValue(plaintext, certificatePEM);
+ encryptedCredentials.put(entry.getKey(), encrypted.toCharArray());
+ }
+ userBuilder.userCredentials(encryptedCredentials);
+ } else {
+ userBuilder.userCredentials(new HashMap<>(credentials));
+ }
}
}
@@ -404,41 +449,91 @@ private boolean hasSpecificSubPathFilter(List expose, String areaPrefix)
}
/**
- * Augment the expose list with paths from REPLACE operations.
- * REPLACE operations require the current values to be visible to the external service,
- * so their paths must be exposed even if not explicitly in the expose configuration.
+ * Augment the expose list with paths from REPLACE and REMOVE operations.
+ * REPLACE operations require the current values to be visible to the external service
+ * so it can decide what replacement to send. REMOVE operations require current values
+ * to be visible so the external service knows what data would be removed.
*
* @param expose The current expose list.
* @param allowedOperations The parsed allowed operations.
* @return The augmented expose list (new list if modified, original if unchanged).
*/
- private List augmentExposeWithReplacePaths(List expose,
- List allowedOperations) {
+ private List augmentExposeWithOperationPaths(List expose,
+ List allowedOperations) {
if (allowedOperations == null || allowedOperations.isEmpty()) {
return expose;
}
- List replacePaths = new ArrayList<>();
+ List additionalPaths = new ArrayList<>();
for (AllowedOperation op : allowedOperations) {
- if (op.getOp() == Operation.REPLACE && op.getPaths() != null) {
+ if ((op.getOp() == Operation.REPLACE || op.getOp() == Operation.REMOVE)
+ && op.getPaths() != null) {
for (String path : op.getPaths()) {
if (!isExposed(path, expose)) {
- replacePaths.add(path);
+ additionalPaths.add(path);
}
}
}
}
- if (replacePaths.isEmpty()) {
+ if (additionalPaths.isEmpty()) {
return expose;
}
List augmented = new ArrayList<>(expose);
- augmented.addAll(replacePaths);
+ augmented.addAll(additionalPaths);
if (LOG.isDebugEnabled()) {
- LOG.debug("Augmented expose list with REPLACE paths: " + replacePaths);
+ LOG.debug("Augmented expose list with REPLACE/REMOVE paths: " + additionalPaths);
}
return augmented;
}
+
+ // ---- Encryption helpers ----
+
+ /**
+ * Determine if a value at the given path should be JWE-encrypted before sending to the external service.
+ *
+ * Checks both the explicit expose config and the operation paths (REPLACE / REMOVE) that may
+ * have been dynamically augmented into the expose list. REPLACE and REMOVE operation paths
+ * carry their own encryption flags in the allowedOperations config — when such a path is
+ * augmented into expose for visibility, its encryption flag must be honoured.
+ *
+ * @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;
+ }
+ // Check explicit expose paths first.
+ if (accessConfig.isExposePathEncrypted(path)) {
+ return true;
+ }
+ // Also check REPLACE and REMOVE operation paths that were augmented into expose.
+ // These paths may have their own encryption flags in the allowedOperations config.
+ return accessConfig.isOperationPathEncrypted("REPLACE", path)
+ || accessConfig.isOperationPathEncrypted("REMOVE", path);
+ }
+
+ /**
+ * JWE-encrypt a plaintext value using the external service's certificate.
+ * If encryption fails, the original value is returned and a warning is logged.
+ *
+ * @param plaintext The value to encrypt.
+ * @param certificatePEM The external service's certificate PEM.
+ * @return The JWE compact serialization string, or the original value on failure.
+ */
+ private String encryptValue(String plaintext, String certificatePEM) {
+
+ try {
+ return JWEEncryptionUtil.encrypt(plaintext, certificatePEM);
+ } catch (Exception e) {
+ LOG.warn("Failed to JWE-encrypt outbound value. Sending plaintext.", e);
+ return plaintext;
+ }
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
index 4770b229fc40..02650d7fa231 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
@@ -45,6 +45,7 @@
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.inflow.extension.model.AccessConfig;
import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder;
import org.wso2.carbon.identity.flow.execution.engine.model.FlowExecutionContext;
import org.wso2.carbon.identity.flow.execution.engine.model.FlowUser;
@@ -120,6 +121,11 @@ public ActionExecutionStatus processSuccessResponse(FlowContext flowCon
pathTypeAnnotations = Collections.emptyMap();
}
+ // Read access config for encryption metadata.
+ // Uses AccessConfig.isOperationPathEncrypted() for canonical encryption checking.
+ AccessConfig accessConfig = flowContext.getValue(
+ InFlowExtensionExecutor.ACCESS_CONFIG_KEY, AccessConfig.class);
+
List results = new ArrayList<>();
// Get operations from the response (already filtered by ActionExecutorServiceImpl).
@@ -140,6 +146,9 @@ public ActionExecutionStatus processSuccessResponse(FlowContext flowCon
operation = normalizedOp;
}
+ // Decrypt inbound value if this operation path is marked as encrypted in AccessConfig.
+ operation = decryptOperationValueIfNeeded(operation, accessConfig, tenantDomain);
+
results.add(processOperation(operation, execCtx, tenantDomain, pathTypeAnnotations));
}
}
@@ -707,4 +716,61 @@ public static class InFlowExtensionSuccess implements Success {
// Marker class for successful execution.
}
+
+ // ========================= Inbound decryption =========================
+
+ /**
+ * Decrypt the operation value if the operation path has encryption enabled in the AccessConfig.
+ * Uses {@link AccessConfig#isOperationPathEncrypted(String, 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) {
+
+ if (accessConfig == null || operation.getValue() == null) {
+ return operation;
+ }
+
+ // Check if this operation path has encryption enabled via AccessConfig.
+ if (!accessConfig.isOperationPathEncrypted(operation.getOp().name(), operation.getPath())) {
+ return operation;
+ }
+
+ // Only decrypt string values that look like JWE compact serialization.
+ Object value = operation.getValue();
+ if (!(value instanceof String)) {
+ return operation;
+ }
+
+ String stringValue = (String) value;
+ if (!JWEEncryptionUtil.isJWEEncrypted(stringValue)) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Value for encrypted path " + operation.getPath() +
+ " is not JWE-encrypted. Using as-is.");
+ }
+ return operation;
+ }
+
+ 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.warn("Failed to decrypt inbound JWE value for path: " + operation.getPath() +
+ ". Using raw value.", e);
+ return operation;
+ }
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
new file mode 100644
index 000000000000..a9ba41c0b470
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.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.identity.action.execution.api.exception.ActionExecutionException;
+import org.wso2.carbon.identity.core.IdentityKeyStoreResolver;
+import org.wso2.carbon.identity.core.util.IdentityKeyStoreResolverConstants;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.text.ParseException;
+
+/**
+ * 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 IdentityKeyStoreResolver} with
+ * {@link IdentityKeyStoreResolverConstants.InboundProtocol#ACTIONS}. This follows the same
+ * pattern as {@code OIDCSessionManagementUtil.decryptWithRSA()}.
+ */
+public final class JWEEncryptionUtil {
+
+ private static final Log LOG = LogFactory.getLog(JWEEncryptionUtil.class);
+
+ /** Number of dot-separated parts in a JWE compact serialization. */
+ private static final int JWE_PART_COUNT = 5;
+
+ /** Standard PEM line length per RFC 7468. */
+ private static final int PEM_LINE_LENGTH = 64;
+
+ 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 IdentityKeyStoreResolver} using
+ * {@link IdentityKeyStoreResolverConstants.InboundProtocol#ACTIONS}.
+ *
+ * @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 = IdentityKeyStoreResolver.getInstance()
+ .getPrivateKey(tenantDomain,
+ IdentityKeyStoreResolverConstants.InboundProtocol.ACTIONS);
+
+ 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 (Exception e) {
+ throw new ActionExecutionException(
+ "Error resolving IS private key for In-Flow Extension JWE decryption.", 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 PEM-encoded X.509 certificate string into an {@link X509Certificate} object.
+ *
+ * Handles PEM strings that may have lost their newlines during database storage/retrieval
+ * (e.g., via {@code CertificateManagementDAOImpl.getStringValueFromBlob()} which strips
+ * line breaks). The method normalizes the PEM format before parsing to ensure the
+ * header/footer are on separate lines and Base64 content is properly structured.
+ *
+ *
+ * @param pem The PEM string (with or without proper line breaks).
+ * @return The parsed X509Certificate.
+ * @throws ActionExecutionException If parsing fails.
+ */
+ public static X509Certificate parsePEMCertificate(String pem) throws ActionExecutionException {
+
+ try {
+ String normalizedPEM = normalizePEM(pem);
+ CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ return (X509Certificate) factory.generateCertificate(
+ new ByteArrayInputStream(normalizedPEM.getBytes(StandardCharsets.UTF_8)));
+ } catch (ActionExecutionException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ActionExecutionException(
+ "Failed to parse PEM certificate for In-Flow Extension action.", e);
+ }
+ }
+
+ /**
+ * Normalize a PEM string to ensure it has proper line structure.
+ *
+ * The CertificateManagementService's DAO layer reads BLOB data using
+ * {@code BufferedReader.readLine()} + {@code StringBuilder.append(line)}, which strips
+ * all newline characters. This produces a single-line string like:
+ * {@code -----BEGIN CERTIFICATE-----MIICxjCC...-----END CERTIFICATE-----}
+ * which Java's X509 parser cannot handle.
+ *
+ *
+ * This method extracts the Base64 body, strips all whitespace, and re-wraps it at
+ * 64-character lines between proper PEM header/footer markers.
+ *
+ *
+ * @param pem The PEM string (possibly with stripped newlines).
+ * @return A properly formatted PEM string.
+ * @throws ActionExecutionException If the PEM structure is invalid.
+ */
+ private static String normalizePEM(String pem) throws ActionExecutionException {
+
+ if (pem == null || pem.isEmpty()) {
+ throw new ActionExecutionException("Certificate PEM string is null or empty.");
+ }
+
+ String trimmed = pem.trim();
+
+ // Extract the Base64 body by stripping header/footer markers.
+ String base64Body = trimmed
+ .replace("-----BEGIN CERTIFICATE-----", "")
+ .replace("-----END CERTIFICATE-----", "")
+ .replaceAll("\\s+", "");
+
+ if (base64Body.isEmpty()) {
+ throw new ActionExecutionException("Certificate PEM contains no Base64 data.");
+ }
+
+ // Reconstruct proper PEM with 64-char line wrapping (RFC 7468).
+ StringBuilder sb = new StringBuilder();
+ sb.append("-----BEGIN CERTIFICATE-----\n");
+ for (int i = 0; i < base64Body.length(); i += PEM_LINE_LENGTH) {
+ sb.append(base64Body, i, Math.min(i + PEM_LINE_LENGTH, base64Body.length()));
+ sb.append('\n');
+ }
+ sb.append("-----END CERTIFICATE-----\n");
+ return sb.toString();
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
index 4f989dfe19d4..43d0626cf597 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
@@ -28,7 +28,10 @@
import org.wso2.carbon.identity.action.management.api.service.ActionManagementService;
import org.wso2.carbon.identity.core.util.IdentityTenantUtil;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AccessConfig;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AllowedOperation;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.OperationPath;
import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder;
import org.wso2.carbon.identity.flow.mgt.FlowUpdateInterceptor;
import org.wso2.carbon.identity.flow.mgt.exception.FlowMgtFrameworkException;
@@ -37,6 +40,7 @@
import org.wso2.carbon.identity.flow.mgt.model.GraphConfig;
import org.wso2.carbon.identity.flow.mgt.model.NodeConfig;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -125,10 +129,10 @@ private void processNode(NodeConfig node, String flowType, String tenantDomain,
// Parse the unified accessConfig JSON: {"expose": [...], "allowedOperations": [...]}
Map accessConfigMap = OBJECT_MAPPER.readValue(accessConfigJson, MAP_TYPE_REF);
- List expose = accessConfigMap.containsKey(EXPOSE_FIELD)
- ? (List) accessConfigMap.get(EXPOSE_FIELD) : null;
- List> allowedOps = accessConfigMap.containsKey(ALLOWED_OPERATIONS_FIELD)
- ? (List>) accessConfigMap.get(ALLOWED_OPERATIONS_FIELD) : null;
+ List expose = parseExposePaths(accessConfigMap.get(EXPOSE_FIELD));
+ List allowedOps = parseAllowedOperations(
+ accessConfigMap.get(ALLOWED_OPERATIONS_FIELD));
+ // Certificate is action-level only — not overridable per flow type.
AccessConfig overrideConfig = new AccessConfig(expose, allowedOps);
// Retrieve the existing action to merge overrides (preserving other flow types).
@@ -171,4 +175,101 @@ private void processNode(NodeConfig node, String flowType, String tenantDomain,
"Error parsing access config metadata for action: " + actionId, e);
}
}
+
+ /**
+ * Parse expose entries from the raw JSON value.
+ * Accepts both simple strings (no encryption) and objects ({path, encrypted}).
+ */
+ @SuppressWarnings("unchecked")
+ private List parseExposePaths(Object value) {
+
+ if (value == null) {
+ return null;
+ }
+ if (!(value instanceof List)) {
+ LOG.warn("Invalid expose value in flow override accessConfig. Expected list.");
+ return null;
+ }
+
+ List> rawList = (List>) value;
+ List result = new ArrayList<>();
+ for (Object item : rawList) {
+ if (item instanceof String) {
+ result.add(new ExposePath((String) item, false));
+ } else if (item instanceof Map) {
+ Map, ?> map = (Map, ?>) item;
+ String path = (String) map.get("path");
+ boolean encrypted = toBooleanSafe(map.get("encrypted"));
+ if (path != null) {
+ result.add(new ExposePath(path, encrypted));
+ }
+ }
+ }
+ return result.isEmpty() ? null : result;
+ }
+
+ /**
+ * Parse allowed operations from the raw JSON value.
+ * Accepts the nested format: [{op, paths: [{path, encrypted}]}].
+ */
+ @SuppressWarnings("unchecked")
+ private List parseAllowedOperations(Object value) {
+
+ if (value == null) {
+ return null;
+ }
+ if (!(value instanceof List)) {
+ LOG.warn("Invalid allowedOperations value in flow override accessConfig. Expected list.");
+ return null;
+ }
+
+ List> rawList = (List>) value;
+ List result = new ArrayList<>();
+ for (Object item : rawList) {
+ if (item instanceof Map) {
+ Map, ?> map = (Map, ?>) item;
+ String op = (String) map.get("op");
+ Object pathsObj = map.get("paths");
+ if (op == null || !(pathsObj instanceof List)) {
+ continue;
+ }
+ List> pathsList = (List>) pathsObj;
+ List operationPaths = new ArrayList<>();
+ for (Object pathItem : pathsList) {
+ if (pathItem instanceof Map) {
+ Map, ?> pathMap = (Map, ?>) pathItem;
+ String path = (String) pathMap.get("path");
+ boolean encrypted = toBooleanSafe(pathMap.get("encrypted"));
+ if (path != null) {
+ operationPaths.add(new OperationPath(path, encrypted));
+ }
+ } else if (pathItem instanceof String) {
+ operationPaths.add(new OperationPath((String) pathItem, false));
+ }
+ }
+ if (!operationPaths.isEmpty()) {
+ result.add(new AllowedOperation(op, operationPaths));
+ }
+ }
+ }
+ return result.isEmpty() ? null : result;
+ }
+
+ /**
+ * Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types.
+ * Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}.
+ *
+ * @param value The value to convert.
+ * @return {@code true} if the value is Boolean TRUE or the string "true" (case-insensitive).
+ */
+ private static boolean toBooleanSafe(Object value) {
+
+ if (value instanceof Boolean) {
+ return (Boolean) value;
+ }
+ if (value instanceof String) {
+ return Boolean.parseBoolean((String) value);
+ }
+ return false;
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
index 2dbdaae25e70..fbd8c88e0b5b 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
@@ -73,6 +73,19 @@ public class InFlowExtensionActionConstants {
*/
public static final int MAX_EXPOSE_PATHS = 50;
+ /**
+ * Property key for the external service's certificate stored in IDN_ACTION_PROPERTIES.
+ * During persistence the certificate object is replaced with its UUID (PRIMITIVE string);
+ * during retrieval the UUID is resolved back to the full Certificate object.
+ */
+ public static final String CERTIFICATE = "CERTIFICATE";
+
+ /**
+ * Naming prefix for certificates stored via CertificateManagementService.
+ * Full name format: {@code "ACTIONS:"}.
+ */
+ public static final String CERTIFICATE_NAME_PREFIX = "ACTIONS:";
+
private InFlowExtensionActionConstants() {
}
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
index fea0f774718d..cb44c2d81a65 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
@@ -22,7 +22,11 @@
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.execution.engine.inflow.extension.model.AccessConfig;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AllowedOperation;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.Encryption;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
import java.util.HashMap;
@@ -31,6 +35,7 @@
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.CERTIFICATE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
@@ -81,6 +86,13 @@ public ActionDTO buildActionDTO(Action action) {
}
}
+ // Encryption certificate (separate from access config).
+ Encryption encryption = inFlowExtensionAction.getEncryption();
+ if (encryption != null && encryption.getCertificate() != null) {
+ properties.put(CERTIFICATE,
+ new ActionProperty.BuilderForService(encryption.getCertificate()).build());
+ }
+
// Per-flow-type overrides using prefixed keys.
Map overrides = inFlowExtensionAction.getFlowTypeOverrides();
if (overrides != null) {
@@ -115,31 +127,40 @@ public ActionDTO buildActionDTO(Action action) {
public Action buildAction(ActionDTO actionDTO) {
// Default access config.
- List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
- List> allowedOperations =
- (List>) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
+ List allowedOperations =
+ (List) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
AccessConfig accessConfig = null;
if (expose != null || allowedOperations != null) {
accessConfig = new AccessConfig(expose, allowedOperations);
}
+ // Encryption certificate (separate from access config).
+ Encryption encryption = null;
+ Object certValue = actionDTO.getPropertyValue(CERTIFICATE);
+ if (certValue instanceof Certificate) {
+ encryption = new Encryption((Certificate) certValue);
+ }
+
// 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));
+ AccessConfig existing = flowTypeOverrides.getOrDefault(flowType,
+ new AccessConfig(null, null));
flowTypeOverrides.put(flowType, new AccessConfig(
- (List) actionDTO.getPropertyValue(propertyKey),
+ (List) actionDTO.getPropertyValue(propertyKey),
existing.getAllowedOperations()));
} else if (propertyKey.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
String flowType = propertyKey.substring(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX.length());
- AccessConfig existing = flowTypeOverrides.getOrDefault(flowType, new AccessConfig(null, null));
+ AccessConfig existing = flowTypeOverrides.getOrDefault(flowType,
+ new AccessConfig(null, null));
flowTypeOverrides.put(flowType, new AccessConfig(
existing.getExpose(),
- (List>) actionDTO.getPropertyValue(propertyKey)));
+ (List) actionDTO.getPropertyValue(propertyKey)));
}
}
}
@@ -155,6 +176,7 @@ public Action buildAction(ActionDTO actionDTO) {
.updatedAt(actionDTO.getUpdatedAt())
.endpoint(actionDTO.getEndpoint())
.accessConfig(accessConfig)
+ .encryption(encryption)
.flowTypeOverrides(flowTypeOverrides)
.rule(actionDTO.getActionRule())
.build();
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
index ba420ce5af44..ec4ac4da1b15 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
@@ -30,6 +30,12 @@
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.execution.engine.inflow.extension.model.AllowedOperation;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.OperationPath;
import java.io.IOException;
import java.util.ArrayList;
@@ -42,8 +48,10 @@
import static java.util.Collections.emptyList;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.CERTIFICATE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.CERTIFICATE_NAME_PREFIX;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.MAX_EXPOSE_PATHS;
/**
@@ -65,10 +73,13 @@ public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelReso
private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
- private static final TypeReference> STRING_LIST_TYPE_REF =
- new TypeReference>() { };
- private static final TypeReference>> MAP_LIST_TYPE_REF =
- new TypeReference>>() { };
+
+ private final CertificateManagementService certificateManagementService;
+
+ public InFlowExtensionActionDTOModelResolver(CertificateManagementService certificateManagementService) {
+
+ this.certificateManagementService = certificateManagementService;
+ }
@Override
public Action.ActionTypes getSupportedActionType() {
@@ -85,17 +96,20 @@ public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain
Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
// Expose is an optional field.
if (exposeValue != null) {
- List validatedExpose = validateExpose(exposeValue);
+ List validatedExpose = validateExpose(exposeValue);
properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose));
}
Object allowedOpsValue = actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
// Allowed operations is an optional field.
if (allowedOpsValue != null) {
- List> validatedOps = validateAllowedOperations(allowedOpsValue);
+ List validatedOps = validateAllowedOperations(allowedOpsValue);
properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(validatedOps));
}
+ // Handle certificate: store via CertificateManagementService and replace with ID.
+ handleCertificateAdd(actionDTO, properties, tenantDomain);
+
// Handle per-flow-type override properties (prefixed keys).
if (actionDTO.getProperties() != null) {
for (Map.Entry entry : actionDTO.getProperties().entrySet()) {
@@ -103,13 +117,13 @@ public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain
if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) {
Object overrideExpose = actionDTO.getPropertyValue(key);
if (overrideExpose != null) {
- List validatedOverrideExpose = validateExpose(overrideExpose);
+ List validatedOverrideExpose = validateExpose(overrideExpose);
properties.put(key, createBlobProperty(validatedOverrideExpose));
}
} else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
Object overrideOps = actionDTO.getPropertyValue(key);
if (overrideOps != null) {
- List> validatedOverrideOps = validateAllowedOperations(overrideOps);
+ List validatedOverrideOps = validateAllowedOperations(overrideOps);
properties.put(key, createBlobProperty(validatedOverrideOps));
}
}
@@ -138,6 +152,9 @@ public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain
((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS)).getJSONString()));
}
+ // Retrieve certificate by stored ID.
+ handleCertificateGet(actionDTO, properties, tenantDomain);
+
// Deserialize per-flow-type override properties (prefixed keys).
if (actionDTO.getProperties() != null) {
for (Map.Entry entry : actionDTO.getProperties().entrySet()) {
@@ -191,17 +208,20 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
// updated the existing properties should be sent to the DAO layer.
// Handle default access config properties.
- List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO);
+ List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO);
if (!expose.isEmpty()) {
properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose));
}
- List> allowedOps =
+ List allowedOps =
getResolvedUpdatingAllowedOps(updatingActionDTO, existingActionDTO);
if (!allowedOps.isEmpty()) {
properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(allowedOps));
}
+ // Handle certificate update.
+ handleCertificateUpdate(updatingActionDTO, existingActionDTO, properties, tenantDomain);
+
// Handle per-flow-type override properties. Since DAO treats update as PUT (full replace),
// we must carry forward all existing overrides that are not explicitly being updated.
// First, carry forward all existing per-flow-type overrides.
@@ -221,13 +241,13 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) {
Object overrideExpose = updatingActionDTO.getPropertyValue(key);
if (overrideExpose != null) {
- List validatedOverrideExpose = validateExpose(overrideExpose);
+ List validatedOverrideExpose = validateExpose(overrideExpose);
properties.put(key, createBlobProperty(validatedOverrideExpose));
}
} else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
Object overrideOps = updatingActionDTO.getPropertyValue(key);
if (overrideOps != null) {
- List> validatedOverrideOps = validateAllowedOperations(overrideOps);
+ List validatedOverrideOps = validateAllowedOperations(overrideOps);
properties.put(key, createBlobProperty(validatedOverrideOps));
}
}
@@ -242,32 +262,34 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
@Override
public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenantDomain)
throws ActionDTOModelResolverException {
- // No-op: properties are cascade-deleted with the action in IDN_ACTION_PROPERTIES.
+
+ // Delete the certificate if one was stored for this action.
+ handleCertificateDelete(deletingActionDTO, tenantDomain);
}
// ---- Update helpers ----
@SuppressWarnings("unchecked")
- private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO)
+ 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 (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
}
return emptyList();
}
@SuppressWarnings("unchecked")
- private List> getResolvedUpdatingAllowedOps(ActionDTO updatingActionDTO,
- ActionDTO existingActionDTO)
+ private List getResolvedUpdatingAllowedOps(ActionDTO updatingActionDTO,
+ ActionDTO existingActionDTO)
throws ActionDTOModelResolverException {
if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
return validateAllowedOperations(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS));
} else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
- return (List>) existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
}
return emptyList();
}
@@ -275,28 +297,43 @@ private List> getResolvedUpdatingAllowedOps(ActionDTO updati
// ---- Validation ----
@SuppressWarnings("unchecked")
- private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException {
+ private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException {
if (!(exposeValue instanceof List>)) {
throw new ActionDTOModelResolverClientException("Invalid expose format.",
- "Expose should be provided as a list of strings.");
+ "Expose should be provided as a list.");
}
List> exposeList = (List>) exposeValue;
+ List result = new ArrayList<>();
+
for (Object item : exposeList) {
- if (!(item instanceof String)) {
+ if (item instanceof String) {
+ // Simple string path — no encryption.
+ result.add(new ExposePath((String) item, false));
+ } else 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 a string or an object with a 'path' field.");
+ }
+ String path = (String) map.get("path");
+ boolean encrypted = map.containsKey("encrypted") && toBooleanSafe(map.get("encrypted"));
+ result.add(new ExposePath(path, encrypted));
+ } else if (item instanceof ExposePath) {
+ result.add((ExposePath) item);
+ } else {
throw new ActionDTOModelResolverClientException("Invalid expose format.",
- "Expose must contain only string values.");
+ "Each expose entry must be a string or an object with 'path' and optional 'encrypted' fields.");
}
}
- List expose = (List) exposeValue;
- validateExposeCount(expose);
- validateExposePathFormat(expose);
- return expose;
+ validateExposeCount(result);
+ validateExposePathFormat(result);
+ return result;
}
- private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException {
+ private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException {
if (expose.size() > MAX_EXPOSE_PATHS) {
throw new ActionDTOModelResolverClientException("Maximum expose paths limit exceeded.",
@@ -305,10 +342,11 @@ private void validateExposeCount(List expose) throws ActionDTOModelResol
}
}
- private void validateExposePathFormat(List expose) throws ActionDTOModelResolverClientException {
+ private void validateExposePathFormat(List expose) throws ActionDTOModelResolverClientException {
Set seen = new HashSet<>();
- for (String path : expose) {
+ for (ExposePath 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.");
@@ -325,7 +363,7 @@ private void validateExposePathFormat(List expose) throws ActionDTOModel
}
@SuppressWarnings("unchecked")
- private List> validateAllowedOperations(Object allowedOpsValue)
+ private List validateAllowedOperations(Object allowedOpsValue)
throws ActionDTOModelResolverClientException {
if (!(allowedOpsValue instanceof List>)) {
@@ -334,23 +372,209 @@ private List> validateAllowedOperations(Object allowedOpsVal
}
List> opsList = (List>) allowedOpsValue;
+ List result = new ArrayList<>();
+
for (Object item : opsList) {
- if (!(item instanceof Map)) {
+ if (item instanceof AllowedOperation) {
+ result.add((AllowedOperation) item);
+ } else if (item instanceof Map) {
+ Map, ?> opMap = (Map, ?>) item;
+ if (!opMap.containsKey("op") || !(opMap.get("op") instanceof String)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
+ "Each allowed operation must contain an 'op' field with a string value.");
+ }
+ String op = (String) opMap.get("op");
+
+ if (!opMap.containsKey("paths") || !(opMap.get("paths") instanceof List)) {
+ throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
+ "Each allowed operation must contain a 'paths' list.");
+ }
+
+ List> pathsList = (List>) opMap.get("paths");
+ List operationPaths = new ArrayList<>();
+ for (Object pathItem : pathsList) {
+ if (pathItem instanceof OperationPath) {
+ operationPaths.add((OperationPath) pathItem);
+ } else if (pathItem instanceof Map) {
+ Map, ?> pathMap = (Map, ?>) pathItem;
+ if (!pathMap.containsKey("path") || !(pathMap.get("path") instanceof String)) {
+ throw new ActionDTOModelResolverClientException("Invalid operation path.",
+ "Each path entry must contain a 'path' field with a string value.");
+ }
+ String path = (String) pathMap.get("path");
+ boolean encrypted = pathMap.containsKey("encrypted")
+ && toBooleanSafe(pathMap.get("encrypted"));
+ operationPaths.add(new OperationPath(path, encrypted));
+ } else if (pathItem instanceof String) {
+ // Simple string path — no encryption.
+ operationPaths.add(new OperationPath((String) pathItem, false));
+ } else {
+ throw new ActionDTOModelResolverClientException("Invalid operation path format.",
+ "Each path entry must be a string or an object with 'path' and "
+ + "optional 'encrypted' fields.");
+ }
+ }
+ result.add(new AllowedOperation(op, operationPaths));
+ } else {
throw new ActionDTOModelResolverClientException("Invalid allowed operations format.",
- "Each allowed operation must be an object with 'op' and 'paths' keys.");
+ "Each allowed operation must be an object with 'op' and 'paths' fields.");
}
- Map, ?> opMap = (Map, ?>) item;
- if (!opMap.containsKey("op") || !(opMap.get("op") instanceof String)) {
- throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
- "Each allowed operation must contain an 'op' field with a string value.");
- }
- if (!opMap.containsKey("paths") || !(opMap.get("paths") instanceof List)) {
- throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
- "Each allowed operation must contain a 'paths' field with a list of strings.");
+ }
+
+ return result;
+ }
+
+ // ---- Certificate lifecycle helpers ----
+
+ /**
+ * Stores the external service's certificate via CertificateManagementService during action creation.
+ * The certificate PEM is replaced with the stored certificate's ID as a PRIMITIVE property.
+ */
+ private void handleCertificateAdd(ActionDTO actionDTO, Map properties,
+ String tenantDomain) throws ActionDTOModelResolverException {
+
+ Object certValue = actionDTO.getPropertyValue(CERTIFICATE);
+ if (certValue == null) {
+ return;
+ }
+
+ String certificatePEM = extractCertificatePEM(certValue);
+ String certName = CERTIFICATE_NAME_PREFIX + actionDTO.getId();
+
+ try {
+ String certificateId = certificateManagementService.addCertificate(
+ new Certificate.Builder()
+ .name(certName)
+ .certificateContent(certificatePEM)
+ .build(),
+ tenantDomain);
+
+ // Store the certificate ID as a primitive property so DAO persists just the ID.
+ properties.put(CERTIFICATE,
+ new ActionProperty.BuilderForDAO(certificateId).build());
+ } catch (CertificateMgtException e) {
+ throw new ActionDTOModelResolverException("Error storing certificate for action: "
+ + actionDTO.getId(), e);
+ }
+ }
+
+ /**
+ * Retrieves the certificate by its stored ID during action get operations.
+ * Replaces the stored ID with the full Certificate object as a service-layer property.
+ */
+ private void handleCertificateGet(ActionDTO actionDTO, Map properties,
+ String tenantDomain) throws ActionDTOModelResolverException {
+
+ Object certIdValue = actionDTO.getPropertyValue(CERTIFICATE);
+ if (certIdValue == null) {
+ return;
+ }
+
+ try {
+ String certIdStr = certIdValue.toString();
+ Certificate certificate = certificateManagementService.getCertificate(
+ certIdStr, tenantDomain);
+ properties.put(CERTIFICATE,
+ new ActionProperty.BuilderForService(certificate).build());
+ } catch (CertificateMgtException e) {
+ throw new ActionDTOModelResolverException("Error retrieving certificate for action: "
+ + actionDTO.getId(), e);
+ }
+ }
+
+ /**
+ * Handles certificate lifecycle during action update: add new, update existing, delete, or carry forward.
+ */
+ private void handleCertificateUpdate(ActionDTO updatingActionDTO, ActionDTO existingActionDTO,
+ Map properties, String tenantDomain)
+ throws ActionDTOModelResolverException {
+
+ Object newCertValue = updatingActionDTO.getPropertyValue(CERTIFICATE);
+ Object existingCertValue = existingActionDTO.getPropertyValue(CERTIFICATE);
+
+ if (newCertValue != null && existingCertValue != null) {
+ // Update existing certificate.
+ String certificatePEM = extractCertificatePEM(newCertValue);
+ try {
+ String existingCertId = extractCertificateId(existingCertValue);
+ certificateManagementService.updateCertificateContent(
+ existingCertId, certificatePEM, tenantDomain);
+ // Carry forward the existing certificate ID.
+ properties.put(CERTIFICATE,
+ new ActionProperty.BuilderForDAO(existingCertId).build());
+ } catch (CertificateMgtException e) {
+ throw new ActionDTOModelResolverException("Error updating certificate for action: "
+ + updatingActionDTO.getId(), e);
}
+ } else if (newCertValue != null) {
+ // Add new certificate (previously had none).
+ handleCertificateAdd(updatingActionDTO, properties, tenantDomain);
+ } else if (existingCertValue != null) {
+ // No new certificate provided — carry forward the existing one (PUT semantics).
+ properties.put(CERTIFICATE,
+ new ActionProperty.BuilderForDAO(extractCertificateId(existingCertValue)).build());
}
+ // else: both null — no certificate to handle.
+ }
- return (List>) allowedOpsValue;
+ /**
+ * Deletes the certificate from IDN_CERTIFICATE during action deletion.
+ */
+ private void handleCertificateDelete(ActionDTO deletingActionDTO, String tenantDomain)
+ throws ActionDTOModelResolverException {
+
+ Object certIdValue = deletingActionDTO.getPropertyValue(CERTIFICATE);
+ if (certIdValue == null) {
+ return;
+ }
+
+ try {
+ String certId = certIdValue.toString();
+ certificateManagementService.deleteCertificate(certId, tenantDomain);
+ } catch (CertificateMgtException e) {
+ throw new ActionDTOModelResolverException("Error deleting certificate for action: "
+ + deletingActionDTO.getId(), e);
+ }
+ }
+
+ /**
+ * Extracts the certificate UUID from a certificate property value.
+ *
+ * The existing action DTO may come from the GET resolver, which replaces the stored UUID
+ * with the full {@link Certificate} object. This method handles both cases:
+ * - {@link Certificate} object: extracts the ID via {@code getId()}.
+ * - String: assumes it is already the UUID.
+ *
+ */
+ private String extractCertificateId(Object certValue) {
+
+ if (certValue instanceof Certificate) {
+ return ((Certificate) certValue).getId();
+ }
+ return certValue.toString();
+ }
+
+ /**
+ * Extracts the PEM string from a certificate value, which may be a Certificate object,
+ * a Map, or a plain string.
+ */
+ private String extractCertificatePEM(Object certValue) throws ActionDTOModelResolverClientException {
+
+ if (certValue instanceof Certificate) {
+ return ((Certificate) certValue).getCertificateContent();
+ } else if (certValue instanceof Map) {
+ Map, ?> certMap = (Map, ?>) certValue;
+ Object content = certMap.get("certificateContent");
+ if (content instanceof String) {
+ return (String) content;
+ }
+ throw new ActionDTOModelResolverClientException("Invalid certificate format.",
+ "Certificate object must contain a 'certificateContent' field.");
+ } else if (certValue instanceof String) {
+ return (String) certValue;
+ }
+ throw new ActionDTOModelResolverClientException("Invalid certificate format.",
+ "Certificate must be a PEM string, a Certificate object, or a map with 'certificateContent'.");
}
// ---- Serialization helpers ----
@@ -368,7 +592,8 @@ private ActionProperty createBlobProperty(Object value) throws ActionDTOModelRes
private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException {
try {
- List expose = OBJECT_MAPPER.readValue(jsonString, STRING_LIST_TYPE_REF);
+ List expose = OBJECT_MAPPER.readValue(jsonString,
+ new TypeReference>() { });
return new ActionProperty.BuilderForService(expose).build();
} catch (IOException e) {
throw new ActionDTOModelResolverException("Error reading expose values from storage.", e);
@@ -379,10 +604,29 @@ private ActionProperty deserializeAllowedOpsProperty(String jsonString)
throws ActionDTOModelResolverException {
try {
- List> allowedOps = OBJECT_MAPPER.readValue(jsonString, MAP_LIST_TYPE_REF);
+ List allowedOps = OBJECT_MAPPER.readValue(jsonString,
+ new TypeReference>() { });
return new ActionProperty.BuilderForService(allowedOps).build();
} catch (IOException e) {
throw new ActionDTOModelResolverException("Error reading allowed operations from storage.", e);
}
}
+
+ /**
+ * Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types.
+ * Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}.
+ *
+ * @param value The value to convert.
+ * @return {@code true} if the value is Boolean TRUE or the string "true" (case-insensitive).
+ */
+ private static boolean toBooleanSafe(Object value) {
+
+ if (value instanceof Boolean) {
+ return (Boolean) value;
+ }
+ if (value instanceof String) {
+ return Boolean.parseBoolean((String) value);
+ }
+ return false;
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
index 77164398e91d..7ef2cab4de85 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
@@ -20,7 +20,9 @@
import java.util.Collections;
import java.util.List;
-import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
/**
* Access Configuration for In-Flow Extension actions.
@@ -30,47 +32,145 @@
*
*
*
- *
{@code expose} – hierarchical path prefixes that control which context data is sent
- * in the action execution request (e.g. {@code "/user/claims/"}, {@code "/flow/properties/"}).
- *
{@code allowedOperations} – a structured list of operation descriptors, where each entry
- * specifies an operation type ({@code "op"}) and the paths it applies to ({@code "paths"}).
+ *
{@code expose} – structured list of {@link ExposePath} entries, each with a hierarchical
+ * path prefix and an {@code encrypted} flag controlling outbound JWE encryption.
+ *
{@code allowedOperations} – list of {@link AllowedOperation} entries, each grouping an
+ * operation type with a list of {@link OperationPath} entries carrying per-path encryption
+ * flags for inbound JWE encryption.
*
+ *
+ *
Note: The external service's certificate for outbound encryption is held in the
+ * separate {@link Encryption} model, not in AccessConfig. This follows the same separation
+ * pattern as {@code PasswordSharing} in the PreUpdatePassword action type.
*/
public class AccessConfig {
- private final List expose;
- private final List> allowedOperations;
+ /**
+ * Regex pattern to match path type annotations at the end of a path.
+ * Strips trailing bracket expressions like {@code []}, {@code [field1, field2]}.
+ * These annotations are used by the request builder for type coercion but are not
+ * part of the logical path that the external service sees.
+ */
+ private static final Pattern PATH_ANNOTATION_PATTERN = Pattern.compile("\\[([^\\]]*)]$");
+
+ private final List expose;
+ private final List allowedOperations;
/**
* Constructs an AccessConfig with the given expose paths and allowed operations.
*
- * @param expose List of hierarchical path prefixes to expose. May be {@code null}.
- * @param allowedOperations List of operation descriptors. May be {@code null}.
+ * @param expose List of expose path entries. May be {@code null}.
+ * @param allowedOperations List of allowed operation entries. May be {@code null}.
*/
- public AccessConfig(List expose, List> allowedOperations) {
+ public AccessConfig(List expose, List allowedOperations) {
this.expose = expose != null ? Collections.unmodifiableList(expose) : null;
- this.allowedOperations = allowedOperations != null ? Collections.unmodifiableList(allowedOperations) : null;
+ this.allowedOperations = allowedOperations != null
+ ? Collections.unmodifiableList(allowedOperations) : null;
}
/**
- * Returns the list of hierarchical path prefixes that define what context data is exposed.
+ * Returns the list of expose path entries.
*
- * @return Unmodifiable list of expose path prefixes, or {@code null} if not configured.
+ * @return Unmodifiable list of {@link ExposePath} entries, or {@code null} if not configured.
*/
- public List getExpose() {
+ public List getExpose() {
return expose;
}
/**
- * Returns the list of allowed operation descriptors.
- * Each entry is a map with at least {@code "op"} (operation type) and {@code "paths"} keys.
+ * Returns the flat list of expose path strings (without encryption metadata).
+ * Convenience method for components that only need path prefixes.
*
- * @return Unmodifiable list of operation descriptors, or {@code null} if not configured.
+ * @return List of path strings, or {@code null} if expose is not configured.
*/
- public List> getAllowedOperations() {
+ public List getExposePaths() {
+
+ if (expose == null) {
+ return null;
+ }
+ return expose.stream().map(ExposePath::getPath).collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the list of allowed operation entries.
+ *
+ * @return Unmodifiable list of {@link AllowedOperation} entries, or {@code null} if not configured.
+ */
+ public List getAllowedOperations() {
return allowedOperations;
}
+
+ /**
+ * Check if a given path prefix has outbound encryption enabled.
+ * Matches the most specific (longest) expose path that is a prefix of the given path.
+ *
+ * @param pathPrefix The path to check.
+ * @return {@code true} if the matching expose entry has {@code encrypted = true}.
+ */
+ public boolean isExposePathEncrypted(String pathPrefix) {
+
+ if (expose == null) {
+ return false;
+ }
+ return expose.stream()
+ .filter(ep -> pathPrefix.startsWith(ep.getPath()))
+ .reduce((a, b) -> a.getPath().length() >= b.getPath().length() ? a : b)
+ .map(ExposePath::isEncrypted)
+ .orElse(false);
+ }
+
+ /**
+ * Check if a given operation path has inbound encryption enabled.
+ * Iterates the nested {@link OperationPath} entries within each {@link AllowedOperation}.
+ * Path type annotations (e.g. {@code []}, {@code [schema]}) are stripped from stored paths
+ * before comparison, since operation paths from the external service do not include annotations.
+ *
+ * @param op The operation type (e.g. "REPLACE").
+ * @param path The operation path (clean, without annotations).
+ * @return {@code true} if the matching operation path has {@code encrypted = true}.
+ */
+ public boolean isOperationPathEncrypted(String op, String path) {
+
+ if (allowedOperations == null) {
+ return false;
+ }
+ return allowedOperations.stream()
+ .filter(ao -> ao.getOp().equalsIgnoreCase(op))
+ .flatMap(ao -> ao.getPaths().stream())
+ .filter(opPath -> path.startsWith(stripAnnotation(opPath.getPath())))
+ .reduce((a, b) -> stripAnnotation(a.getPath()).length()
+ >= stripAnnotation(b.getPath()).length() ? a : b)
+ .map(OperationPath::isEncrypted)
+ .orElse(false);
+ }
+
+ /**
+ * Returns whether any expose path or allowed operation path has encryption enabled.
+ *
+ * @return {@code true} if at least one path has {@code encrypted = true}.
+ */
+ public boolean hasAnyEncryptedPath() {
+
+ boolean hasEncryptedExpose = expose != null
+ && expose.stream().anyMatch(ExposePath::isEncrypted);
+ boolean hasEncryptedOps = allowedOperations != null
+ && allowedOperations.stream().anyMatch(AllowedOperation::hasAnyEncryptedPath);
+ return hasEncryptedExpose || hasEncryptedOps;
+ }
+
+ /**
+ * Strip trailing path type annotations from a path string.
+ * For example, {@code "/properties/riskFactors[]"} becomes {@code "/properties/riskFactors"}.
+ *
+ * @param path The path that may contain trailing annotations.
+ * @return The path without annotations.
+ */
+ private static String stripAnnotation(String path) {
+
+ Matcher m = PATH_ANNOTATION_PATTERN.matcher(path);
+ return m.find() ? path.substring(0, m.start()) : path;
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
new file mode 100644
index 000000000000..d040ea86afac
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Domain model for an allowed operation with structured paths.
+ *
+ * Each entry groups an operation type with a list of {@link OperationPath} entries,
+ * where each path carries its own {@code encrypted} flag.
+ *
+ */
+public class AllowedOperation {
+
+ private final String op;
+ private final List paths;
+
+ @JsonCreator
+ public AllowedOperation(@JsonProperty("op") String op,
+ @JsonProperty("paths") List paths) {
+
+ this.op = op;
+ this.paths = paths != null ? Collections.unmodifiableList(paths) : Collections.emptyList();
+ }
+
+ /**
+ * Returns the operation type (e.g. {@code "ADD"}, {@code "REPLACE"}, {@code "REMOVE"}).
+ *
+ * @return The operation type string.
+ */
+ public String getOp() {
+
+ return op;
+ }
+
+ /**
+ * Returns the list of {@link OperationPath} entries for this operation.
+ *
+ * @return Unmodifiable list of operation paths with encryption flags.
+ */
+ public List getPaths() {
+
+ return paths;
+ }
+
+ /**
+ * Returns whether any path in this operation has encryption enabled.
+ *
+ * @return {@code true} if at least one path has {@code encrypted = true}.
+ */
+ public boolean hasAnyEncryptedPath() {
+
+ return paths.stream().anyMatch(OperationPath::isEncrypted);
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/Encryption.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/Encryption.java
new file mode 100644
index 000000000000..a3b72ecafeaa
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/Encryption.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.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.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java
new file mode 100644
index 000000000000..04728ac59153
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Domain model for an expose path with an optional encryption flag.
+ *
+ * When {@code encrypted} is {@code true}, the value at this path prefix is JWE-encrypted
+ * in the outbound request using the external service's public certificate before sending.
+ *
+ */
+public class ExposePath {
+
+ private final String path;
+ private final boolean encrypted;
+
+ @JsonCreator
+ public ExposePath(@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.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java
index 2abf96831962..c4539d783235 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/InFlowExtensionAction.java
@@ -36,12 +36,14 @@
public class InFlowExtensionAction extends Action {
private final AccessConfig accessConfig;
+ private final Encryption encryption;
private final Map flowTypeOverrides;
public InFlowExtensionAction(ResponseBuilder responseBuilder) {
super(responseBuilder);
this.accessConfig = responseBuilder.accessConfig;
+ this.encryption = responseBuilder.encryption;
this.flowTypeOverrides = responseBuilder.flowTypeOverrides != null
? Collections.unmodifiableMap(new HashMap<>(responseBuilder.flowTypeOverrides))
: Collections.emptyMap();
@@ -51,6 +53,7 @@ public InFlowExtensionAction(RequestBuilder requestBuilder) {
super(requestBuilder);
this.accessConfig = requestBuilder.accessConfig;
+ this.encryption = requestBuilder.encryption;
this.flowTypeOverrides = requestBuilder.flowTypeOverrides != null
? Collections.unmodifiableMap(new HashMap<>(requestBuilder.flowTypeOverrides))
: Collections.emptyMap();
@@ -66,6 +69,16 @@ 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 per-flow-type access config overrides.
* Keys are flow type strings (e.g., "REGISTRATION", "LOGIN").
@@ -99,6 +112,7 @@ public AccessConfig resolveAccessConfig(String flowType) {
public static class ResponseBuilder extends ActionResponseBuilder {
private AccessConfig accessConfig;
+ private Encryption encryption;
private Map flowTypeOverrides;
@Override
@@ -177,6 +191,12 @@ public ResponseBuilder accessConfig(AccessConfig accessConfig) {
return this;
}
+ public ResponseBuilder encryption(Encryption encryption) {
+
+ this.encryption = encryption;
+ return this;
+ }
+
public ResponseBuilder flowTypeOverrides(Map flowTypeOverrides) {
this.flowTypeOverrides = flowTypeOverrides;
@@ -197,6 +217,7 @@ public InFlowExtensionAction build() {
public static class RequestBuilder extends ActionRequestBuilder {
private AccessConfig accessConfig;
+ private Encryption encryption;
private Map flowTypeOverrides;
public RequestBuilder(Action action) {
@@ -235,6 +256,12 @@ public RequestBuilder accessConfig(AccessConfig accessConfig) {
return this;
}
+ public RequestBuilder encryption(Encryption encryption) {
+
+ this.encryption = encryption;
+ return this;
+ }
+
public RequestBuilder flowTypeOverrides(Map flowTypeOverrides) {
this.flowTypeOverrides = flowTypeOverrides;
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
new file mode 100644
index 000000000000..bb4abaa64bbd
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Domain model for an operation path with an optional encryption flag.
+ *
+ * Used within {@link AllowedOperation} to represent a single path on which
+ * the operation is allowed, along with an encryption flag indicating whether
+ * the IS should expect/produce JWE-encrypted values for that specific path.
+ *
ADD operations: external service sends JWE-encrypted values to the IS.
+ *
REPLACE operations: both directions — outbound current value encrypted,
+ * inbound replacement value encrypted.
+ *
REMOVE operations: outbound current value encrypted in the expose event.
+ *
+ *
+ * @return {@code true} if JWE encryption is required for this path.
+ */
+ public boolean isEncrypted() {
+
+ return encrypted;
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
index 323458a451b6..8a38b6a0bc3f 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineDataHolder.java
@@ -21,6 +21,7 @@
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.application.mgt.ApplicationManagementService;
+import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService;
import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService;
import org.wso2.carbon.identity.event.services.IdentityEventService;
import org.wso2.carbon.identity.flow.execution.engine.graph.Executor;
@@ -51,6 +52,7 @@ public class FlowExecutionEngineDataHolder {
private IdentityEventService identityEventService;
private ActionExecutorService actionExecutorService;
private ActionManagementService actionManagementService;
+ private CertificateManagementService certificateManagementService;
private List flowExecutionListeners = new ArrayList<>();
private FlowExecutionEngineDataHolder() {
@@ -262,4 +264,24 @@ public void setActionManagementService(ActionManagementService actionManagementS
this.actionManagementService = actionManagementService;
}
+
+ /**
+ * Get the CertificateManagementService instance.
+ *
+ * @return CertificateManagementService instance.
+ */
+ public CertificateManagementService getCertificateManagementService() {
+
+ return certificateManagementService;
+ }
+
+ /**
+ * Set the CertificateManagementService instance.
+ *
+ * @param certificateManagementService CertificateManagementService instance.
+ */
+ public void setCertificateManagementService(CertificateManagementService certificateManagementService) {
+
+ this.certificateManagementService = certificateManagementService;
+ }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
index ed25143e86e7..cda3fcdc80cb 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/internal/FlowExecutionEngineServiceComponent.java
@@ -35,6 +35,7 @@
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.application.mgt.ApplicationManagementService;
+import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService;
import org.wso2.carbon.identity.claim.metadata.mgt.ClaimMetadataManagementService;
import org.wso2.carbon.identity.event.services.IdentityEventService;
import org.wso2.carbon.identity.flow.execution.engine.FlowExecutionService;
@@ -104,7 +105,9 @@ protected void activate(ComponentContext context) {
bundleContext.registerService(ActionConverter.class.getName(),
new InFlowExtensionActionConverter(), null);
bundleContext.registerService(ActionDTOModelResolver.class.getName(),
- new InFlowExtensionActionDTOModelResolver(), null);
+ new InFlowExtensionActionDTOModelResolver(
+ FlowExecutionEngineDataHolder.getInstance().getCertificateManagementService()),
+ null);
// Register flow update interceptor for access config overrides
bundleContext.registerService(FlowUpdateInterceptor.class.getName(),
@@ -254,25 +257,6 @@ public void unsetFederatedAssociationManager(FederatedAssociationManager federat
FlowExecutionEngineDataHolder.getInstance().setFederatedAssociationManager(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 Flow Engine component.");
- FlowExecutionEngineDataHolder.getInstance().setActionExecutorService(actionExecutorService);
- }
-
- protected void unsetActionExecutorService(ActionExecutorService actionExecutorService) {
-
- LOG.debug("Unsetting the ActionExecutorService in the Flow Engine component.");
- FlowExecutionEngineDataHolder.getInstance().setActionExecutorService(null);
- }
-
@Reference(
name = "ClaimMetadataManagementService",
service = ClaimMetadataManagementService.class,
@@ -329,4 +313,44 @@ protected void unsetActionExecutorService(ActionExecutorService actionExecutorSe
LOG.debug("Unsetting the ActionExecutorService in the Flow Engine component.");
FlowExecutionEngineDataHolder.getInstance().setActionExecutorService(null);
}
+
+ @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 Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance().setActionManagementService(actionManagementService);
+ }
+
+ protected void unsetActionManagementService(ActionManagementService actionManagementService) {
+
+ LOG.debug("Unsetting the ActionManagementService in the Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance().setActionManagementService(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 Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance()
+ .setCertificateManagementService(certificateManagementService);
+ }
+
+ protected void unsetCertificateManagementService(
+ CertificateManagementService certificateManagementService) {
+
+ LOG.debug("Unsetting the CertificateManagementService in the Flow Engine component.");
+ FlowExecutionEngineDataHolder.getInstance().setCertificateManagementService(null);
+ }
}
diff --git a/components/identity-core/org.wso2.carbon.identity.core/src/main/java/org/wso2/carbon/identity/core/util/IdentityKeyStoreResolverConstants.java b/components/identity-core/org.wso2.carbon.identity.core/src/main/java/org/wso2/carbon/identity/core/util/IdentityKeyStoreResolverConstants.java
index 0a8c7abddd7e..f78fe303f584 100644
--- a/components/identity-core/org.wso2.carbon.identity.core/src/main/java/org/wso2/carbon/identity/core/util/IdentityKeyStoreResolverConstants.java
+++ b/components/identity-core/org.wso2.carbon.identity.core/src/main/java/org/wso2/carbon/identity/core/util/IdentityKeyStoreResolverConstants.java
@@ -48,6 +48,7 @@ public class IdentityKeyStoreResolverConstants {
public static final String INBOUND_PROTOCOL_SAML = "saml";
public static final String INBOUND_PROTOCOL_WS_TRUST = "ws-trust";
public static final String INBOUND_PROTOCOL_WS_FEDERATION = "ws-federation";
+ public static final String INBOUND_PROTOCOL_ACTIONS = "actions";
/**
* Enums for inbound protocols.
@@ -58,7 +59,8 @@ public enum InboundProtocol {
OAUTH(INBOUND_PROTOCOL_OAUTH),
SAML(INBOUND_PROTOCOL_SAML),
WS_TRUST(INBOUND_PROTOCOL_WS_TRUST),
- WS_FEDERATION(INBOUND_PROTOCOL_WS_FEDERATION);
+ WS_FEDERATION(INBOUND_PROTOCOL_WS_FEDERATION),
+ ACTIONS(INBOUND_PROTOCOL_ACTIONS);
private final String protocolName;
@@ -81,6 +83,8 @@ public static InboundProtocol fromString(String protocolName) {
return WS_TRUST;
case INBOUND_PROTOCOL_WS_FEDERATION:
return WS_FEDERATION;
+ case INBOUND_PROTOCOL_ACTIONS:
+ return ACTIONS;
default:
return null;
}
From ffca806aa42df72d08cd31643ffd0d63b163324d Mon Sep 17 00:00:00 2001
From: ThejithaR
Date: Fri, 27 Mar 2026 08:47:11 +0530
Subject: [PATCH 08/41] path type annotation validations and encryption
---
.../management/api/constant/ErrorMessage.java | 2 +
.../api/service/ActionManagementService.java | 12 +
.../impl/ActionManagementServiceImpl.java | 39 ++
.../CacheBackedActionManagementService.java | 7 +
.../executor/InFlowExtensionExecutor.java | 27 +-
.../InFlowExtensionRequestBuilder.java | 234 ++-----
.../InFlowExtensionResponseProcessor.java | 386 ++---------
.../extension/executor/JWEEncryptionUtil.java | 142 ++---
.../executor/PathTypeAnnotationUtil.java | 393 ++++++++++++
.../AccessConfigFlowUpdateInterceptor.java | 77 +--
.../InFlowExtensionActionConstants.java | 6 +-
.../InFlowExtensionActionConverter.java | 41 +-
...InFlowExtensionActionDTOModelResolver.java | 184 ++----
.../inflow/extension/model/AccessConfig.java | 128 ++--
.../extension/model/AllowedOperation.java | 87 ---
.../{ExposePath.java => ContextPath.java} | 14 +-
.../inflow/extension/model/OperationPath.java | 70 --
.../FlowExecutionEngineServiceComponent.java | 3 -
.../InFlowExtensionRequestBuilderTest.java | 172 ++---
.../InFlowExtensionResponseProcessorTest.java | 385 +++++------
.../executor/PathTypeAnnotationUtilTest.java | 599 ++++++++++++++++++
.../engine/model/AccessConfigTest.java | 204 ++++++
.../model/InFlowExtensionEventTest.java | 126 ++++
.../model/OperationExecutionResultTest.java | 79 +++
.../src/test/resources/testng.xml | 8 +
.../IdentityKeyStoreResolverConstants.java | 6 +-
26 files changed, 2069 insertions(+), 1362 deletions(-)
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/PathTypeAnnotationUtil.java
delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
rename components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/{ExposePath.java => ContextPath.java} (75%)
delete mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/executor/PathTypeAnnotationUtilTest.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/model/AccessConfigTest.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/model/InFlowExtensionEventTest.java
create mode 100644 components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/test/java/org/wso2/carbon/identity/flow/execution/engine/model/OperationExecutionResultTest.java
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 30d998be18d2..ef1ceaf5b84d 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/constant/ErrorMessage.java
@@ -49,6 +49,8 @@ public enum ErrorMessage {
"Each attribute must be a non-empty string."),
ERROR_UNSUPPORTED_ATTRIBUTE("60014", "Unsupported attribute provided.",
"The attribute %s is not supported to be shared with the extension."),
+ ERROR_ACTION_NAME_ALREADY_EXISTS("60015", "Action name already exists.",
+ "An action with the name '%s' already exists for the given action type."),
// Server errors.
ERROR_WHILE_ADDING_ACTION("65001", "Error while adding Action.",
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java
index ac233ffdb0fc..cddc2d5548ed 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/api/service/ActionManagementService.java
@@ -118,6 +118,7 @@ Action updateAction(String actionType, String actionId, Action action, String te
/**
* Update the authentication of the action endpoint.
+
*
* @param actionType Action Type.
* @param actionId Action ID.
@@ -128,4 +129,15 @@ Action updateAction(String actionType, String actionId, Action action, String te
*/
Action updateActionEndpointAuthentication(String actionType, String actionId, Authentication authentication,
String tenantDomain) throws ActionMgtException;
+
+ /**
+ * Check whether the given action name is available (unique) within the specified action type.
+ *
+ * @param actionType Action Type path parameter.
+ * @param name Action name to check.
+ * @param tenantDomain Tenant domain.
+ * @return {@code true} if the name is available, {@code false} otherwise.
+ * @throws ActionMgtException If an error occurs while checking name availability.
+ */
+ boolean isActionNameAvailable(String actionType, String name, String tenantDomain) throws ActionMgtException;
}
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 619505c374d4..974e18b02b45 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
@@ -78,6 +78,8 @@ public Action addAction(String actionType, Action action, String tenantDomain) t
castedActionType, ActionManagementConfig.getInstance().getLatestVersion(castedActionType), action);
// Check whether the maximum allowed actions per type is reached.
validateMaxActionsPerType(resolvedActionType, tenantDomain);
+ // Check whether the action name is unique within the action type.
+ validateActionNameUniqueness(resolvedActionType, action.getName(), null, tenantId);
String generatedActionId = UUID.randomUUID().toString();
ActionDTO creatingActionDTO = buildActionDTOForCreation(resolvedActionType, generatedActionId, action);
@@ -161,6 +163,9 @@ public Action updateAction(String actionType, String actionId, Action action, St
Action.ActionTypes castedActionType = Action.ActionTypes.valueOf(resolvedActionType);
ActionValidatorFactory.getActionValidator(castedActionType).doPreUpdateActionValidations(
castedActionType, resolveActionVersionAtUpdating(action, existingActionDTO), action);
+ if (action.getName() != null) {
+ validateActionNameUniqueness(resolvedActionType, action.getName(), actionId, tenantId);
+ }
ActionDTO updatingActionDTO = buildActionDTOForUpdate(resolvedActionType, actionId, action);
DAO_FACADE.updateAction(updatingActionDTO, existingActionDTO, tenantId);
@@ -171,6 +176,17 @@ public Action updateAction(String actionType, String actionId, Action action, St
return buildAction(resolvedActionType, updatedActionDTO);
}
+ @Override
+ public boolean isActionNameAvailable(String actionType, String name, String tenantDomain)
+ throws ActionMgtException {
+
+ String resolvedActionType = getActionTypeFromPath(actionType);
+ int tenantId = IdentityTenantUtil.getTenantId(tenantDomain);
+ List actionDTOS = DAO_FACADE.getActionsByActionType(resolvedActionType, tenantId);
+ return actionDTOS.stream()
+ .noneMatch(dto -> name.equalsIgnoreCase(dto.getName()));
+ }
+
private String resolveActionVersionAtUpdating(Action updatingAction, ActionDTO existingActionDTO) {
String updatingActionVersion = updatingAction.getActionVersion();
@@ -353,6 +369,29 @@ private ActionDTO checkIfActionExists(String actionType, String actionId, String
return actionDTO;
}
+ /**
+ * Validate that the action name is unique within the given action type.
+ *
+ * @param actionType Action type.
+ * @param name Action name to validate.
+ * @param excludeId Action ID to exclude (for update). Null for creation.
+ * @param tenantId Tenant ID.
+ * @throws ActionMgtException If a duplicate name is found.
+ */
+ private void validateActionNameUniqueness(String actionType, String name, String excludeId, int tenantId)
+ throws ActionMgtException {
+
+ List existingActions = DAO_FACADE.getActionsByActionType(actionType, tenantId);
+ boolean duplicateExists = existingActions.stream()
+ .filter(dto -> excludeId == null || !excludeId.equals(dto.getId()))
+ .anyMatch(dto -> name.equalsIgnoreCase(dto.getName()));
+
+ if (duplicateExists) {
+ throw ActionManagementExceptionHandler.handleClientException(
+ ErrorMessage.ERROR_ACTION_NAME_ALREADY_EXISTS, name);
+ }
+ }
+
/**
* For action creation operation, builds an `ActionDTO` object based on the provided action type, action ID, and
* action model. This method resolves the action type and status, applies necessary transformations, and constructs
diff --git a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java
index 5e2e0dd0f3b6..6ab187ce2c6c 100644
--- a/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java
+++ b/components/action-mgt/org.wso2.carbon.identity.action.management/src/main/java/org/wso2/carbon/identity/action/management/internal/service/impl/CacheBackedActionManagementService.java
@@ -182,6 +182,13 @@ public Action updateActionEndpointAuthentication(String actionType, String actio
return updatedAction;
}
+ @Override
+ public boolean isActionNameAvailable(String actionType, String name, String tenantDomain)
+ throws ActionMgtException {
+
+ return ACTION_MGT_SERVICE.isActionNameAvailable(actionType, name, tenantDomain);
+ }
+
private void updateCache(Action action, ActionCacheEntry entry, ActionTypeCacheKey cacheKey, String tenantDomain) {
if (LOG.isDebugEnabled()) {
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
index 39a4d520bc1e..5f4fd60307bd 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionExecutor.java
@@ -18,8 +18,6 @@
package org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor;
-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.exception.ActionExecutionException;
@@ -59,9 +57,9 @@
*
Extract executor metadata: {@code actionId}.
*
Resolve access config from the action (with per-flow-type override support via
* {@link ActionManagementService}). Falls back to system defaults if unavailable.
- *
Build a minimal {@link FlowContext} containing only three entries:
- * the full {@link FlowExecutionContext}, the expose list, and the allowed operations
- * JSON. The request builder will use these to construct the filtered request.
+ *
Build a minimal {@link FlowContext} containing the full {@link FlowExecutionContext},
+ * the expose list, the access config, and the encryption config.
+ * The request builder will use these to construct the filtered request.
*
Invoke the external service via {@link ActionExecutorService}.
*
Map the {@link ActionExecutionStatus} to an {@link ExecutorResponse}.
* Context updates are already applied directly to the {@link FlowExecutionContext}
@@ -72,11 +70,9 @@ public class InFlowExtensionExecutor implements Executor {
private static final Log LOG = LogFactory.getLog(InFlowExtensionExecutor.class);
private static final String EXECUTOR_NAME = "ExtensionExecutor";
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public static final String FLOW_EXECUTION_CONTEXT_KEY = "flowExecutionContext";
public static final String EXPOSE_KEY = "expose";
- public static final String ALLOWED_OPERATIONS_KEY = "allowedOperations";
public static final String PATH_TYPE_ANNOTATIONS_KEY = "pathTypeAnnotations";
public static final String ACCESS_CONFIG_KEY = "accessConfig";
public static final String ENCRYPTION_KEY = "encryption";
@@ -109,7 +105,6 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE
Encryption encryption = resolveEncryptionFromAction(actionId, context);
List expose;
- String allowedOpsJson = null;
if (resolvedConfig != null && resolvedConfig.getExpose() != null) {
expose = resolvedConfig.getExposePaths();
@@ -118,24 +113,12 @@ public ExecutorResponse execute(FlowExecutionContext context) throws FlowEngineE
expose = new ArrayList<>(HierarchicalPrefixMatcher.DEFAULT_EXPOSE);
}
- if (resolvedConfig != null && resolvedConfig.getAllowedOperations() != null) {
- try {
- allowedOpsJson = OBJECT_MAPPER.writeValueAsString(resolvedConfig.getAllowedOperations());
- } catch (JsonProcessingException e) {
- LOG.error("Failed to serialize resolved allowed operations.", e);
- }
- }
-
FlowContext flowContext = FlowContext.create()
.add(FLOW_EXECUTION_CONTEXT_KEY, context)
.add(EXPOSE_KEY, expose);
- if (allowedOpsJson != null) {
- flowContext.add(ALLOWED_OPERATIONS_KEY, allowedOpsJson);
- }
-
- // Pass the full AccessConfig so request builder and response processor can access
- // per-path encryption flags for JWE encryption/decryption.
+ // Pass the full AccessConfig so request builder can derive allowed operations
+ // from modify paths, and response processor can check encryption flags.
if (resolvedConfig != null) {
flowContext.add(ACCESS_CONFIG_KEY, resolvedConfig);
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
index 71f6a648c222..918d3e633db8 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionRequestBuilder.java
@@ -18,9 +18,6 @@
package org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.wso2.carbon.identity.action.execution.api.exception.ActionExecutionRequestBuilderException;
@@ -39,6 +36,7 @@
import org.wso2.carbon.identity.core.util.IdentityTenantUtil;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AccessConfig;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.Encryption;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ContextPath;
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;
@@ -47,10 +45,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
/**
* This class is responsible for building the {@link ActionExecutionRequest} for In-Flow Extension
@@ -58,24 +53,13 @@
*
*
Responsibility: expose-based filtering and request construction.
* It receives a {@link FlowContext} containing the full {@link FlowExecutionContext}, the expose
- * list, and the allowed-operations JSON. It filters the {@link FlowExecutionContext} data according
- * to the expose configuration and maps the result into the {@link InFlowExtensionEvent} model.
+ * list, and the access config. It filters the {@link FlowExecutionContext} data according
+ * to the expose configuration and maps the result into the {@link InFlowExtensionEvent} model.
+ * Modify paths from the access config are converted to a single REPLACE {@link AllowedOperation}.
*/
public class InFlowExtensionRequestBuilder implements ActionExecutionRequestBuilder {
private static final Log LOG = LogFactory.getLog(InFlowExtensionRequestBuilder.class);
- private static final ObjectMapper objectMapper = new ObjectMapper();
- private static final TypeReference>> OPERATION_LIST_TYPE_REF =
- new TypeReference>>() { };
-
- /**
- * Regex pattern to match path type annotations.
- * Matches a trailing bracket expression at the end of a path:
- * - "[]" — denotes a string array value.
- * - "[field1, field2, field3[]]" — denotes a complex object array with a schema.
- * Group 1 captures the content inside the brackets (empty for simple arrays).
- */
- private static final Pattern PATH_ANNOTATION_PATTERN = Pattern.compile("\\[([^\\]]*)]$");
@Override
public ActionType getSupportedActionType() {
@@ -109,19 +93,13 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex
Encryption encryption = flowContext.getValue(
InFlowExtensionExecutor.ENCRYPTION_KEY, Encryption.class);
- // Build allowed operations first so REPLACE paths can augment the expose list.
- List allowedOperations = buildAllowedOperations(
- flowContext.getValue(InFlowExtensionExecutor.ALLOWED_OPERATIONS_KEY, String.class),
- flowContext, accessConfig);
+ // Build allowed operations from modify paths.
+ List allowedOperations = buildAllowedOperationsFromModify(
+ accessConfig, flowContext);
- // Augment expose with REPLACE and REMOVE paths so the external service can see
- // the current values it may replace or remove.
- expose = augmentExposeWithOperationPaths(expose, allowedOperations);
-
- // Determine certificate PEM for outbound encryption (if any paths are encrypted).
+ // Determine certificate PEM for outbound encryption.
String certificatePEM = null;
- if (accessConfig != null && accessConfig.hasAnyEncryptedPath()
- && encryption != null && encryption.getCertificate() != null) {
+ if (accessConfig != null && encryption != null && encryption.getCertificate() != null) {
certificatePEM = encryption.getCertificate().getCertificateContent();
}
@@ -136,115 +114,63 @@ public ActionExecutionRequest buildActionExecutionRequest(FlowContext flowContex
}
/**
- * Parse the allowed-operations JSON into typed {@link AllowedOperation} objects.
- * Handles the nested format where each entry is {@code {op, paths: [{path, encrypted}]}}.
- * Groups entries by operation type and strips path type annotations.
- * The original annotations are stored in the FlowContext under
- * {@link InFlowExtensionExecutor#PATH_TYPE_ANNOTATIONS_KEY} for the response processor.
- *
- * Encryption metadata is NOT extracted from the JSON. Instead, the {@link AccessConfig}
- * (already in FlowContext) is the single source of truth for per-path encryption flags.
- * The response processor uses {@code AccessConfig.isOperationPathEncrypted()} directly.
+ * Build allowed operations from the modify paths in {@link AccessConfig}.
+ * All modify paths are mapped to a single REPLACE {@link AllowedOperation}.
+ * Path type annotations (e.g. {@code []} or {@code [schema]}) are stripped and stored
+ * in the {@link FlowContext} under {@link InFlowExtensionExecutor#PATH_TYPE_ANNOTATIONS_KEY}
+ * for the response processor.
*
- * @param allowedOperationsJson The JSON string (maybe null).
- * @param flowContext The FlowContext to store path annotations.
- * @param accessConfig The access config (may be null). Reserved for future use.
- * @return List of AllowedOperation objects with clean (annotation-free) paths.
+ * @param accessConfig The access config containing modify paths (may be null).
+ * @param flowContext The FlowContext to store path annotations.
+ * @return A singleton list containing one REPLACE operation, or empty list if no modify paths.
*/
- private List buildAllowedOperations(String allowedOperationsJson,
- FlowContext flowContext,
- AccessConfig accessConfig) {
+ private List buildAllowedOperationsFromModify(AccessConfig accessConfig,
+ FlowContext flowContext) {
- if (allowedOperationsJson == null || allowedOperationsJson.isEmpty()) {
- LOG.debug("No allowed operations configured. Using empty list.");
+ if (accessConfig == null || accessConfig.getModify() == null || accessConfig.getModify().isEmpty()) {
return Collections.emptyList();
}
- try {
- List> operationConfigs = objectMapper.readValue(
- allowedOperationsJson, OPERATION_LIST_TYPE_REF);
-
- // Map to store path type annotations: cleanPath -> annotation content.
- Map pathTypeAnnotations = new HashMap<>();
+ List cleanPaths = new ArrayList<>();
+ Map pathTypeAnnotations = new HashMap<>();
- // Group entries by operation type, handling nested paths [{path, encrypted}].
- Map> groupedByOp = new HashMap<>();
- for (Map config : operationConfigs) {
- String opString = (String) config.get("op");
- Object pathsObj = config.get("paths");
+ for (ContextPath modifyPath : accessConfig.getModify()) {
+ String rawPath = modifyPath.getPath();
+ if (rawPath == null) {
+ continue;
+ }
- if (opString == null) {
- LOG.warn("Invalid allowed operation config: missing 'op'.");
- continue;
- }
- if (!(pathsObj instanceof List)) {
- LOG.warn("Invalid allowed operation config: missing or invalid 'paths'.");
+ String[] stripped = PathTypeAnnotationUtil.stripAnnotation(rawPath);
+ String cleanPath = stripped[0];
+ String annotation = stripped[1];
+ if (annotation != null) {
+ if (!PathTypeAnnotationUtil.validateAnnotationLimits(annotation)) {
+ LOG.warn("Annotation for path " + cleanPath
+ + " exceeds maximum attribute limit. Skipping path.");
continue;
}
-
- List> pathsList = (List>) pathsObj;
- for (Object pathEntry : pathsList) {
- String rawPath;
-
- if (pathEntry instanceof Map) {
- // Nested format: {path, encrypted} — only extract path here.
- // Encryption metadata is resolved from AccessConfig at runtime.
- Map, ?> pathMap = (Map, ?>) pathEntry;
- rawPath = (String) pathMap.get("path");
- } else if (pathEntry instanceof String) {
- rawPath = (String) pathEntry;
- } else {
- LOG.warn("Invalid path entry in allowed operation.");
- continue;
- }
-
- if (rawPath == null) {
- continue;
- }
-
- // Strip annotations and accumulate paths into groups.
- Matcher matcher = PATH_ANNOTATION_PATTERN.matcher(rawPath);
- String cleanPath;
- if (matcher.find()) {
- cleanPath = rawPath.substring(0, matcher.start());
- pathTypeAnnotations.put(cleanPath, matcher.group(1));
- } else {
- cleanPath = rawPath;
- }
-
- groupedByOp.computeIfAbsent(opString.toUpperCase(Locale.ENGLISH),
- k -> new ArrayList<>()).add(cleanPath);
- }
+ pathTypeAnnotations.put(cleanPath, annotation);
}
+ cleanPaths.add(cleanPath);
+ }
- // Build AllowedOperation objects from grouped entries.
- List allowedOperations = new ArrayList<>();
- for (Map.Entry> entry : groupedByOp.entrySet()) {
- try {
- Operation operation = Operation.valueOf(entry.getKey());
- AllowedOperation allowedOperation = new AllowedOperation();
- allowedOperation.setOp(operation);
- allowedOperation.setPaths(entry.getValue());
- allowedOperations.add(allowedOperation);
- } catch (IllegalArgumentException e) {
- LOG.warn("Unknown operation type: " + entry.getKey());
- }
- }
+ if (cleanPaths.isEmpty()) {
+ return Collections.emptyList();
+ }
- // Store annotations in FlowContext for the response processor.
- if (!pathTypeAnnotations.isEmpty()) {
- flowContext.add(InFlowExtensionExecutor.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations);
- }
+ AllowedOperation replaceOp = new AllowedOperation();
+ replaceOp.setOp(Operation.REPLACE);
+ replaceOp.setPaths(cleanPaths);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Built " + allowedOperations.size() + " allowed operations. " +
- "Path annotations: " + pathTypeAnnotations.size());
- }
- return allowedOperations;
- } catch (JsonProcessingException e) {
- LOG.error("Failed to parse allowed operations: " + e.getMessage(), e);
- return Collections.emptyList();
+ if (!pathTypeAnnotations.isEmpty()) {
+ flowContext.add(InFlowExtensionExecutor.PATH_TYPE_ANNOTATIONS_KEY, pathTypeAnnotations);
+ }
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Built REPLACE allowed operation with " + cleanPaths.size()
+ + " modify paths. Path annotations: " + pathTypeAnnotations.size());
}
+ return Collections.singletonList(replaceOp);
}
/**
@@ -448,56 +374,11 @@ private boolean hasSpecificSubPathFilter(List expose, String areaPrefix)
return false;
}
- /**
- * Augment the expose list with paths from REPLACE and REMOVE operations.
- * REPLACE operations require the current values to be visible to the external service
- * so it can decide what replacement to send. REMOVE operations require current values
- * to be visible so the external service knows what data would be removed.
- *
- * @param expose The current expose list.
- * @param allowedOperations The parsed allowed operations.
- * @return The augmented expose list (new list if modified, original if unchanged).
- */
- private List augmentExposeWithOperationPaths(List expose,
- List allowedOperations) {
-
- if (allowedOperations == null || allowedOperations.isEmpty()) {
- return expose;
- }
-
- List additionalPaths = new ArrayList<>();
- for (AllowedOperation op : allowedOperations) {
- if ((op.getOp() == Operation.REPLACE || op.getOp() == Operation.REMOVE)
- && op.getPaths() != null) {
- for (String path : op.getPaths()) {
- if (!isExposed(path, expose)) {
- additionalPaths.add(path);
- }
- }
- }
- }
-
- if (additionalPaths.isEmpty()) {
- return expose;
- }
-
- List augmented = new ArrayList<>(expose);
- augmented.addAll(additionalPaths);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Augmented expose list with REPLACE/REMOVE paths: " + additionalPaths);
- }
- return augmented;
- }
-
// ---- Encryption helpers ----
/**
- * Determine if a value at the given path should be JWE-encrypted before sending to the external service.
- *
- * Checks both the explicit expose config and the operation paths (REPLACE / REMOVE) that may
- * have been dynamically augmented into the expose list. REPLACE and REMOVE operation paths
- * carry their own encryption flags in the allowedOperations config — when such a path is
- * augmented into expose for visibility, its encryption flag must be honoured.
+ * 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.
@@ -509,14 +390,7 @@ private boolean shouldEncrypt(String path, AccessConfig accessConfig, String cer
if (certificatePEM == null || accessConfig == null) {
return false;
}
- // Check explicit expose paths first.
- if (accessConfig.isExposePathEncrypted(path)) {
- return true;
- }
- // Also check REPLACE and REMOVE operation paths that were augmented into expose.
- // These paths may have their own encryption flags in the allowedOperations config.
- return accessConfig.isOperationPathEncrypted("REPLACE", path)
- || accessConfig.isOperationPathEncrypted("REMOVE", path);
+ return accessConfig.isExposePathEncrypted(path);
}
/**
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
index 02650d7fa231..707334fb1c9b 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/InFlowExtensionResponseProcessor.java
@@ -36,7 +36,6 @@
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.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;
@@ -56,7 +55,6 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -64,21 +62,19 @@
* This class is responsible for processing the response from In-Flow Extension actions.
*
*
Responsibility: operation processing and applying context updates directly to
- * the {@link FlowExecutionContext}. It processes operations (ADD, REMOVE, REPLACE) on flow
+ * the {@link FlowExecutionContext}. It processes REPLACE operations on flow
* properties, user claims, and user inputs.
*
- *
The {@code allowedOperations} list (sent to the external service in the request and enforced
- * upstream by {@code ActionExecutorServiceImpl}) is the sole mechanism for gating which operations
- * are permitted. This processor performs two additional validations:
+ *
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/} or {@code /graph/}.
- *
Claim URI validation: For ADD operations on {@code /user/claims/}, validates that
- * the claim URI exists in the system via {@code ClaimMetadataManagementService}.
+ *
Claim URI validation: For {@code /user/claims/} operations, validates that
+ * the claim URI exists in the local claim dialect and is not an identity claim.
*
*/
-
-// TODO: Consider separating claim validation and read-only path checks into utility classes.
-
public class InFlowExtensionResponseProcessor implements ActionExecutionResponseProcessor {
private static final Log LOG = LogFactory.getLog(InFlowExtensionResponseProcessor.class);
@@ -90,9 +86,6 @@ public class InFlowExtensionResponseProcessor implements ActionExecutionResponse
// Legacy prefix for backward compatibility
private static final String LEGACY_USER_INPUTS_PATH_PREFIX = "/userInputs/";
- private static final char PATH_SEPARATOR = '/';
- private static final String LAST_ELEMENT_CHARACTER = "-";
-
// Cache for valid claim URIs (per tenant)
private Map> validClaimUrisCache = new HashMap<>();
@@ -113,8 +106,7 @@ public ActionExecutionStatus processSuccessResponse(FlowContext flowCon
String tenantDomain = execCtx != null ? execCtx.getTenantDomain() : null;
// Read path type annotations set by the request builder.
- // Maps clean paths (e.g., "/properties/riskFactors") to annotation content
- // ("" for string arrays, or schema content for complex object arrays).
+ // Maps clean paths to annotation content
Map pathTypeAnnotations = flowContext.getValue(
InFlowExtensionExecutor.PATH_TYPE_ANNOTATIONS_KEY, Map.class);
if (pathTypeAnnotations == null) {
@@ -122,13 +114,12 @@ public ActionExecutionStatus processSuccessResponse(FlowContext flowCon
}
// Read access config for encryption metadata.
- // Uses AccessConfig.isOperationPathEncrypted() for canonical encryption checking.
+ // Uses AccessConfig.isModifyPathEncrypted() for canonical encryption checking.
AccessConfig accessConfig = flowContext.getValue(
InFlowExtensionExecutor.ACCESS_CONFIG_KEY, AccessConfig.class);
List results = new ArrayList<>();
- // Get operations from the response (already filtered by ActionExecutorServiceImpl).
List operations =
responseContext.getActionInvocationResponse().getOperations();
@@ -198,269 +189,66 @@ private OperationExecutionResult processOperation(PerformableOperation operation
", " + USER_CLAIMS_PATH_PREFIX + ", " + USER_INPUTS_PATH_PREFIX);
}
- // ========================= Property operations =========================
-
/**
- * Handle operations on flow properties — apply directly to {@link FlowExecutionContext}.
- *
- *
Supports terminal paths with nested segments. For example:
{@code /properties/risk_margin/lower_margin} → nested: sets "lower_margin" inside
- * a "risk_margin" Map, auto-creating the parent Map if needed.
- *
- *
- *
Value type rules (based on path type annotations from allowed operations):
- *
- *
No annotation: value must be a string (or convertible via String.valueOf).
- *
{@code []} annotation: value must be a List of strings.
- *
{@code [schema]} annotation: value must be a List of objects matching the schema.
- *
+ * Handle operation on flow properties — apply directly to {@link FlowExecutionContext}.
*
- *
REPLACE validation: the target path must already exist in the context.
+ *
Only flat property paths are supported (e.g., {@code /properties/riskScore}).
+ * The property is created if it does not already exist. Value coercion is applied
+ * based on path type annotations from the request builder via
+ * {@link PathTypeAnnotationUtil#coerceValue}.
*
* @param operation The performable operation.
* @param context The FlowExecutionContext.
* @param pathTypeAnnotations Path type annotations map from request builder.
*/
- @SuppressWarnings("unchecked")
private OperationExecutionResult handlePropertyOperation(PerformableOperation operation,
FlowExecutionContext context, Map pathTypeAnnotations) {
- String remaining = extractNameFromPath(operation.getPath(), PROPERTIES_PATH_PREFIX);
+ String propertyName = extractNameFromPath(operation.getPath(), PROPERTIES_PATH_PREFIX);
- if (remaining == null || remaining.isEmpty()) {
+ if (propertyName == null || propertyName.isEmpty()) {
return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
"Invalid property path. Property name is required.");
}
- // Split into path segments for nested property support.
- // e.g., "risk_margin/lower_margin" -> ["risk_margin", "lower_margin"]
- String[] segments = remaining.split("/");
-
- switch (operation.getOp()) {
- case ADD:
- return handlePropertyAdd(operation, context, segments, pathTypeAnnotations);
-
- case REPLACE:
- return handlePropertyReplace(operation, context, segments, pathTypeAnnotations);
-
- case REMOVE:
- return handlePropertyRemove(operation, context, segments);
-
- default:
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Unsupported operation: " + operation.getOp());
- }
- }
-
- /**
- * Handle ADD operation on properties. Auto-creates parent Maps for nested paths.
- */
- @SuppressWarnings("unchecked")
- private OperationExecutionResult handlePropertyAdd(PerformableOperation operation,
- FlowExecutionContext context, String[] segments,
- Map pathTypeAnnotations) {
-
- if (operation.getValue() == null) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Value is required for ADD operation.");
- }
-
- // Coerce value based on path type annotation.
- Object coercedValue = coerceValue(operation.getPath(), operation.getValue(), pathTypeAnnotations);
-
- if (segments.length == 1) {
- // Flat property: /properties/riskScore -> setProperty("riskScore", value)
- context.setProperty(segments[0], coercedValue);
- } else {
- // Nested property: /properties/risk_margin/lower_margin
- // Auto-create parent Map(s) and set the leaf value.
- setNestedProperty(context, segments, coercedValue);
- }
-
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "Property add applied.");
- }
-
- /**
- * Handle REPLACE operation on properties. Validates that the target path exists.
- */
- @SuppressWarnings("unchecked")
- private OperationExecutionResult handlePropertyReplace(PerformableOperation operation,
- FlowExecutionContext context, String[] segments,
- Map pathTypeAnnotations) {
-
if (operation.getValue() == null) {
return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
"Value is required for REPLACE operation.");
}
- // Validate that the target path exists before replacing.
- if (segments.length == 1) {
- if (!context.getProperties().containsKey(segments[0])) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Cannot REPLACE: property '" + segments[0] + "' does not exist in context.");
- }
- } else {
- // For nested paths, validate the full path exists.
- Object existing = resolveNestedProperty(context, segments);
- if (existing == null) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Cannot REPLACE: nested property path '" + String.join("/", segments) +
- "' does not exist in context.");
- }
+ // 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 = coerceValue(operation.getPath(), operation.getValue(), pathTypeAnnotations);
+ Object coercedValue = PathTypeAnnotationUtil.coerceValue(
+ operation.getPath(), operation.getValue(), pathTypeAnnotations);
- if (segments.length == 1) {
- context.setProperty(segments[0], coercedValue);
- } else {
- setNestedProperty(context, segments, coercedValue);
+ // 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());
}
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "Property replace applied.");
- }
-
- /**
- * Handle REMOVE operation on properties.
- */
- @SuppressWarnings("unchecked")
- private OperationExecutionResult handlePropertyRemove(PerformableOperation operation,
- FlowExecutionContext context, String[] segments) {
-
- if (segments.length == 1) {
- context.getProperties().remove(segments[0]);
- } else {
- // For nested paths, remove the leaf key from the parent Map.
- removeNestedProperty(context, segments);
- }
+ context.setProperty(propertyName, coercedValue);
return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "Property removed.");
- }
-
- /**
- * Set a value at a nested path in the properties map, auto-creating parent Maps.
- * e.g., segments=["risk_margin", "lower_margin"], value="20"
- * Creates: properties.risk_margin = { lower_margin: "20" }
- */
- @SuppressWarnings("unchecked")
- private void setNestedProperty(FlowExecutionContext context, String[] segments, Object value) {
-
- Map current = context.getProperties();
-
- // Navigate/create parent maps for all segments except the last.
- for (int i = 0; i < segments.length - 1; i++) {
- Object child = current.get(segments[i]);
- if (child instanceof Map) {
- current = (Map) child;
- } else {
- // Auto-create parent Map.
- Map newMap = new HashMap<>();
- current.put(segments[i], newMap);
- current = newMap;
- }
- }
-
- // Set the leaf value.
- current.put(segments[segments.length - 1], value);
- }
-
- /**
- * Resolve a nested property path, returning the leaf value or null if any segment is missing.
- */
- @SuppressWarnings("unchecked")
- private Object resolveNestedProperty(FlowExecutionContext context, String[] segments) {
-
- Map current = context.getProperties();
-
- for (int i = 0; i < segments.length - 1; i++) {
- Object child = current.get(segments[i]);
- if (child instanceof Map) {
- current = (Map) child;
- } else {
- return null;
- }
- }
-
- return current.get(segments[segments.length - 1]);
- }
-
- /**
- * Remove a leaf key from a nested property path.
- */
- @SuppressWarnings("unchecked")
- private void removeNestedProperty(FlowExecutionContext context, String[] segments) {
-
- Map current = context.getProperties();
-
- for (int i = 0; i < segments.length - 1; i++) {
- Object child = current.get(segments[i]);
- if (child instanceof Map) {
- current = (Map) child;
- } else {
- return; // Parent doesn't exist, nothing to remove.
- }
- }
-
- current.remove(segments[segments.length - 1]);
+ "Property replace applied.");
}
/**
- * Coerce a value based on path type annotations.
+ * Handle REPLACE operation on user claims — validate and apply directly to {@link FlowUser}.
*
+ *
Validates that the claim URI:
*
- *
No annotation: value is coerced to String via String.valueOf().
- *
"" annotation (from []): value is expected to be a List; each element is coerced to String.
- *
Non-empty annotation (from [schema]): value is expected to be a List of objects;
- * passed through as-is (schema validation can be added later).
+ *
Exists in the local claim dialect (via {@link ClaimMetadataManagementService}).
+ *
Is not an identity claim ({@code http://wso2.org/claims/identity/}).
*
- *
- * @param path The operation path.
- * @param value The raw value from the operation.
- * @param pathTypeAnnotations Path type annotations map.
- * @return The coerced value.
- */
- @SuppressWarnings("unchecked")
- private Object coerceValue(String path, Object value,
- Map pathTypeAnnotations) {
-
- String annotation = pathTypeAnnotations.get(path);
-
- if (annotation == null) {
- // No annotation: coerce to String.
- return String.valueOf(value);
- }
-
- if (annotation.isEmpty()) {
- // [] annotation: expect a List of strings.
- if (value instanceof List) {
- List rawList = (List) value;
- List stringList = new ArrayList<>();
- for (Object item : rawList) {
- stringList.add(String.valueOf(item));
- }
- return stringList;
- }
- // Single value — wrap in a list.
- List singleList = new ArrayList<>();
- singleList.add(String.valueOf(value));
- return singleList;
- }
-
- // [schema] annotation: pass through as-is for now.
- // TODO: Add schema validation for complex object arrays.
- return value;
- }
-
- // ========================= User claim operations =========================
-
- /**
- * Handle operations on user claims — validate and apply directly to {@link FlowUser}.
+ *
The value is always stringified via {@code String.valueOf()}.
*/
private OperationExecutionResult handleUserClaimOperation(PerformableOperation operation,
FlowExecutionContext context, String tenantDomain) {
@@ -472,12 +260,16 @@ private OperationExecutionResult handleUserClaimOperation(PerformableOperation o
"Invalid claim path. Claim URI is required.");
}
- // For ADD operations, validate that the claim URI exists in system configuration.
- if (operation.getOp() == Operation.ADD) {
- if (!isValidClaimUri(claimUri, tenantDomain)) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Invalid claim URI. Claim must be configured in the system: " + claimUri);
- }
+ // Reject identity claims — these are system-managed and not user-modifiable.
+ if (claimUri.startsWith(PathTypeAnnotationUtil.IDENTITY_CLAIM_URI_PREFIX)) {
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
+ "Identity claims cannot be modified via extensions: " + claimUri);
+ }
+
+ // Validate claim exists in local claim dialect.
+ if (!isValidClaimUri(claimUri, tenantDomain)) {
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
+ "Invalid claim URI. Claim must be configured in the local claim dialect: " + claimUri);
}
FlowUser user = context.getFlowUser();
@@ -486,32 +278,19 @@ private OperationExecutionResult handleUserClaimOperation(PerformableOperation o
"No FlowUser in FlowExecutionContext. Cannot apply user claim operation.");
}
- switch (operation.getOp()) {
- case ADD:
- case REPLACE:
- if (operation.getValue() == null) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Value is required for " + operation.getOp() + " operation.");
- }
- user.addClaim(claimUri, String.valueOf(operation.getValue()));
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "User claim " + operation.getOp().name().toLowerCase(Locale.ENGLISH) + " applied.");
-
- case REMOVE:
- if (user.getClaims() != null) {
- user.getClaims().remove(claimUri);
- }
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "User claim removed.");
-
- default:
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Unsupported operation: " + operation.getOp());
+ if (operation.getValue() == null) {
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
+ "Value is required for REPLACE operation.");
}
+
+ user.addClaim(claimUri, String.valueOf(operation.getValue()));
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
+ "User claim replace applied.");
}
/**
- * Handle operations on user inputs — apply directly to {@link FlowExecutionContext}.
+ * Handle REPLACE operation on user inputs — apply directly to {@link FlowExecutionContext}.
+ * The value is always stringified via {@code String.valueOf()}.
*/
private OperationExecutionResult handleUserInputOperation(PerformableOperation operation,
FlowExecutionContext context) {
@@ -523,30 +302,19 @@ private OperationExecutionResult handleUserInputOperation(PerformableOperation o
"Invalid user input path. Input name is required.");
}
- switch (operation.getOp()) {
- case ADD:
- case REPLACE:
- if (operation.getValue() == null) {
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Value is required for " + operation.getOp() + " operation.");
- }
- context.addUserInputData(inputName, String.valueOf(operation.getValue()));
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "User input " + operation.getOp().name().toLowerCase(Locale.ENGLISH) + " applied.");
-
- case REMOVE:
- context.getUserInputData().remove(inputName);
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
- "User input removed.");
-
- default:
- return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
- "Unsupported operation: " + operation.getOp());
+ if (operation.getValue() == null) {
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.FAILURE,
+ "Value is required for REPLACE operation.");
}
+
+ context.addUserInputData(inputName, String.valueOf(operation.getValue()));
+ return new OperationExecutionResult(operation, OperationExecutionResult.Status.SUCCESS,
+ "User input replace applied.");
}
+ //TODO: These validations can be removed once the attribute executor introduced.
/**
- * Validate if a claim URI exists in system configuration.
+ * Validate if a claim URI exists in the local claim dialect.
*/
private boolean isValidClaimUri(String claimUri, String tenantDomain) {
@@ -610,28 +378,9 @@ private String extractNameFromPath(String path, String prefix) {
remaining = remaining.substring(0, remaining.length() - 1);
}
- // Handle array index notation (e.g., /properties/items/0).
- int lastSlash = remaining.lastIndexOf(PATH_SEPARATOR);
- if (lastSlash > 0) {
- String possibleIndex = remaining.substring(lastSlash + 1);
- if (LAST_ELEMENT_CHARACTER.equals(possibleIndex) || isNumeric(possibleIndex)) {
- return remaining.substring(0, lastSlash);
- }
- }
-
return remaining;
}
- private boolean isNumeric(String str) {
-
- try {
- Integer.parseInt(str);
- return true;
- } catch (NumberFormatException e) {
- return false;
- }
- }
-
@Override
public ActionExecutionStatus processErrorResponse(FlowContext flowContext,
ActionExecutionResponseContext responseContext)
@@ -721,7 +470,7 @@ public static class InFlowExtensionSuccess implements Success {
/**
* Decrypt the operation value if the operation path has encryption enabled in the AccessConfig.
- * Uses {@link AccessConfig#isOperationPathEncrypted(String, String)} for canonical checking.
+ * 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.
*
@@ -737,16 +486,13 @@ private PerformableOperation decryptOperationValueIfNeeded(PerformableOperation
return operation;
}
- // Check if this operation path has encryption enabled via AccessConfig.
- if (!accessConfig.isOperationPathEncrypted(operation.getOp().name(), operation.getPath())) {
+ // Check if this operation path has encryption enabled via modify paths in AccessConfig.
+ if (!accessConfig.isModifyPathEncrypted(operation.getPath())) {
return operation;
}
// Only decrypt string values that look like JWE compact serialization.
Object value = operation.getValue();
- if (!(value instanceof String)) {
- return operation;
- }
String stringValue = (String) value;
if (!JWEEncryptionUtil.isJWEEncrypted(stringValue)) {
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
index a9ba41c0b470..bfe9813199e5 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/JWEEncryptionUtil.java
@@ -29,9 +29,10 @@
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.IdentityKeyStoreResolver;
-import org.wso2.carbon.identity.core.util.IdentityKeyStoreResolverConstants;
+import org.wso2.carbon.identity.core.util.IdentityTenantUtil;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
@@ -41,6 +42,8 @@
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.text.ParseException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Utility class for JWE encryption and decryption operations used by In-Flow Extension actions.
@@ -50,9 +53,8 @@
* pattern as {@code PasswordUpdatingUser.encryptCredential()}.
*
*
Inbound decryption (external service → IS): uses the IS server's RSA private key
- * retrieved via {@link IdentityKeyStoreResolver} with
- * {@link IdentityKeyStoreResolverConstants.InboundProtocol#ACTIONS}. This follows the same
- * pattern as {@code OIDCSessionManagementUtil.decryptWithRSA()}.
+ * retrieved via {@link KeyStoreManager} for the tenant. This follows the same
+ * pattern as {@code UserAssertionUtils.getPrivateKey()}.
*/
public final class JWEEncryptionUtil {
@@ -64,6 +66,9 @@ public final class JWEEncryptionUtil {
/** Standard PEM line length per RFC 7468. */
private static final int PEM_LINE_LENGTH = 64;
+ /** Cache of resolved private keys, keyed by tenant ID. */
+ private static final Map PRIVATE_KEYS = new ConcurrentHashMap<>();
+
private JWEEncryptionUtil() {
}
@@ -99,8 +104,7 @@ public static String encrypt(String plaintext, String certificatePEM) throws Act
/**
* Decrypt a JWE compact string using the IS server's RSA private key.
- * The private key is resolved via {@link IdentityKeyStoreResolver} using
- * {@link IdentityKeyStoreResolverConstants.InboundProtocol#ACTIONS}.
+ * 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.
@@ -112,9 +116,7 @@ public static String decrypt(String jweString, String tenantDomain) throws Actio
try {
JWEObject jweObject = JWEObject.parse(jweString);
- Key privateKey = IdentityKeyStoreResolver.getInstance()
- .getPrivateKey(tenantDomain,
- IdentityKeyStoreResolverConstants.InboundProtocol.ACTIONS);
+ Key privateKey = getPrivateKey(tenantDomain);
RSADecrypter decrypter = new RSADecrypter((RSAPrivateKey) privateKey);
jweObject.decrypt(decrypter);
@@ -126,12 +128,52 @@ public static String decrypt(String jweString, String tenantDomain) throws Actio
} 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);
+ Key cachedKey = PRIVATE_KEYS.get(cacheKey);
+ if (cachedKey != null) {
+ return cachedKey;
+ }
+
+ 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, 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.
@@ -158,77 +200,31 @@ public static boolean isJWEEncrypted(String value) {
}
/**
- * Parse a PEM-encoded X.509 certificate string into an {@link X509Certificate} object.
- *
- * Handles PEM strings that may have lost their newlines during database storage/retrieval
- * (e.g., via {@code CertificateManagementDAOImpl.getStringValueFromBlob()} which strips
- * line breaks). The method normalizes the PEM format before parsing to ensure the
- * header/footer are on separate lines and Base64 content is properly structured.
- *
+ * 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 pem The PEM string (with or without proper line breaks).
+ * @param base64EncodedPem The Base64 encoded PEM string.
* @return The parsed X509Certificate.
- * @throws ActionExecutionException If parsing fails.
+ * @throws ActionExecutionException If parsing or decoding fails.
*/
- public static X509Certificate parsePEMCertificate(String pem) throws ActionExecutionException {
+ public static X509Certificate parsePEMCertificate(String base64EncodedPem) throws ActionExecutionException {
- try {
- String normalizedPEM = normalizePEM(pem);
- CertificateFactory factory = CertificateFactory.getInstance("X.509");
- return (X509Certificate) factory.generateCertificate(
- new ByteArrayInputStream(normalizedPEM.getBytes(StandardCharsets.UTF_8)));
- } catch (ActionExecutionException e) {
- throw e;
- } catch (Exception e) {
- throw new ActionExecutionException(
- "Failed to parse PEM certificate for In-Flow Extension action.", e);
- }
- }
-
- /**
- * Normalize a PEM string to ensure it has proper line structure.
- *
- * The CertificateManagementService's DAO layer reads BLOB data using
- * {@code BufferedReader.readLine()} + {@code StringBuilder.append(line)}, which strips
- * all newline characters. This produces a single-line string like:
- * {@code -----BEGIN CERTIFICATE-----MIICxjCC...-----END CERTIFICATE-----}
- * which Java's X509 parser cannot handle.
- *
- *
- * This method extracts the Base64 body, strips all whitespace, and re-wraps it at
- * 64-character lines between proper PEM header/footer markers.
- *
- *
- * @param pem The PEM string (possibly with stripped newlines).
- * @return A properly formatted PEM string.
- * @throws ActionExecutionException If the PEM structure is invalid.
- */
- private static String normalizePEM(String pem) throws ActionExecutionException {
-
- if (pem == null || pem.isEmpty()) {
- throw new ActionExecutionException("Certificate PEM string is null or empty.");
+ if (base64EncodedPem == null || base64EncodedPem.trim().isEmpty()) {
+ throw new ActionExecutionException("Certificate string is null or empty.");
}
- String trimmed = pem.trim();
-
- // Extract the Base64 body by stripping header/footer markers.
- String base64Body = trimmed
- .replace("-----BEGIN CERTIFICATE-----", "")
- .replace("-----END CERTIFICATE-----", "")
- .replaceAll("\\s+", "");
+ try {
+ byte[] decodedPemBytes = java.util.Base64.getDecoder().decode(base64EncodedPem.trim());
- if (base64Body.isEmpty()) {
- throw new ActionExecutionException("Certificate PEM contains no Base64 data.");
- }
+ CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decodedPemBytes));
- // Reconstruct proper PEM with 64-char line wrapping (RFC 7468).
- StringBuilder sb = new StringBuilder();
- sb.append("-----BEGIN CERTIFICATE-----\n");
- for (int i = 0; i < base64Body.length(); i += PEM_LINE_LENGTH) {
- sb.append(base64Body, i, Math.min(i + PEM_LINE_LENGTH, base64Body.length()));
- sb.append('\n');
+ } 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);
}
- sb.append("-----END CERTIFICATE-----\n");
- return sb.toString();
}
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/PathTypeAnnotationUtil.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/PathTypeAnnotationUtil.java
new file mode 100644
index 000000000000..6e63a3108bee
--- /dev/null
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/executor/PathTypeAnnotationUtil.java
@@ -0,0 +1,393 @@
+/*
+ * Copyright (c) 2025, 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.execution.engine.inflow.extension.executor;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility class for path type annotation parsing, stripping, and value coercion.
+ *
+ *
Path type annotations use a trailing brace expression at the end of a modify path
+ * to declare the expected data type for that path. The unified format uses curly braces:
+ *
+ *
{@code /properties/risk-factor{String}} — primary data type.
+ *
{@code /properties/risk-factors{[String]}} — multivalued primary (array of type).
+ *
{@code /properties/risk{risk: Float, factor: String}} — complex object with schema.
This class provides methods to strip annotations from paths and coerce incoming values
+ * based on the stored annotations.
+ */
+public final class PathTypeAnnotationUtil {
+
+ /**
+ * Regex pattern to match a trailing curly brace annotation at the end of a path.
+ * Captures the content inside the braces (Group 1).
+ * Examples: {@code {String}}, {@code {[String]}}, {@code {risk: Float, factor: String}}.
+ */
+ static final Pattern ANNOTATION_PATTERN = Pattern.compile("\\{([^}]*)}$");
+
+ /** Claim URI prefix for the WSO2 local claim dialect. */
+ static final String LOCAL_CLAIM_DIALECT_PREFIX = "http://wso2.org/claims/";
+
+ /** Claim URI prefix for WSO2 identity claims (subset of local claims, not user-modifiable). */
+ static final String IDENTITY_CLAIM_URI_PREFIX = "http://wso2.org/claims/identity/";
+
+ /** Reusable ObjectMapper for parsing JSON-string values received for complex-typed paths. */
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private PathTypeAnnotationUtil() {
+
+ // Utility class, no instantiation.
+ }
+
+ /**
+ * Strip a trailing path type annotation from a raw path.
+ *
+ * @param rawPath The raw path potentially containing a trailing {@code {annotation}}.
+ * @return A two-element array: {@code [cleanPath, annotation]}.
+ * If no annotation is found, annotation element is {@code null}.
+ */
+ public static String[] stripAnnotation(String rawPath) {
+
+ if (rawPath == null) {
+ return new String[]{null, null};
+ }
+
+ Matcher matcher = ANNOTATION_PATTERN.matcher(rawPath);
+ if (matcher.find()) {
+ String cleanPath = rawPath.substring(0, matcher.start());
+ String annotation = matcher.group(1);
+ return new String[]{cleanPath, annotation};
+ }
+ return new String[]{rawPath, null};
+ }
+
+ /**
+ * Coerce a value based on path type annotations.
+ *
+ *
Annotation interpretation:
+ *
+ *
{@code null} (no annotation): value is coerced to String via {@code String.valueOf()}.
+ *
Starts with {@code [} and contains {@code :} (e.g., {@code [risk: Float]}):
+ * complex object array — value is passed through as-is.
+ *
Starts with {@code [} without {@code :} (e.g., {@code [String]}):
+ * multivalued primary type — value is expected to be a List; each element coerced to String.
+ * A single value is wrapped into a list.
+ *
Contains {@code :} (e.g., {@code risk: Float, factor: String}):
+ * complex object — value is passed through as-is.
+ *
Any other annotation (e.g., {@code String}, {@code Integer}):
+ * primary type — value is coerced to String via {@code String.valueOf()}.
+ *
+ *
+ * @param path The operation path (used as lookup key in annotations map).
+ * @param value The raw value from the operation.
+ * @param pathTypeAnnotations Map from clean path to annotation content (may be empty).
+ * @return The coerced value.
+ */
+ @SuppressWarnings("unchecked")
+ public static Object coerceValue(String path, Object value,
+ Map pathTypeAnnotations) {
+
+ String annotation = pathTypeAnnotations.get(path);
+
+ if (annotation == null) {
+ // No annotation: coerce to String.
+ return String.valueOf(value);
+ }
+
+ // Check for multivalued annotation: starts with [
+ if (annotation.startsWith("[")) {
+ String inner = annotation.substring(1, annotation.length() - 1);
+ if (inner.contains(":")) {
+ // Complex object array (e.g., [risk: Float, factor: String]):
+ // parse JSON string if needed, then pass through.
+ return tryParseJsonString(value);
+ }
+ // Multivalued primary type (e.g., [String], [Integer]): coerce to List.
+ // Parse JSON string first in case the value arrived as "[\"a\",\"b\"]".
+ Object resolvedList = tryParseJsonString(value);
+ if (resolvedList instanceof List) {
+ List rawList = (List) resolvedList;
+ List stringList = new ArrayList<>();
+ for (Object item : rawList) {
+ stringList.add(String.valueOf(item));
+ }
+ return stringList;
+ }
+ // Single value — wrap in a list.
+ List singleList = new ArrayList<>();
+ singleList.add(String.valueOf(value));
+ return singleList;
+ }
+
+ // Check for complex object annotation: contains ":"
+ if (annotation.contains(":")) {
+ // Complex object (e.g., risk: Float, factor: String):
+ // parse JSON string if needed, then pass through.
+ return tryParseJsonString(value);
+ }
+
+ // Primary type annotation (e.g., String, Integer, Boolean): coerce to String.
+ return String.valueOf(value);
+ }
+
+ /** Maximum number of attributes allowed in a complex object annotation. */
+ static final int MAX_ATTRIBUTES_PER_OBJECT = 10;
+
+ /** Maximum number of items allowed in an array (primary or complex object array). */
+ static final int MAX_ARRAY_ITEMS = 10;
+
+ /**
+ * Validate that a complex object annotation does not exceed the maximum attribute count.
+ * Should be called on the raw annotation content (inside braces) before stripping.
+ *
+ * @param annotation The annotation content (e.g., {@code "risk: Float, factor: String"}
+ * or {@code "[risk: Float, factor: String]"}). May be {@code null}.
+ * @return {@code true} if the annotation is valid (within limits or not a complex annotation).
+ */
+ public static boolean validateAnnotationLimits(String annotation) {
+
+ if (annotation == null || annotation.isEmpty()) {
+ return true;
+ }
+
+ String inner = annotation;
+ // Unwrap array brackets if present.
+ if (inner.startsWith("[") && inner.endsWith("]")) {
+ inner = inner.substring(1, inner.length() - 1);
+ }
+
+ // Only validate complex annotations (those with attribute definitions containing ':').
+ if (!inner.contains(":")) {
+ return true;
+ }
+
+ return parseAnnotationAttributes(inner).size() <= MAX_ATTRIBUTES_PER_OBJECT;
+ }
+
+ /**
+ * Validate a complex object value against its path type annotation schema.
+ *
+ *
Validates:
+ *
+ *
Value is a Map with attribute names matching the annotation schema.
+ *
Only one nesting level: attributes must be primary types or primary arrays.
+ *
Attribute count does not exceed {@link #MAX_ATTRIBUTES_PER_OBJECT}.
+ *
Array attributes do not exceed {@link #MAX_ARRAY_ITEMS} items.
+ *
+ *
+ *
For non-complex annotations (primary types, primary arrays), this method returns
+ * {@code true} without further validation since those are handled by coercion.
+ *
+ * @param path The operation path.
+ * @param value The value to validate.
+ * @param pathTypeAnnotations Map from clean path to annotation content.
+ * @return {@code true} if the value is valid against the annotation, {@code false} otherwise.
+ */
+ @SuppressWarnings("unchecked")
+ public static boolean validateValueAgainstAnnotation(String path, Object value,
+ Map pathTypeAnnotations) {
+
+ String annotation = pathTypeAnnotations.get(path);
+ if (annotation == null) {
+ return true;
+ }
+
+ boolean isArray = annotation.startsWith("[");
+ String inner = isArray ? annotation.substring(1, annotation.length() - 1) : annotation;
+
+ // Only validate complex annotations (those with attribute definitions).
+ if (!inner.contains(":")) {
+ return true;
+ }
+
+ Map schema = parseAnnotationAttributes(inner);
+
+ // Parse JSON string if the value arrived as serialized JSON
+ // (e.g., after JWE decryption or when the external service stringifies before encrypting).
+ Object resolvedValue = tryParseJsonString(value);
+
+ if (isArray) {
+ // Complex object array: validate each item.
+ if (!(resolvedValue instanceof List)) {
+ return false;
+ }
+ List items = (List) resolvedValue;
+ if (items.size() > MAX_ARRAY_ITEMS) {
+ return false;
+ }
+ for (Object item : items) {
+ if (!validateSingleComplexObject(item, schema)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // Single complex object.
+ return validateSingleComplexObject(resolvedValue, schema);
+ }
+
+ /**
+ * Enforce array item limits on a value. Applies to both primary arrays and complex object arrays.
+ *
+ * @param path The operation path.
+ * @param value The value to check.
+ * @param pathTypeAnnotations Map from clean path to annotation content.
+ * @return {@code true} if the value is within array item limits, {@code false} otherwise.
+ */
+ @SuppressWarnings("unchecked")
+ public static boolean enforceArrayItemLimit(String path, Object value,
+ Map pathTypeAnnotations) {
+
+ String annotation = pathTypeAnnotations.get(path);
+ if (annotation == null || !annotation.startsWith("[")) {
+ return true;
+ }
+
+ if (!(value instanceof List)) {
+ return true;
+ }
+
+ return ((List) value).size() <= MAX_ARRAY_ITEMS;
+ }
+
+ /**
+ * Attempt to parse a value as JSON if it is a String that starts with {@code [} or {@code {}.
+ * This handles values that arrive as serialized JSON strings — for example, after JWE
+ * decryption, or when an external service serialises a complex object/array before encrypting it.
+ *
+ *
If the value is not a String, does not start with {@code [} or {@code {}, or cannot be
+ * parsed as valid JSON, the original value is returned unchanged.
+ *
+ * @param value The value to inspect.
+ * @return The parsed JSON structure (List or Map), or the original value if not applicable.
+ */
+ private static Object tryParseJsonString(Object value) {
+
+ if (!(value instanceof String)) {
+ return value;
+ }
+ String str = ((String) value).trim();
+ if (!str.startsWith("[") && !str.startsWith("{")) {
+ return value;
+ }
+ try {
+ return OBJECT_MAPPER.readValue(str, Object.class);
+ } catch (IOException ignored) {
+ return value;
+ }
+ }
+
+ /**
+ * Parse complex annotation attributes into a map of attribute name to type.
+ * Handles array types indicated by trailing {@code []}.
+ *
+ * @param inner The inner annotation content (e.g., {@code "risk: Float, factor: String"}).
+ * @return Map from attribute name to type string (e.g., {@code "Float"} or {@code "String[]"}).
+ */
+ private static Map parseAnnotationAttributes(String inner) {
+
+ Map attributes = new HashMap<>();
+ String[] parts = inner.split(",");
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ int colonIndex = trimmed.indexOf(':');
+ if (colonIndex > 0) {
+ String name = trimmed.substring(0, colonIndex).trim();
+ String type = trimmed.substring(colonIndex + 1).trim();
+ attributes.put(name, type);
+ }
+ }
+ return attributes;
+ }
+
+ /**
+ * Validate a single complex object value against a schema.
+ * Ensures value is a Map, keys match schema names, attribute count within limits,
+ * and nested values are only primary types or primary arrays (single nesting level).
+ *
+ * @param value The value to validate (expected to be a Map).
+ * @param schema The annotation schema (attribute name to type).
+ * @return {@code true} if valid.
+ */
+ @SuppressWarnings("unchecked")
+ private static boolean validateSingleComplexObject(Object value, Map schema) {
+
+ if (!(value instanceof Map)) {
+ return false;
+ }
+
+ Map map = (Map) value;
+
+ // Attribute count limit.
+ if (map.size() > MAX_ATTRIBUTES_PER_OBJECT) {
+ return false;
+ }
+
+ // All keys in the value must be defined in the schema.
+ for (String key : map.keySet()) {
+ if (!schema.containsKey(key)) {
+ return false;
+ }
+ }
+
+ // Validate single nesting level: each attribute value must be primary or primary array.
+ for (Map.Entry entry : map.entrySet()) {
+ String type = schema.get(entry.getKey());
+ Object attrValue = entry.getValue();
+
+ if (type != null && type.endsWith("[]")) {
+ // Array attribute: validate it's a list of primitives within item limit.
+ if (!(attrValue instanceof List)) {
+ return false;
+ }
+ List list = (List) attrValue;
+ if (list.size() > MAX_ARRAY_ITEMS) {
+ return false;
+ }
+ for (Object item : list) {
+ if (item instanceof Map || item instanceof List) {
+ return false;
+ }
+ }
+ } else {
+ // Primary attribute: must not be a nested structure.
+ if (attrValue instanceof Map || attrValue instanceof List) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
index 43d0626cf597..0f49ba756b45 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/AccessConfigFlowUpdateInterceptor.java
@@ -28,10 +28,8 @@
import org.wso2.carbon.identity.action.management.api.service.ActionManagementService;
import org.wso2.carbon.identity.core.util.IdentityTenantUtil;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AccessConfig;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AllowedOperation;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ContextPath;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.OperationPath;
import org.wso2.carbon.identity.flow.execution.engine.internal.FlowExecutionEngineDataHolder;
import org.wso2.carbon.identity.flow.mgt.FlowUpdateInterceptor;
import org.wso2.carbon.identity.flow.mgt.exception.FlowMgtFrameworkException;
@@ -56,7 +54,7 @@
*
Iterates graph nodes looking for In-Flow Extension executors with an
* {@code accessConfig} metadata entry.
Saves the parsed data as per-flow-type override properties on the corresponding action
* via {@code ActionManagementService.updateAction()}.
*
Strips the {@code accessConfig} key from executor metadata so it is NOT persisted
@@ -68,12 +66,13 @@ public class AccessConfigFlowUpdateInterceptor implements FlowUpdateInterceptor
private static final Log LOG = LogFactory.getLog(AccessConfigFlowUpdateInterceptor.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String IN_FLOW_EXTENSION_EXECUTOR_NAME = "ExtensionExecutor";
private static final TypeReference> MAP_TYPE_REF =
new TypeReference>() { };
private static final String ACTION_ID_METADATA_KEY = "actionId";
private static final String EXPOSE_FIELD = "expose";
- private static final String ALLOWED_OPERATIONS_FIELD = "allowedOperations";
+ private static final String MODIFY_FIELD = "modify";
@Override
public void onFlowUpdate(String flowType, GraphConfig graphConfig, int tenantId)
@@ -106,7 +105,8 @@ private void processNode(NodeConfig node, String flowType, String tenantDomain,
ActionManagementService actionMgtService) throws FlowMgtFrameworkException {
ExecutorDTO executorConfig = node.getExecutorConfig();
- if (executorConfig == null || executorConfig.getMetadata() == null) {
+ if (executorConfig == null || !executorConfig.getName().equals(IN_FLOW_EXTENSION_EXECUTOR_NAME)
+ ||executorConfig.getMetadata() == null) {
return;
}
@@ -126,14 +126,13 @@ private void processNode(NodeConfig node, String flowType, String tenantDomain,
metadata.remove(ACCESS_CONFIG_METADATA_KEY);
try {
- // Parse the unified accessConfig JSON: {"expose": [...], "allowedOperations": [...]}
+ // Parse the unified accessConfig JSON: {"expose": [...], "modify": [...]}
Map accessConfigMap = OBJECT_MAPPER.readValue(accessConfigJson, MAP_TYPE_REF);
- List expose = parseExposePaths(accessConfigMap.get(EXPOSE_FIELD));
- List allowedOps = parseAllowedOperations(
- accessConfigMap.get(ALLOWED_OPERATIONS_FIELD));
+ List expose = parseContextPaths(accessConfigMap.get(EXPOSE_FIELD));
+ List modify = parseContextPaths(accessConfigMap.get(MODIFY_FIELD));
// Certificate is action-level only — not overridable per flow type.
- AccessConfig overrideConfig = new AccessConfig(expose, allowedOps);
+ AccessConfig overrideConfig = new AccessConfig(expose, modify);
// Retrieve the existing action to merge overrides (preserving other flow types).
Action currentAction = actionMgtService.getActionByActionId(
@@ -181,7 +180,7 @@ private void processNode(NodeConfig node, String flowType, String tenantDomain,
* Accepts both simple strings (no encryption) and objects ({path, encrypted}).
*/
@SuppressWarnings("unchecked")
- private List parseExposePaths(Object value) {
+ private List parseContextPaths(Object value) {
if (value == null) {
return null;
@@ -192,64 +191,18 @@ private List parseExposePaths(Object value) {
}
List> rawList = (List>) value;
- List result = new ArrayList<>();
+ List result = new ArrayList<>();
for (Object item : rawList) {
- if (item instanceof String) {
- result.add(new ExposePath((String) item, false));
- } else if (item instanceof Map) {
+ if (item instanceof Map) {
Map, ?> map = (Map, ?>) item;
String path = (String) map.get("path");
boolean encrypted = toBooleanSafe(map.get("encrypted"));
if (path != null) {
- result.add(new ExposePath(path, encrypted));
+ result.add(new ContextPath(path, encrypted));
}
}
- }
- return result.isEmpty() ? null : result;
- }
-
- /**
- * Parse allowed operations from the raw JSON value.
- * Accepts the nested format: [{op, paths: [{path, encrypted}]}].
- */
- @SuppressWarnings("unchecked")
- private List parseAllowedOperations(Object value) {
-
- if (value == null) {
- return null;
- }
- if (!(value instanceof List)) {
- LOG.warn("Invalid allowedOperations value in flow override accessConfig. Expected list.");
- return null;
- }
+ else {
- List> rawList = (List>) value;
- List result = new ArrayList<>();
- for (Object item : rawList) {
- if (item instanceof Map) {
- Map, ?> map = (Map, ?>) item;
- String op = (String) map.get("op");
- Object pathsObj = map.get("paths");
- if (op == null || !(pathsObj instanceof List)) {
- continue;
- }
- List> pathsList = (List>) pathsObj;
- List operationPaths = new ArrayList<>();
- for (Object pathItem : pathsList) {
- if (pathItem instanceof Map) {
- Map, ?> pathMap = (Map, ?>) pathItem;
- String path = (String) pathMap.get("path");
- boolean encrypted = toBooleanSafe(pathMap.get("encrypted"));
- if (path != null) {
- operationPaths.add(new OperationPath(path, encrypted));
- }
- } else if (pathItem instanceof String) {
- operationPaths.add(new OperationPath((String) pathItem, false));
- }
- }
- if (!operationPaths.isEmpty()) {
- result.add(new AllowedOperation(op, operationPaths));
- }
}
}
return result.isEmpty() ? null : result;
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
index fbd8c88e0b5b..15dfd2451913 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConstants.java
@@ -33,7 +33,7 @@ public class InFlowExtensionActionConstants {
* Property key for the allowed operations stored in IDN_ACTION_PROPERTIES.
* Stored as a BLOB (JSON-serialized list of operation descriptors).
*/
- public static final String ACCESS_CONFIG_ALLOWED_OPERATIONS = "ACCESS_CONFIG_ALLOWED_OPERATIONS";
+ public static final String ACCESS_CONFIG_MODIFY = "ACCESS_CONFIG_MODIFY";
/**
* Separator used between property key and flow type in override keys.
@@ -51,8 +51,8 @@ public class InFlowExtensionActionConstants {
* Prefix for flow-type-specific allowed operations override properties.
* Full key format: "ACCESS_CONFIG_ALLOWED_OPERATIONS:<FLOW_TYPE>"
*/
- public static final String ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX =
- ACCESS_CONFIG_ALLOWED_OPERATIONS + OVERRIDE_KEY_SEPARATOR;
+ public static final String ACCESS_CONFIG_MODIFY_PREFIX =
+ ACCESS_CONFIG_MODIFY + OVERRIDE_KEY_SEPARATOR;
/**
* Metadata key used in executor metadata (flow configuration) to pass a unified access config
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
index cb44c2d81a65..6cdc9204318a 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionConverter.java
@@ -24,17 +24,16 @@
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.execution.engine.inflow.extension.model.AccessConfig;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AllowedOperation;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.Encryption;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ContextPath;
import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.InFlowExtensionAction;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
-import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_MODIFY;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_MODIFY_PREFIX;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.CERTIFICATE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
@@ -57,7 +56,7 @@ public Action.ActionTypes getSupportedActionType() {
/**
* Converts an {@link InFlowExtensionAction} to an {@link ActionDTO} for persistence.
- * Maps the access config fields (expose, allowedOperations) and flow type overrides
+ * Maps the access config fields (expose, modify) and flow type overrides
* into the DTO's properties map using prefixed keys.
*
* @param action The InFlowExtensionAction to convert.
@@ -80,9 +79,9 @@ public ActionDTO buildActionDTO(Action action) {
properties.put(ACCESS_CONFIG_EXPOSE,
new ActionProperty.BuilderForService(accessConfig.getExpose()).build());
}
- if (accessConfig.getAllowedOperations() != null) {
- properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS,
- new ActionProperty.BuilderForService(accessConfig.getAllowedOperations()).build());
+ if (accessConfig.getModify() != null) {
+ properties.put(ACCESS_CONFIG_MODIFY,
+ new ActionProperty.BuilderForService(accessConfig.getModify()).build());
}
}
@@ -103,9 +102,9 @@ public ActionDTO buildActionDTO(Action action) {
properties.put(ACCESS_CONFIG_EXPOSE_PREFIX + flowType,
new ActionProperty.BuilderForService(overrideConfig.getExpose()).build());
}
- if (overrideConfig.getAllowedOperations() != null) {
- properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX + flowType,
- new ActionProperty.BuilderForService(overrideConfig.getAllowedOperations()).build());
+ if (overrideConfig.getModify() != null) {
+ properties.put(ACCESS_CONFIG_MODIFY_PREFIX + flowType,
+ new ActionProperty.BuilderForService(overrideConfig.getModify()).build());
}
}
}
@@ -127,13 +126,13 @@ public ActionDTO buildActionDTO(Action action) {
public Action buildAction(ActionDTO actionDTO) {
// Default access config.
- List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
- List allowedOperations =
- (List) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ List expose = (List) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
+ List modify =
+ (List) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY);
AccessConfig accessConfig = null;
- if (expose != null || allowedOperations != null) {
- accessConfig = new AccessConfig(expose, allowedOperations);
+ if (expose != null || modify != null) {
+ accessConfig = new AccessConfig(expose, modify);
}
// Encryption certificate (separate from access config).
@@ -152,15 +151,15 @@ public Action buildAction(ActionDTO actionDTO) {
AccessConfig existing = flowTypeOverrides.getOrDefault(flowType,
new AccessConfig(null, null));
flowTypeOverrides.put(flowType, new AccessConfig(
- (List) actionDTO.getPropertyValue(propertyKey),
- existing.getAllowedOperations()));
- } else if (propertyKey.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
- String flowType = propertyKey.substring(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX.length());
+ (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)));
+ (List) actionDTO.getPropertyValue(propertyKey)));
}
}
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
index ec4ac4da1b15..c8063a7b5b4b 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/management/InFlowExtensionActionDTOModelResolver.java
@@ -33,9 +33,7 @@
import org.wso2.carbon.identity.certificate.management.exception.CertificateMgtException;
import org.wso2.carbon.identity.certificate.management.model.Certificate;
import org.wso2.carbon.identity.certificate.management.service.CertificateManagementService;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.AllowedOperation;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ExposePath;
-import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.OperationPath;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model.ContextPath;
import java.io.IOException;
import java.util.ArrayList;
@@ -46,8 +44,8 @@
import java.util.Set;
import static java.util.Collections.emptyList;
-import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS;
-import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_MODIFY;
+import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_MODIFY_PREFIX;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.CERTIFICATE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE;
import static org.wso2.carbon.identity.flow.execution.engine.inflow.extension.management.InFlowExtensionActionConstants.ACCESS_CONFIG_EXPOSE_PREFIX;
@@ -58,11 +56,11 @@
* ActionDTOModelResolver implementation for In-Flow Extension actions.
*
* Responsible for validating and transforming access config properties (expose paths and
- * allowed operations) between the service layer representation and the DAO layer BLOB format.
+ * modify paths) between the service layer representation and the DAO layer BLOB format.
*
*
*
- *
Add operation: Validates expose paths and allowed operations, then serializes
+ *
Add operation: Validates expose paths and modify paths, then serializes
* them to JSON {@link BinaryObject}s for BLOB storage in IDN_ACTION_PROPERTIES.
*
Get operation: Deserializes BLOBs back to service-layer list objects.
@@ -73,6 +71,8 @@ public class InFlowExtensionActionDTOModelResolver implements ActionDTOModelReso
private static final Log LOG = LogFactory.getLog(InFlowExtensionActionDTOModelResolver.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final TypeReference> CONTEXT_PATH_LIST_TYPE_REF =
+ new TypeReference>() { };
private final CertificateManagementService certificateManagementService;
@@ -96,15 +96,15 @@ public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain
Object exposeValue = actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
// Expose is an optional field.
if (exposeValue != null) {
- List validatedExpose = validateExpose(exposeValue);
+ List validatedExpose = validateExpose(exposeValue);
properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(validatedExpose));
}
- Object allowedOpsValue = actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
- // Allowed operations is an optional field.
- if (allowedOpsValue != null) {
- List validatedOps = validateAllowedOperations(allowedOpsValue);
- properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(validatedOps));
+ Object modifyValue = actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY);
+ // Modify is an optional field.
+ if (modifyValue != null) {
+ List validatedModify = validateExpose(modifyValue);
+ properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(validatedModify));
}
// Handle certificate: store via CertificateManagementService and replace with ID.
@@ -117,14 +117,14 @@ public ActionDTO resolveForAddOperation(ActionDTO actionDTO, String tenantDomain
if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) {
Object overrideExpose = actionDTO.getPropertyValue(key);
if (overrideExpose != null) {
- List validatedOverrideExpose = validateExpose(overrideExpose);
+ List validatedOverrideExpose = validateExpose(overrideExpose);
properties.put(key, createBlobProperty(validatedOverrideExpose));
}
- } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
- Object overrideOps = actionDTO.getPropertyValue(key);
- if (overrideOps != null) {
- List validatedOverrideOps = validateAllowedOperations(overrideOps);
- properties.put(key, createBlobProperty(validatedOverrideOps));
+ } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) {
+ Object overrideModify = actionDTO.getPropertyValue(key);
+ if (overrideModify != null) {
+ List validatedOverrideModify = validateExpose(overrideModify);
+ properties.put(key, createBlobProperty(validatedOverrideModify));
}
}
}
@@ -147,9 +147,9 @@ public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain
((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE)).getJSONString()));
}
- if (actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
- properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, deserializeAllowedOpsProperty(
- ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS)).getJSONString()));
+ if (actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY) != null) {
+ properties.put(ACCESS_CONFIG_MODIFY, deserializeExposeProperty(
+ ((BinaryObject) actionDTO.getPropertyValue(ACCESS_CONFIG_MODIFY)).getJSONString()));
}
// Retrieve certificate by stored ID.
@@ -163,9 +163,9 @@ public ActionDTO resolveForGetOperation(ActionDTO actionDTO, String tenantDomain
&& actionDTO.getPropertyValue(key) != null) {
properties.put(key, deserializeExposeProperty(
((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString()));
- } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)
+ } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)
&& actionDTO.getPropertyValue(key) != null) {
- properties.put(key, deserializeAllowedOpsProperty(
+ properties.put(key, deserializeExposeProperty(
((BinaryObject) actionDTO.getPropertyValue(key)).getJSONString()));
}
}
@@ -208,15 +208,14 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
// updated the existing properties should be sent to the DAO layer.
// Handle default access config properties.
- List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO);
+ List expose = getResolvedUpdatingExpose(updatingActionDTO, existingActionDTO);
if (!expose.isEmpty()) {
properties.put(ACCESS_CONFIG_EXPOSE, createBlobProperty(expose));
}
- List allowedOps =
- getResolvedUpdatingAllowedOps(updatingActionDTO, existingActionDTO);
- if (!allowedOps.isEmpty()) {
- properties.put(ACCESS_CONFIG_ALLOWED_OPERATIONS, createBlobProperty(allowedOps));
+ List modify = getResolvedUpdatingModify(updatingActionDTO, existingActionDTO);
+ if (!modify.isEmpty()) {
+ properties.put(ACCESS_CONFIG_MODIFY, createBlobProperty(modify));
}
// Handle certificate update.
@@ -229,7 +228,7 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
for (Map.Entry entry : existingActionDTO.getProperties().entrySet()) {
String key = entry.getKey();
if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)
- || key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
+ || key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) {
properties.put(key, createBlobProperty(existingActionDTO.getPropertyValue(key)));
}
}
@@ -241,14 +240,14 @@ public ActionDTO resolveForUpdateOperation(ActionDTO updatingActionDTO, ActionDT
if (key.startsWith(ACCESS_CONFIG_EXPOSE_PREFIX)) {
Object overrideExpose = updatingActionDTO.getPropertyValue(key);
if (overrideExpose != null) {
- List validatedOverrideExpose = validateExpose(overrideExpose);
+ List validatedOverrideExpose = validateExpose(overrideExpose);
properties.put(key, createBlobProperty(validatedOverrideExpose));
}
- } else if (key.startsWith(ACCESS_CONFIG_ALLOWED_OPERATIONS_PREFIX)) {
- Object overrideOps = updatingActionDTO.getPropertyValue(key);
- if (overrideOps != null) {
- List validatedOverrideOps = validateAllowedOperations(overrideOps);
- properties.put(key, createBlobProperty(validatedOverrideOps));
+ } else if (key.startsWith(ACCESS_CONFIG_MODIFY_PREFIX)) {
+ Object overrideModify = updatingActionDTO.getPropertyValue(key);
+ if (overrideModify != null) {
+ List validatedOverrideModify = validateExpose(overrideModify);
+ properties.put(key, createBlobProperty(validatedOverrideModify));
}
}
}
@@ -270,26 +269,26 @@ public void resolveForDeleteOperation(ActionDTO deletingActionDTO, String tenant
// ---- Update helpers ----
@SuppressWarnings("unchecked")
- private List getResolvedUpdatingExpose(ActionDTO updatingActionDTO, ActionDTO existingActionDTO)
+ 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 (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_EXPOSE);
}
return emptyList();
}
@SuppressWarnings("unchecked")
- private List getResolvedUpdatingAllowedOps(ActionDTO updatingActionDTO,
- ActionDTO existingActionDTO)
+ private List getResolvedUpdatingModify(ActionDTO updatingActionDTO,
+ ActionDTO existingActionDTO)
throws ActionDTOModelResolverException {
- if (updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
- return validateAllowedOperations(updatingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS));
- } else if (existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS) != null) {
- return (List) existingActionDTO.getPropertyValue(ACCESS_CONFIG_ALLOWED_OPERATIONS);
+ 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();
}
@@ -297,7 +296,7 @@ private List getResolvedUpdatingAllowedOps(ActionDTO updatingA
// ---- Validation ----
@SuppressWarnings("unchecked")
- private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException {
+ private List validateExpose(Object exposeValue) throws ActionDTOModelResolverException {
if (!(exposeValue instanceof List>)) {
throw new ActionDTOModelResolverClientException("Invalid expose format.",
@@ -305,12 +304,12 @@ private List validateExpose(Object exposeValue) throws ActionDTOMode
}
List> exposeList = (List>) exposeValue;
- List result = new ArrayList<>();
+ List result = new ArrayList<>();
for (Object item : exposeList) {
if (item instanceof String) {
// Simple string path — no encryption.
- result.add(new ExposePath((String) item, false));
+ result.add(new ContextPath((String) item, false));
} else if (item instanceof Map) {
Map, ?> map = (Map, ?>) item;
if (!map.containsKey("path") || !(map.get("path") instanceof String)) {
@@ -319,9 +318,9 @@ private List validateExpose(Object exposeValue) throws ActionDTOMode
}
String path = (String) map.get("path");
boolean encrypted = map.containsKey("encrypted") && toBooleanSafe(map.get("encrypted"));
- result.add(new ExposePath(path, encrypted));
- } else if (item instanceof ExposePath) {
- result.add((ExposePath) item);
+ result.add(new ContextPath(path, encrypted));
+ } else if (item instanceof ContextPath) {
+ result.add((ContextPath) item);
} else {
throw new ActionDTOModelResolverClientException("Invalid expose format.",
"Each expose entry must be a string or an object with 'path' and optional 'encrypted' fields.");
@@ -329,11 +328,11 @@ private List validateExpose(Object exposeValue) throws ActionDTOMode
}
validateExposeCount(result);
- validateExposePathFormat(result);
+ validateContextPathFormat(result);
return result;
}
- private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException {
+ private void validateExposeCount(List expose) throws ActionDTOModelResolverClientException {
if (expose.size() > MAX_EXPOSE_PATHS) {
throw new ActionDTOModelResolverClientException("Maximum expose paths limit exceeded.",
@@ -342,10 +341,10 @@ private void validateExposeCount(List expose) throws ActionDTOModelR
}
}
- private void validateExposePathFormat(List expose) throws ActionDTOModelResolverClientException {
+ private void validateContextPathFormat(List expose) throws ActionDTOModelResolverClientException {
Set seen = new HashSet<>();
- for (ExposePath exposePath : expose) {
+ for (ContextPath exposePath : expose) {
String path = exposePath.getPath();
if (path == null || path.trim().isEmpty()) {
throw new ActionDTOModelResolverClientException("Invalid expose path.",
@@ -362,68 +361,6 @@ private void validateExposePathFormat(List expose) throws ActionDTOM
}
}
- @SuppressWarnings("unchecked")
- private List validateAllowedOperations(Object allowedOpsValue)
- throws ActionDTOModelResolverClientException {
-
- if (!(allowedOpsValue instanceof List>)) {
- throw new ActionDTOModelResolverClientException("Invalid allowed operations format.",
- "Allowed operations should be provided as a list.");
- }
-
- List> opsList = (List>) allowedOpsValue;
- List result = new ArrayList<>();
-
- for (Object item : opsList) {
- if (item instanceof AllowedOperation) {
- result.add((AllowedOperation) item);
- } else if (item instanceof Map) {
- Map, ?> opMap = (Map, ?>) item;
- if (!opMap.containsKey("op") || !(opMap.get("op") instanceof String)) {
- throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
- "Each allowed operation must contain an 'op' field with a string value.");
- }
- String op = (String) opMap.get("op");
-
- if (!opMap.containsKey("paths") || !(opMap.get("paths") instanceof List)) {
- throw new ActionDTOModelResolverClientException("Invalid allowed operation.",
- "Each allowed operation must contain a 'paths' list.");
- }
-
- List> pathsList = (List>) opMap.get("paths");
- List operationPaths = new ArrayList<>();
- for (Object pathItem : pathsList) {
- if (pathItem instanceof OperationPath) {
- operationPaths.add((OperationPath) pathItem);
- } else if (pathItem instanceof Map) {
- Map, ?> pathMap = (Map, ?>) pathItem;
- if (!pathMap.containsKey("path") || !(pathMap.get("path") instanceof String)) {
- throw new ActionDTOModelResolverClientException("Invalid operation path.",
- "Each path entry must contain a 'path' field with a string value.");
- }
- String path = (String) pathMap.get("path");
- boolean encrypted = pathMap.containsKey("encrypted")
- && toBooleanSafe(pathMap.get("encrypted"));
- operationPaths.add(new OperationPath(path, encrypted));
- } else if (pathItem instanceof String) {
- // Simple string path — no encryption.
- operationPaths.add(new OperationPath((String) pathItem, false));
- } else {
- throw new ActionDTOModelResolverClientException("Invalid operation path format.",
- "Each path entry must be a string or an object with 'path' and "
- + "optional 'encrypted' fields.");
- }
- }
- result.add(new AllowedOperation(op, operationPaths));
- } else {
- throw new ActionDTOModelResolverClientException("Invalid allowed operations format.",
- "Each allowed operation must be an object with 'op' and 'paths' fields.");
- }
- }
-
- return result;
- }
-
// ---- Certificate lifecycle helpers ----
/**
@@ -592,26 +529,13 @@ private ActionProperty createBlobProperty(Object value) throws ActionDTOModelRes
private ActionProperty deserializeExposeProperty(String jsonString) throws ActionDTOModelResolverException {
try {
- List expose = OBJECT_MAPPER.readValue(jsonString,
- new TypeReference>() { });
+ 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);
}
}
- private ActionProperty deserializeAllowedOpsProperty(String jsonString)
- throws ActionDTOModelResolverException {
-
- try {
- List allowedOps = OBJECT_MAPPER.readValue(jsonString,
- new TypeReference>() { });
- return new ActionProperty.BuilderForService(allowedOps).build();
- } catch (IOException e) {
- throw new ActionDTOModelResolverException("Error reading allowed operations from storage.", e);
- }
- }
-
/**
* Safely converts a value to boolean, handling both {@link Boolean} and {@link String} types.
* Jackson deserializes JSON {@code true} as {@link Boolean} but JSON {@code "true"} as {@link String}.
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
index 7ef2cab4de85..2fcf3165a3e4 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AccessConfig.java
@@ -18,63 +18,58 @@
package org.wso2.carbon.identity.flow.execution.engine.inflow.extension.model;
+import org.wso2.carbon.identity.flow.execution.engine.inflow.extension.executor.PathTypeAnnotationUtil;
+
import java.util.Collections;
import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Access Configuration for In-Flow Extension actions.
*
* Defines which parts of the flow context are exposed to the external service
- * and what operations the service is allowed to perform.
+ * and which paths the service is allowed to modify.
*
*
*
- *
{@code expose} – structured list of {@link ExposePath} entries, each with a hierarchical
+ *
{@code expose} – structured list of {@link ContextPath} entries, each with a hierarchical
* path prefix and an {@code encrypted} flag controlling outbound JWE encryption.
- *
{@code allowedOperations} – list of {@link AllowedOperation} entries, each grouping an
- * operation type with a list of {@link OperationPath} entries carrying per-path encryption
- * flags for inbound JWE encryption.
+ *
{@code modify} – structured list of {@link ContextPath} entries defining which paths the
+ * external service can change. All modifications map to REPLACE operations internally.
+ * The {@code encrypted} flag on modify paths controls inbound JWE encryption (the external
+ * service encrypts values, IS decrypts with its private key).
*
*
+ *
Expose and modify are independent: expose controls what data is sent to the external service,
+ * while modify controls what data the external service is allowed to change. A path can appear
+ * in both lists with independent encryption flags.
+ *
*
Note: The external service's certificate for outbound encryption is held in the
- * separate {@link Encryption} model, not in AccessConfig. This follows the same separation
- * pattern as {@code PasswordSharing} in the PreUpdatePassword action type.
+ * separate {@link Encryption} model, not in AccessConfig.
*/
public class AccessConfig {
- /**
- * Regex pattern to match path type annotations at the end of a path.
- * Strips trailing bracket expressions like {@code []}, {@code [field1, field2]}.
- * These annotations are used by the request builder for type coercion but are not
- * part of the logical path that the external service sees.
- */
- private static final Pattern PATH_ANNOTATION_PATTERN = Pattern.compile("\\[([^\\]]*)]$");
-
- private final List expose;
- private final List allowedOperations;
+ private final List expose;
+ private final List modify;
/**
- * Constructs an AccessConfig with the given expose paths and allowed operations.
+ * Constructs an AccessConfig with the given expose and modify paths.
*
- * @param expose List of expose path entries. May be {@code null}.
- * @param allowedOperations List of allowed operation entries. May be {@code null}.
+ * @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 allowedOperations) {
+ public AccessConfig(List expose, List modify) {
this.expose = expose != null ? Collections.unmodifiableList(expose) : null;
- this.allowedOperations = allowedOperations != null
- ? Collections.unmodifiableList(allowedOperations) : null;
+ this.modify = modify != null ? Collections.unmodifiableList(modify) : null;
}
/**
* Returns the list of expose path entries.
*
- * @return Unmodifiable list of {@link ExposePath} entries, or {@code null} if not configured.
+ * @return Unmodifiable list of {@link ContextPath} entries, or {@code null} if not configured.
*/
- public List getExpose() {
+ public List getExpose() {
return expose;
}
@@ -90,17 +85,31 @@ public List getExposePaths() {
if (expose == null) {
return null;
}
- return expose.stream().map(ExposePath::getPath).collect(Collectors.toList());
+ 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 list of allowed operation entries.
+ * Returns the flat list of modify path strings (without encryption metadata).
+ * Convenience method for components that only need path strings.
*
- * @return Unmodifiable list of {@link AllowedOperation} entries, or {@code null} if not configured.
+ * @return List of path strings, or {@code null} if modify is not configured.
*/
- public List getAllowedOperations() {
+ public List getModifyPaths() {
- return allowedOperations;
+ if (modify == null) {
+ return null;
+ }
+ return modify.stream().map(ContextPath::getPath).collect(Collectors.toList());
}
/**
@@ -118,59 +127,28 @@ public boolean isExposePathEncrypted(String pathPrefix) {
return expose.stream()
.filter(ep -> pathPrefix.startsWith(ep.getPath()))
.reduce((a, b) -> a.getPath().length() >= b.getPath().length() ? a : b)
- .map(ExposePath::isEncrypted)
+ .map(ContextPath::isEncrypted)
.orElse(false);
}
/**
- * Check if a given operation path has inbound encryption enabled.
- * Iterates the nested {@link OperationPath} entries within each {@link AllowedOperation}.
- * Path type annotations (e.g. {@code []}, {@code [schema]}) are stripped from stored paths
- * before comparison, since operation paths from the external service do not include annotations.
+ * Check if a given modify path has inbound encryption enabled.
+ * Matches the most specific (longest) modify path that is a prefix of the given path.
*
- * @param op The operation type (e.g. "REPLACE").
- * @param path The operation path (clean, without annotations).
- * @return {@code true} if the matching operation path has {@code encrypted = true}.
+ * @param pathPrefix The path to check (clean, without annotations).
+ * @return {@code true} if the matching modify entry has {@code encrypted = true}.
*/
- public boolean isOperationPathEncrypted(String op, String path) {
+ public boolean isModifyPathEncrypted(String pathPrefix) {
- if (allowedOperations == null) {
+ if (modify == null) {
return false;
}
- return allowedOperations.stream()
- .filter(ao -> ao.getOp().equalsIgnoreCase(op))
- .flatMap(ao -> ao.getPaths().stream())
- .filter(opPath -> path.startsWith(stripAnnotation(opPath.getPath())))
- .reduce((a, b) -> stripAnnotation(a.getPath()).length()
- >= stripAnnotation(b.getPath()).length() ? a : b)
- .map(OperationPath::isEncrypted)
+ return modify.stream()
+ .filter(mp -> pathPrefix.startsWith(PathTypeAnnotationUtil.stripAnnotation(mp.getPath())[0]))
+ .reduce((a, b) -> PathTypeAnnotationUtil.stripAnnotation(a.getPath())[0].length()
+ >= PathTypeAnnotationUtil.stripAnnotation(b.getPath())[0].length() ? a : b)
+ .map(ContextPath::isEncrypted)
.orElse(false);
}
- /**
- * Returns whether any expose path or allowed operation path has encryption enabled.
- *
- * @return {@code true} if at least one path has {@code encrypted = true}.
- */
- public boolean hasAnyEncryptedPath() {
-
- boolean hasEncryptedExpose = expose != null
- && expose.stream().anyMatch(ExposePath::isEncrypted);
- boolean hasEncryptedOps = allowedOperations != null
- && allowedOperations.stream().anyMatch(AllowedOperation::hasAnyEncryptedPath);
- return hasEncryptedExpose || hasEncryptedOps;
- }
-
- /**
- * Strip trailing path type annotations from a path string.
- * For example, {@code "/properties/riskFactors[]"} becomes {@code "/properties/riskFactors"}.
- *
- * @param path The path that may contain trailing annotations.
- * @return The path without annotations.
- */
- private static String stripAnnotation(String path) {
-
- Matcher m = PATH_ANNOTATION_PATTERN.matcher(path);
- return m.find() ? path.substring(0, m.start()) : path;
- }
}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
deleted file mode 100644
index d040ea86afac..000000000000
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/AllowedOperation.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Domain model for an allowed operation with structured paths.
- *
- * Each entry groups an operation type with a list of {@link OperationPath} entries,
- * where each path carries its own {@code encrypted} flag.
- *
- */
-public class AllowedOperation {
-
- private final String op;
- private final List paths;
-
- @JsonCreator
- public AllowedOperation(@JsonProperty("op") String op,
- @JsonProperty("paths") List paths) {
-
- this.op = op;
- this.paths = paths != null ? Collections.unmodifiableList(paths) : Collections.emptyList();
- }
-
- /**
- * Returns the operation type (e.g. {@code "ADD"}, {@code "REPLACE"}, {@code "REMOVE"}).
- *
- * @return The operation type string.
- */
- public String getOp() {
-
- return op;
- }
-
- /**
- * Returns the list of {@link OperationPath} entries for this operation.
- *
- * @return Unmodifiable list of operation paths with encryption flags.
- */
- public List getPaths() {
-
- return paths;
- }
-
- /**
- * Returns whether any path in this operation has encryption enabled.
- *
- * @return {@code true} if at least one path has {@code encrypted = true}.
- */
- public boolean hasAnyEncryptedPath() {
-
- return paths.stream().anyMatch(OperationPath::isEncrypted);
- }
-}
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ContextPath.java
similarity index 75%
rename from components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java
rename to components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ContextPath.java
index 04728ac59153..06b8445a0c4d 100644
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ExposePath.java
+++ b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/ContextPath.java
@@ -22,20 +22,22 @@
import com.fasterxml.jackson.annotation.JsonProperty;
/**
- * Domain model for an expose path with an optional encryption flag.
+ * 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
- * in the outbound request using the external service's public certificate before sending.
+ * 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 ExposePath {
+public class ContextPath {
private final String path;
private final boolean encrypted;
@JsonCreator
- public ExposePath(@JsonProperty("path") String path,
- @JsonProperty("encrypted") boolean encrypted) {
+ public ContextPath(@JsonProperty("path") String path,
+ @JsonProperty("encrypted") boolean encrypted) {
this.path = path;
this.encrypted = encrypted;
diff --git a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java b/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
deleted file mode 100644
index bb4abaa64bbd..000000000000
--- a/components/flow-orchestration-framework/org.wso2.carbon.identity.flow.execution.engine/src/main/java/org/wso2/carbon/identity/flow/execution/engine/inflow/extension/model/OperationPath.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright (c) 2025, 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.execution.engine.inflow.extension.model;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-/**
- * Domain model for an operation path with an optional encryption flag.
- *
- * Used within {@link AllowedOperation} to represent a single path on which
- * the operation is allowed, along with an encryption flag indicating whether
- * the IS should expect/produce JWE-encrypted values for that specific path.
- *