[SYNCOPE-1815] Macro improvements (#696) (#709)

diff --git a/client/idm/console/src/main/java/org/apache/syncope/client/console/commons/IdMAnyDirectoryPanelAdditionalActionLinksProvider.java b/client/idm/console/src/main/java/org/apache/syncope/client/console/commons/IdMAnyDirectoryPanelAdditionalActionLinksProvider.java
index 8de1443..d685da5 100644
--- a/client/idm/console/src/main/java/org/apache/syncope/client/console/commons/IdMAnyDirectoryPanelAdditionalActionLinksProvider.java
+++ b/client/idm/console/src/main/java/org/apache/syncope/client/console/commons/IdMAnyDirectoryPanelAdditionalActionLinksProvider.java
@@ -192,8 +192,7 @@
 
             @Override
             public void onClick(final AjaxRequestTarget target, final GroupTO ignore) {
-                IModel<AnyWrapper<GroupTO>> formModel = new CompoundPropertyModel<>(
-                        new GroupWrapper(modelObject));
+                IModel<AnyWrapper<GroupTO>> formModel = new CompoundPropertyModel<>(new GroupWrapper(modelObject));
                 modal.setFormModel(formModel);
 
                 target.add(modal.setContent(new AnyStatusModal<>(
diff --git a/client/idm/console/src/main/java/org/apache/syncope/client/console/wizards/resources/ResourceWizardBuilder.java b/client/idm/console/src/main/java/org/apache/syncope/client/console/wizards/resources/ResourceWizardBuilder.java
index 003a65c..cfc8493 100644
--- a/client/idm/console/src/main/java/org/apache/syncope/client/console/wizards/resources/ResourceWizardBuilder.java
+++ b/client/idm/console/src/main/java/org/apache/syncope/client/console/wizards/resources/ResourceWizardBuilder.java
@@ -87,7 +87,6 @@
             protected void onComponentTag(final ComponentTag tag) {
                 tag.append("class", "scrollable-tab-content", " ");
             }
-
         };
 
         if (createFlag && resourceDetailsPanel.getConnector() != null) {
@@ -100,8 +99,7 @@
                 protected void onUpdate(final AjaxRequestTarget target) {
                     resourceTO.setConnector(resourceDetailsPanel.getConnector().getModelObject());
 
-                    LoadableDetachableModel<List<ConnConfProperty>> model =
-                            new LoadableDetachableModel<>() {
+                    LoadableDetachableModel<List<ConnConfProperty>> model = new LoadableDetachableModel<>() {
 
                         private static final long serialVersionUID = -2965284931860212687L;
 
diff --git a/ext/flowable/client-common-ui/src/main/java/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.java b/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.java
similarity index 74%
rename from ext/flowable/client-common-ui/src/main/java/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.java
rename to client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.java
index 1231099..2cfc3cb 100644
--- a/ext/flowable/client-common-ui/src/main/java/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.java
+++ b/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.ext.client.common.ui.panels;
+package org.apache.syncope.client.ui.commons.panels;
 
 import java.text.ParseException;
 import java.util.Date;
@@ -32,13 +32,9 @@
 import org.apache.syncope.client.ui.commons.markup.html.form.AjaxSpinnerFieldPanel;
 import org.apache.syncope.client.ui.commons.markup.html.form.AjaxTextFieldPanel;
 import org.apache.syncope.client.ui.commons.markup.html.form.FieldPanel;
-import org.apache.syncope.common.lib.to.UserRequestForm;
-import org.apache.syncope.common.lib.to.UserRequestFormProperty;
-import org.apache.syncope.common.lib.to.UserRequestFormPropertyValue;
-import org.apache.syncope.common.lib.types.IdRepoEntitlement;
-import org.apache.wicket.ajax.AjaxRequestTarget;
-import org.apache.wicket.ajax.markup.html.AjaxLink;
-import org.apache.wicket.authroles.authorization.strategies.role.metadata.MetaDataRoleAuthorizationStrategy;
+import org.apache.syncope.common.lib.form.FormProperty;
+import org.apache.syncope.common.lib.form.FormPropertyValue;
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.wicket.markup.html.list.ListItem;
 import org.apache.wicket.markup.html.list.ListView;
 import org.apache.wicket.markup.html.panel.Panel;
@@ -48,37 +44,33 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public abstract class UserRequestFormPanel extends Panel {
+public class SyncopeFormPanel<F extends SyncopeForm> extends Panel {
 
     private static final long serialVersionUID = -8847854414429745216L;
 
-    protected static final Logger LOG = LoggerFactory.getLogger(UserRequestFormPanel.class);
+    protected static final Logger LOG = LoggerFactory.getLogger(SyncopeFormPanel.class);
 
-    public UserRequestFormPanel(final String id, final UserRequestForm form) {
-        this(id, form, true);
-    }
-
-    public UserRequestFormPanel(final String id, final UserRequestForm form, final boolean showDetails) {
+    public SyncopeFormPanel(final String id, final F form) {
         super(id);
 
-        IModel<List<UserRequestFormProperty>> formProps = new LoadableDetachableModel<>() {
+        IModel<List<FormProperty>> formProps = new LoadableDetachableModel<>() {
 
             private static final long serialVersionUID = 3169142472626817508L;
 
             @Override
-            protected List<UserRequestFormProperty> load() {
+            protected List<FormProperty> load() {
                 return form.getProperties();
             }
         };
 
-        ListView<UserRequestFormProperty> propView = new ListView<>("propView", formProps) {
+        ListView<FormProperty> propView = new ListView<>("propView", formProps) {
 
             private static final long serialVersionUID = 9101744072914090143L;
 
             @Override
             @SuppressWarnings({ "unchecked", "rawtypes" })
-            protected void populateItem(final ListItem<UserRequestFormProperty> item) {
-                final UserRequestFormProperty prop = item.getModelObject();
+            protected void populateItem(final ListItem<FormProperty> item) {
+                FormProperty prop = item.getModelObject();
 
                 String label = StringUtils.isBlank(prop.getName()) ? prop.getId() : prop.getName();
 
@@ -134,10 +126,10 @@
                                 "value", label, new PropertyModel<String>(prop, "value"), false).
                                 setChoiceRenderer(new MapChoiceRenderer(prop.getEnumValues().stream().
                                         collect(Collectors.toMap(
-                                                UserRequestFormPropertyValue::getKey,
-                                                UserRequestFormPropertyValue::getValue)))).
+                                                FormPropertyValue::getKey,
+                                                FormPropertyValue::getValue)))).
                                 setChoices(prop.getEnumValues().stream().
-                                        map(UserRequestFormPropertyValue::getKey).collect(Collectors.toList()));
+                                        map(FormPropertyValue::getKey).collect(Collectors.toList()));
                         break;
 
                     case Dropdown:
@@ -145,10 +137,10 @@
                                 "value", label, new PropertyModel<String>(prop, "value"), false).
                                 setChoiceRenderer(new MapChoiceRenderer(prop.getDropdownValues().stream().
                                         collect(Collectors.toMap(
-                                                UserRequestFormPropertyValue::getKey,
-                                                UserRequestFormPropertyValue::getValue)))).
+                                                FormPropertyValue::getKey,
+                                                FormPropertyValue::getValue)))).
                                 setChoices(prop.getDropdownValues().stream().
-                                        map(UserRequestFormPropertyValue::getKey).collect(Collectors.toList()));
+                                        map(FormPropertyValue::getKey).collect(Collectors.toList()));
                         break;
 
                     case Long:
@@ -193,23 +185,6 @@
             }
         };
 
-        AjaxLink<String> userDetails = new AjaxLink<>("userDetails") {
-
-            private static final long serialVersionUID = -4804368561204623354L;
-
-            @Override
-            public void onClick(final AjaxRequestTarget target) {
-                viewDetails(target);
-            }
-        };
-        MetaDataRoleAuthorizationStrategy.authorize(userDetails, ENABLE, IdRepoEntitlement.USER_READ);
-
-        boolean enabled = form.getUserTO() != null;
-        userDetails.setVisible(enabled && showDetails).setEnabled(enabled);
-
         add(propView);
-        add(userDetails);
     }
-
-    protected abstract void viewDetails(AjaxRequestTarget target);
 }
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html b/client/idrepo/common-ui/src/main/resources/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.html
similarity index 80%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
copy to client/idrepo/common-ui/src/main/resources/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.html
index aef81e5..127037b 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
+++ b/client/idrepo/common-ui/src/main/resources/org/apache/syncope/client/ui/commons/panels/SyncopeFormPanel.html
@@ -22,10 +22,6 @@
       <span wicket:id="value">[value]</span>
     </div>
 
-    <div style="margin: 20px 0">
-      <a href="#" alt="user details" class="btn btn-success btn-circle btn-lg" wicket:id="userDetails" wicket:message="title:userDetails">
-        <i class="fas fa-eye"></i>
-      </a>
-    </div>
+    <wicket:child/>    
   </wicket:panel>
 </html>
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/audit/AuditHistoryDetails.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/audit/AuditHistoryDetails.java
index 0284b4d..7b39d99 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/audit/AuditHistoryDetails.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/audit/AuditHistoryDetails.java
@@ -21,6 +21,7 @@
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.json.JsonMapper;
@@ -214,21 +215,21 @@
 
             @Override
             protected void onEvent(final AjaxRequestTarget target) {
-                AuditEventTO beforeEntry = beforeVersionsPanel.getModelObject() == null
+                AuditEventTO beforeEvent = beforeVersionsPanel.getModelObject() == null
                         ? latestAuditEventTO
                         : beforeVersionsPanel.getModelObject();
-                AuditEventTO afterEntry = afterVersionsPanel.getModelObject() == null
+                AuditEventTO afterEvent = afterVersionsPanel.getModelObject() == null
                         ? after
-                        : buildAfterAuditEventTO(beforeEntry);
+                        : buildAfterAuditEventTO(beforeEvent);
                 AuditHistoryDetails.this.addOrReplace(
-                        new JsonDiffPanel(toJSON(beforeEntry, reference), toJSON(afterEntry, reference)));
+                        new JsonDiffPanel(toJSON(beforeEvent, reference), toJSON(afterEvent, reference)));
                 // change after audit entries in order to match only the ones newer than the current after one
                 afterVersionsPanel.setChoices(auditEntries.stream().
-                        filter(ae -> ae.getWhen().isAfter(beforeEntry.getWhen())
-                        || ae.getWhen().isEqual(beforeEntry.getWhen())).
+                        filter(ae -> ae.getWhen().isAfter(beforeEvent.getWhen())
+                        || ae.getWhen().isEqual(beforeEvent.getWhen())).
                         collect(Collectors.toList()));
                 // set the new after entry
-                afterVersionsPanel.setModelObject(afterEntry);
+                afterVersionsPanel.setModelObject(afterEvent);
                 target.add(AuditHistoryDetails.this);
             }
         });
@@ -323,30 +324,36 @@
         return output;
     }
 
-    protected Model<String> toJSON(final AuditEventTO auditEntry, final Class<T> reference) {
+    protected Model<String> toJSON(final AuditEventTO auditEvent, final Class<T> reference) {
+        if (auditEvent == null) {
+            return Model.of();
+        }
+
         try {
-            if (auditEntry == null) {
-                return Model.of();
+            String content;
+            if (auditEvent.getBefore() == null) {
+                JsonNode output = MAPPER.readTree(auditEvent.getOutput());
+                if (output.has("entity")) {
+                    content = output.get("entity").toPrettyString();
+                } else {
+                    content = output.toPrettyString();
+                }
+            } else {
+                content = auditEvent.getBefore();
             }
-            String content = auditEntry.getBefore() == null
-                    ? MAPPER.readTree(auditEntry.getOutput()).get("entity") == null
-                    ? MAPPER.readTree(auditEntry.getOutput()).toPrettyString()
-                    : MAPPER.readTree(auditEntry.getOutput()).get("entity").toPrettyString()
-                    : auditEntry.getBefore();
 
             T entity = MAPPER.reader().
                     with(StreamReadFeature.STRICT_DUPLICATE_DETECTION).
                     readValue(content, reference);
-            if (entity instanceof UserTO) {
-                UserTO userTO = (UserTO) entity;
+            if (entity instanceof UserTO userTO) {
                 userTO.setPassword(null);
                 userTO.setSecurityAnswer(null);
             }
 
             return Model.of(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(entity));
         } catch (Exception e) {
-            LOG.error("While (de)serializing entity {}", auditEntry, e);
-            throw new WicketRuntimeException(e);
+            LOG.error("While (de)serializing entity {}", auditEvent, e);
+            return Model.of();
         }
     }
 }
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/IdRepoImplementationInfoProvider.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/IdRepoImplementationInfoProvider.java
index 83507a8..4da290d 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/IdRepoImplementationInfoProvider.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/IdRepoImplementationInfoProvider.java
@@ -119,8 +119,12 @@
                 templateClassName = "MyLogicActions";
                 break;
 
-            case IdRepoImplementationType.VALIDATOR:
-                templateClassName = "MyValidator";
+            case IdRepoImplementationType.MACRO_ACTIONS:
+                templateClassName = "MyMacroActions";
+                break;
+
+            case IdRepoImplementationType.ATTR_VALUE_VALIDATOR:
+                templateClassName = "MyAttrValueValidator";
                 break;
 
             case IdRepoImplementationType.RECIPIENTS_PROVIDER:
@@ -208,6 +212,20 @@
     }
 
     @Override
+    public IModel<List<String>> getMacroActions() {
+        return new LoadableDetachableModel<>() {
+
+            private static final long serialVersionUID = 5275935387613157437L;
+
+            @Override
+            protected List<String> load() {
+                return implementationRestClient.list(IdRepoImplementationType.MACRO_ACTIONS).stream().
+                        map(ImplementationTO::getKey).sorted().collect(Collectors.toList());
+            }
+        };
+    }
+
+    @Override
     public IModel<List<String>> getPullActions() {
         return new LoadableDetachableModel<>() {
 
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/ImplementationInfoProvider.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/ImplementationInfoProvider.java
index 51638e4..d01cde7 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/ImplementationInfoProvider.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/commons/ImplementationInfoProvider.java
@@ -46,6 +46,8 @@
 
     IModel<List<String>> getReconFilterBuilders();
 
+    IModel<List<String>> getMacroActions();
+
     IModel<List<String>> getPullActions();
 
     IModel<List<String>> getPushActions();
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/BeanPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/BeanPanel.java
index cfec7ab..9960f1f 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/BeanPanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/BeanPanel.java
@@ -115,17 +115,18 @@
         this.excluded.add("serialVersionUID");
         this.excluded.add("class");
 
-        LoadableDetachableModel<List<String>> model = new LoadableDetachableModel<>() {
+        LoadableDetachableModel<List<Field>> model = new LoadableDetachableModel<>() {
 
             private static final long serialVersionUID = 5275935387613157437L;
 
             @Override
-            protected List<String> load() {
-                List<String> result = new ArrayList<>();
+            protected List<Field> load() {
+                List<Field> result = new ArrayList<>();
 
                 if (BeanPanel.this.getDefaultModelObject() != null) {
-                    ReflectionUtils.doWithFields(BeanPanel.this.getDefaultModelObject().getClass(),
-                            field -> result.add(field.getName()),
+                    ReflectionUtils.doWithFields(
+                            BeanPanel.this.getDefaultModelObject().getClass(),
+                            result::add,
                             field -> !field.isSynthetic() && !BeanPanel.this.excluded.contains(field.getName()));
                 }
 
@@ -137,7 +138,7 @@
 
             private static final long serialVersionUID = 9101744072914090143L;
 
-            private void setRequired(final ListItem<String> item, final boolean required) {
+            private void setRequired(final ListItem<Field> item, final boolean required) {
                 if (required) {
                     Fragment fragment = new Fragment("required", "requiredFragment", this);
                     fragment.add(new Label("requiredLabel", "*"));
@@ -145,7 +146,7 @@
                 }
             }
 
-            private void setDescription(final ListItem<String> item, final String description) {
+            private void setDescription(final ListItem<Field> item, final String description) {
                 Fragment fragment = new Fragment("description", "descriptionFragment", this);
                 fragment.add(new Label("descriptionLabel", Model.of()).add(new PopoverBehavior(
                         Model.<String>of(),
@@ -164,25 +165,20 @@
 
             @SuppressWarnings({ "unchecked", "rawtypes" })
             @Override
-            protected void populateItem(final ListItem<String> item) {
+            protected void populateItem(final ListItem<Field> item) {
                 item.add(new Fragment("required", "emptyFragment", this));
                 item.add(new Fragment("description", "emptyFragment", this));
 
-                String fieldName = item.getModelObject();
+                Field field = item.getModelObject();
 
-                item.add(new Label("fieldName", new ResourceModel(fieldName, fieldName)));
-
-                Field field = ReflectionUtils.findField(bean.getObject().getClass(), fieldName);
-                if (field == null) {
-                    return;
-                }
+                item.add(new Label("fieldName", new ResourceModel(field.getName(), field.getName())));
 
                 Panel panel;
 
                 SearchCondition scondAnnot = field.getAnnotation(SearchCondition.class);
                 if (scondAnnot != null) {
                     BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(bean.getObject());
-                    String fiql = (String) wrapper.getPropertyValue(fieldName);
+                    String fiql = (String) wrapper.getPropertyValue(field.getName());
 
                     List<SearchClause> clauses = Optional.ofNullable(fiql).
                             map(f -> SearchUtils.getSearchClauses(f.replaceAll(
@@ -211,7 +207,7 @@
                     }
 
                     Optional.ofNullable(BeanPanel.this.sCondWrapper).
-                            ifPresent(scw -> scw.put(fieldName, Pair.of(builder, clauses)));
+                            ifPresent(scw -> scw.put(field.getName(), Pair.of(builder, clauses)));
                 } else if (List.class.equals(field.getType())) {
                     Class<?> listItemType = field.getGenericType() instanceof ParameterizedType
                             ? (Class<?>) ((ParameterizedType) field.getGenericType()).getActualTypeArguments()[0]
@@ -243,15 +239,15 @@
                             }
                         }
 
-                        panel = new AjaxPalettePanel.Builder<>().setName(fieldName).build(
+                        panel = new AjaxPalettePanel.Builder<>().setName(field.getName()).build(
                                 "value",
-                                new PropertyModel<>(bean.getObject(), fieldName),
+                                new PropertyModel<>(bean.getObject(), field.getName()),
                                 new ListModel<>(choices.stream().map(SchemaTO::getKey).collect(Collectors.toList()))).
                                 hideLabel();
                     } else if (listItemType.isEnum()) {
-                        panel = new AjaxPalettePanel.Builder<>().setName(fieldName).build(
+                        panel = new AjaxPalettePanel.Builder<>().setName(field.getName()).build(
                                 "value",
-                                new PropertyModel<>(bean.getObject(), fieldName),
+                                new PropertyModel<>(bean.getObject(), field.getName()),
                                 new ListModel(List.of(listItemType.getEnumConstants()))).hideLabel();
                     } else {
                         Triple<FieldPanel, Boolean, Optional<String>> single =
@@ -262,14 +258,14 @@
                         single.getRight().ifPresent(description -> setDescription(item, description));
 
                         panel = new MultiFieldPanel.Builder<>(
-                                new PropertyModel<>(bean.getObject(), fieldName)).build(
+                                new PropertyModel<>(bean.getObject(), field.getName())).build(
                                 "value",
-                                fieldName,
+                                field.getName(),
                                 single.getLeft()).hideLabel();
                     }
                 } else if (Map.class.equals(field.getType())) {
                     panel = new AjaxGridFieldPanel(
-                            "value", fieldName, new PropertyModel<>(bean, fieldName)).hideLabel();
+                            "value", field.getName(), new PropertyModel<>(bean, field.getName())).hideLabel();
                     Optional.ofNullable(field.getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class)).
                             ifPresent(annot -> setDescription(item, annot.description()));
                 } else {
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/ParametersWizardPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/ParametersWizardPanel.java
index fa19891..01cf3d6 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/ParametersWizardPanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/ParametersWizardPanel.java
@@ -47,11 +47,6 @@
     }
 
     @Override
-    protected void onCancelInternal(final ParametersForm modelObject) {
-        //do nothing
-    }
-
-    @Override
     protected Serializable onApplyInternal(final ParametersForm modelObject) {
         modelObject.getParam().setMultivalue(modelObject.getSchema().isMultivalue());
         try {
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/PlainSchemaDetails.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/PlainSchemaDetails.java
index c3470c7..44ea3c7 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/PlainSchemaDetails.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/PlainSchemaDetails.java
@@ -257,7 +257,7 @@
 
             @Override
             protected List<String> load() {
-                return implementationRestClient.list(IdRepoImplementationType.VALIDATOR).stream().
+                return implementationRestClient.list(IdRepoImplementationType.ATTR_VALUE_VALIDATOR).stream().
                         map(ImplementationTO::getKey).sorted().collect(Collectors.toList());
             }
         };
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/StartAtTogglePanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/StartAtTogglePanel.java
index 35acde0..16a0dda 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/StartAtTogglePanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/panels/StartAtTogglePanel.java
@@ -53,18 +53,13 @@
         form = new Form<>("startAtForm");
         addInnerObject(form);
 
-        final AjaxDateTimeFieldPanel startAtDate = new AjaxDateTimeFieldPanel(
+        AjaxDateTimeFieldPanel startAtDate = new AjaxDateTimeFieldPanel(
                 "startAtDate", "startAtDate", startAtDateModel,
                 FastDateFormat.getInstance(SyncopeConstants.DATE_PATTERNS[3]));
+        form.add(startAtDate.setReadOnly(true).hideLabel());
 
-        startAtDate.setReadOnly(true).hideLabel();
-        form.add(startAtDate);
-
-        final AjaxCheckBoxPanel startAtCheck = new AjaxCheckBoxPanel(
+        AjaxCheckBoxPanel startAtCheck = new AjaxCheckBoxPanel(
                 "startAtCheck", "startAtCheck", new Model<>(false), false);
-
-        form.add(startAtCheck);
-
         startAtCheck.getField().add(new IndicatorAjaxFormComponentUpdatingBehavior(Constants.ON_CHANGE) {
 
             private static final long serialVersionUID = -1107858522700306810L;
@@ -74,6 +69,7 @@
                 target.add(startAtDate.setModelObject(null).setReadOnly(!startAtCheck.getModelObject()));
             }
         });
+        form.add(startAtCheck);
 
         form.add(new AjaxSubmitLink("startAt", form) {
 
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/reports/ReportWizardBuilder.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/reports/ReportWizardBuilder.java
index 1fe177a..053aeda 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/reports/ReportWizardBuilder.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/reports/ReportWizardBuilder.java
@@ -138,7 +138,7 @@
         return wizardModel;
     }
 
-    public class Profile extends WizardStep {
+    protected class Profile extends WizardStep {
 
         private static final long serialVersionUID = -3043839139187792810L;
 
@@ -192,7 +192,7 @@
         }
     }
 
-    public class Configuration extends WizardStep implements WizardModel.ICondition {
+    protected class Configuration extends WizardStep implements WizardModel.ICondition {
 
         private static final long serialVersionUID = -785981096328637758L;
 
@@ -219,7 +219,7 @@
         }
     }
 
-    public class Schedule extends WizardStep {
+    protected class Schedule extends WizardStep {
 
         private static final long serialVersionUID = -785981096328637758L;
 
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/rest/TaskRestClient.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/rest/TaskRestClient.java
index 33b5639..f7c5713 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/rest/TaskRestClient.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/rest/TaskRestClient.java
@@ -27,6 +27,7 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.client.lib.batch.BatchRequest;
 import org.apache.syncope.client.ui.commons.DateOps;
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.JobTO;
 import org.apache.syncope.common.lib.to.NotificationTaskTO;
@@ -192,6 +193,10 @@
         return getService(TaskService.class).read(type, taskKey, false);
     }
 
+    public SyncopeForm getMacroTaskForm(final String taskKey) {
+        return getService(TaskService.class).getMacroTaskForm(taskKey);
+    }
+
     public void delete(final TaskType type, final String taskKey) {
         getService(TaskService.class).delete(type, taskKey);
     }
@@ -202,8 +207,19 @@
     }
 
     public void startExecution(final String taskKey, final Date startAt, final boolean dryRun) {
-        getService(TaskService.class).execute(new ExecSpecs.Builder().key(taskKey).
-                startAt(DateOps.toOffsetDateTime(startAt)).dryRun(dryRun).build());
+        getService(TaskService.class).execute(
+                new ExecSpecs.Builder().key(taskKey).startAt(DateOps.toOffsetDateTime(startAt)).dryRun(dryRun).build());
+    }
+
+    public void startExecution(
+            final String taskKey,
+            final Date startAt,
+            final boolean dryRun,
+            final SyncopeForm macroTaskForm) {
+
+        getService(TaskService.class).execute(
+                new ExecSpecs.Builder().key(taskKey).startAt(DateOps.toOffsetDateTime(startAt)).dryRun(dryRun).build(),
+                macroTaskForm);
     }
 
     @Override
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeDirectoryPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeDirectoryPanel.java
index 56befe8..aa31489 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeDirectoryPanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeDirectoryPanel.java
@@ -72,22 +72,22 @@
     @SpringBean
     protected CommandRestClient commandRestClient;
 
-    protected final BaseModal<MacroTaskTO> baseModal;
-
     protected final String task;
 
+    protected final BaseModal<MacroTaskTO> baseModal;
+
     public CommandComposeDirectoryPanel(
+            final String task,
             final CommandRestClient restClient,
             final BaseModal<MacroTaskTO> baseModal,
-            final String task,
             final PageReference pageRef) {
 
         super(BaseModal.CONTENT_ID, restClient, pageRef, false);
 
         disableCheckBoxes();
 
-        this.baseModal = baseModal;
         this.task = task;
+        this.baseModal = baseModal;
 
         enableUtilityButton();
 
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder.java
index 00f9829..1d25bed 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder.java
@@ -83,6 +83,10 @@
 
     @Override
     protected Serializable onApplyInternal(final CommandWrapper modelObject) {
+        if (modelObject.getCommand().getArgs() == null) {
+            throw new IllegalArgumentException("Incorrect Command definition");
+        }
+
         MacroTaskTO taskTO = taskRestClient.readTask(TaskType.MACRO, task);
         if (modelObject.isNew()) {
             taskTO.getCommands().add(modelObject.getCommand());
@@ -112,7 +116,6 @@
 
         public Profile(final CommandWrapper command) {
             this.command = command;
-            MacroTaskTO taskTO = taskRestClient.readTask(TaskType.MACRO, task);
 
             AutoCompleteSettings settings = new AutoCompleteSettings();
             settings.setShowCompleteListOnFocusGain(false);
@@ -127,8 +130,7 @@
                 protected Iterator<String> getChoices(final String input) {
                     return commands.getObject().stream().
                             map(ImplementationTO::getKey).
-                            filter(cmd -> cmd.contains(input)
-                            && taskTO.getCommands().stream().noneMatch(c -> c.getKey().equals(cmd))).
+                            filter(cmd -> cmd.contains(input)).
                             sorted().iterator();
                 }
             };
@@ -140,8 +142,12 @@
 
                 @Override
                 protected void onUpdate(final AjaxRequestTarget target) {
-                    CommandTO cmd = commandRestClient.read(command.getCommand().getKey());
-                    command.getCommand().setArgs(cmd.getArgs());
+                    try {
+                        CommandTO cmd = commandRestClient.read(command.getCommand().getKey());
+                        command.getCommand().setArgs(cmd.getArgs());
+                    } catch (Exception e) {
+                        LOG.error("While attempting to read Command {}", command.getCommand().getKey(), e);
+                    }
                 }
             });
             add(args);
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.java
new file mode 100644
index 0000000..3d6210f
--- /dev/null
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.client.console.tasks;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.syncope.client.console.SyncopeConsoleSession;
+import org.apache.syncope.client.console.panels.AbstractModalPanel;
+import org.apache.syncope.client.console.rest.TaskRestClient;
+import org.apache.syncope.client.console.wicket.markup.html.bootstrap.dialog.BaseModal;
+import org.apache.syncope.client.console.wicket.markup.html.form.ActionLink;
+import org.apache.syncope.client.console.wicket.markup.html.form.ActionsPanel;
+import org.apache.syncope.client.ui.commons.Constants;
+import org.apache.syncope.client.ui.commons.ajax.form.IndicatorAjaxFormComponentUpdatingBehavior;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxCheckBoxPanel;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxDropDownChoicePanel;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxGridFieldPanel;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxTextFieldPanel;
+import org.apache.syncope.client.ui.commons.pages.BaseWebPage;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.common.lib.to.FormPropertyDefTO;
+import org.apache.syncope.common.lib.to.MacroTaskTO;
+import org.apache.syncope.common.lib.types.TaskType;
+import org.apache.wicket.PageReference;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.extensions.ajax.markup.html.IndicatingAjaxButton;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.list.ListItem;
+import org.apache.wicket.markup.html.list.ListView;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.PropertyModel;
+import org.apache.wicket.model.util.ListModel;
+import org.apache.wicket.spring.injection.annot.SpringBean;
+
+public class FormPropertyDefsPanel extends AbstractModalPanel<MacroTaskTO> {
+
+    private static final long serialVersionUID = 6991001927367507753L;
+
+    @SpringBean
+    protected TaskRestClient taskRestClient;
+
+    protected final MacroTaskTO task;
+
+    protected final IModel<List<FormPropertyDefTO>> model;
+
+    public FormPropertyDefsPanel(
+            final MacroTaskTO task,
+            final BaseModal<MacroTaskTO> modal,
+            final PageReference pageRef) {
+
+        super(modal, pageRef);
+        this.task = task;
+
+        WebMarkupContainer propertyDefContainer = new WebMarkupContainer("propertyDefContainer");
+        propertyDefContainer.setOutputMarkupId(true);
+        add(propertyDefContainer);
+
+        model = new ListModel<>(new ArrayList<>());
+        model.getObject().addAll(task.getFormPropertyDefs());
+
+        ListView<FormPropertyDefTO> propertyDefs = new ListView<>("propertyDefs", model) {
+
+            private static final long serialVersionUID = 1814616131938968887L;
+
+            @Override
+            protected void populateItem(final ListItem<FormPropertyDefTO> item) {
+                FormPropertyDefTO fpd = item.getModelObject();
+
+                AjaxTextFieldPanel key = new AjaxTextFieldPanel(
+                        "key",
+                        "key",
+                        new PropertyModel<>(fpd, "key"),
+                        true);
+                item.add(key.setRequired(true).hideLabel());
+
+                AjaxTextFieldPanel name = new AjaxTextFieldPanel(
+                        "name",
+                        "name",
+                        new PropertyModel<>(fpd, "name"),
+                        true);
+                item.add(name.setRequired(true).hideLabel());
+
+                AjaxCheckBoxPanel readable = new AjaxCheckBoxPanel(
+                        "readable",
+                        "readable",
+                        new PropertyModel<>(fpd, "readable"),
+                        true);
+                item.add(readable.hideLabel());
+
+                AjaxCheckBoxPanel writable = new AjaxCheckBoxPanel(
+                        "writable",
+                        "writable",
+                        new PropertyModel<>(fpd, "writable"),
+                        true);
+                item.add(writable.hideLabel());
+
+                AjaxCheckBoxPanel required = new AjaxCheckBoxPanel(
+                        "required",
+                        "required",
+                        new PropertyModel<>(fpd, "required"),
+                        true);
+                item.add(required.hideLabel());
+
+                AjaxDropDownChoicePanel<FormPropertyType> type = new AjaxDropDownChoicePanel<>(
+                        "type",
+                        "type",
+                        new PropertyModel<>(fpd, "type"),
+                        true);
+                type.setChoices(List.of(FormPropertyType.values())).setNullValid(false);
+                item.add(type.setRequired(true).hideLabel());
+
+                AjaxTextFieldPanel datePattern = new AjaxTextFieldPanel(
+                        "datePattern",
+                        "datePattern",
+                        new PropertyModel<>(fpd, "datePattern"),
+                        true);
+                datePattern.setEnabled(fpd.getType() == FormPropertyType.Date);
+                item.add(datePattern.hideLabel().setOutputMarkupId(true));
+
+                AjaxGridFieldPanel<String, String> enumValues = new AjaxGridFieldPanel<>(
+                        "enumValues",
+                        "enumValues",
+                        new PropertyModel<>(fpd, "enumValues"));
+                enumValues.setEnabled(fpd.getType() == FormPropertyType.Enum);
+                item.add(enumValues.hideLabel().setOutputMarkupId(true));
+
+                type.getField().add(new IndicatorAjaxFormComponentUpdatingBehavior(Constants.ON_CHANGE) {
+
+                    private static final long serialVersionUID = -1107858522700306810L;
+
+                    @Override
+                    protected void onUpdate(final AjaxRequestTarget target) {
+                        switch (type.getModelObject()) {
+                            case Date -> {
+                                datePattern.setEnabled(true);
+                                enumValues.setEnabled(false);
+                                fpd.getEnumValues().clear();
+                            }
+
+                            case Enum -> {
+                                datePattern.setEnabled(false);
+                                enumValues.setEnabled(true);
+                            }
+
+                            default -> {
+                                datePattern.setEnabled(false);
+                                enumValues.setEnabled(false);
+                                fpd.getEnumValues().clear();
+                            }
+                        }
+
+                        target.add(datePattern);
+                        target.add(enumValues);
+                    }
+                });
+
+                ActionsPanel<Serializable> actions = new ActionsPanel<>("toRemove", null);
+                actions.add(new ActionLink<>() {
+
+                    private static final long serialVersionUID = -3722207913631435501L;
+
+                    @Override
+                    public void onClick(final AjaxRequestTarget target, final Serializable ignore) {
+                        model.getObject().remove(item.getIndex());
+                        item.getParent().removeAll();
+                        target.add(propertyDefContainer);
+                    }
+                }, ActionLink.ActionType.DELETE, StringUtils.EMPTY, true).hideLabel();
+                item.add(actions);
+            }
+        };
+        propertyDefs.setReuseItems(true);
+        propertyDefContainer.add(propertyDefs);
+
+        IndicatingAjaxButton addPropertyDef = new IndicatingAjaxButton("addPropertyDef") {
+
+            private static final long serialVersionUID = -4804368561204623354L;
+
+            @Override
+            protected void onSubmit(final AjaxRequestTarget target) {
+                model.getObject().add(new FormPropertyDefTO());
+                target.add(propertyDefContainer);
+            }
+        };
+        addPropertyDef.setDefaultFormProcessing(false);
+        propertyDefContainer.add(addPropertyDef);
+    }
+
+    @Override
+    public void onSubmit(final AjaxRequestTarget target) {
+        task.getFormPropertyDefs().clear();
+        task.getFormPropertyDefs().addAll(model.getObject());
+        try {
+            taskRestClient.update(TaskType.MACRO, task);
+
+            SyncopeConsoleSession.get().success(getString(Constants.OPERATION_SUCCEEDED));
+            modal.close(target);
+        } catch (Exception e) {
+            LOG.error("While updating Macro Task {}", task.getKey(), e);
+            SyncopeConsoleSession.get().onException(e);
+        }
+        ((BaseWebPage) pageRef.getPage()).getNotificationPanel().refresh(target);
+    }
+}
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.java
index c314562..345fda7 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.java
@@ -18,6 +18,8 @@
  */
 package org.apache.syncope.client.console.tasks;
 
+import de.agilecoders.wicket.core.markup.html.bootstrap.dialog.Modal;
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.tuple.Pair;
@@ -25,13 +27,19 @@
 import org.apache.syncope.client.console.rest.CommandRestClient;
 import org.apache.syncope.client.console.rest.TaskRestClient;
 import org.apache.syncope.client.console.wicket.extensions.markup.html.repeater.data.table.BooleanPropertyColumn;
+import org.apache.syncope.client.console.wicket.markup.html.bootstrap.dialog.BaseModal;
+import org.apache.syncope.client.console.wicket.markup.html.form.Action;
 import org.apache.syncope.client.console.wicket.markup.html.form.ActionLink;
 import org.apache.syncope.client.console.wicket.markup.html.form.ActionsPanel;
+import org.apache.syncope.client.ui.commons.Constants;
+import org.apache.syncope.client.ui.commons.wizards.AjaxWizard;
 import org.apache.syncope.common.lib.to.MacroTaskTO;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
 import org.apache.syncope.common.lib.types.TaskType;
 import org.apache.wicket.PageReference;
 import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.event.IEvent;
+import org.apache.wicket.event.IEventSink;
 import org.apache.wicket.extensions.markup.html.repeater.data.table.IColumn;
 import org.apache.wicket.model.IModel;
 import org.apache.wicket.model.Model;
@@ -43,15 +51,55 @@
 
     private static final long serialVersionUID = -6247673131495530094L;
 
+    protected class ExecModalEventSink implements IEventSink, Serializable {
+
+        private static final long serialVersionUID = -5961049309874978659L;
+
+        @Override
+        public void onEvent(final IEvent<?> event) {
+            if (event.getPayload() instanceof AjaxWizard.NewItemCancelEvent
+                    || event.getPayload() instanceof AjaxWizard.NewItemFinishEvent) {
+
+                AjaxWizard.NewItemEvent<?> nie = AjaxWizard.NewItemEvent.class.cast(event.getPayload());
+                nie.getTarget().ifPresent(execModal::close);
+            }
+
+            MacroTaskDirectoryPanel.this.onEvent(event);
+        }
+    }
+
     @SpringBean
     protected CommandRestClient commandRestClient;
 
+    protected final BaseModal<MacroTaskTO> formPropertyDefModal = new BaseModal<>(Constants.OUTER);
+
+    protected final BaseModal<MacroTaskTO> execModal;
+
     public MacroTaskDirectoryPanel(
             final TaskRestClient restClient,
             final MultilevelPanel mlp,
             final PageReference pageRef) {
 
         super(MultilevelPanel.FIRST_LEVEL_ID, restClient, null, mlp, TaskType.MACRO, new MacroTaskTO(), pageRef, true);
+
+        formPropertyDefModal.size(Modal.Size.Extra_large);
+        formPropertyDefModal.addSubmitButton();
+        setWindowClosedReloadCallback(formPropertyDefModal);
+        addOuterObject(formPropertyDefModal);
+
+        execModal = new BaseModal<>(Constants.OUTER) {
+
+            private static final long serialVersionUID = 389935548143327858L;
+
+            @Override
+            protected void onConfigure() {
+                super.onConfigure();
+                setFooterVisible(false);
+            }
+        };
+        execModal.size(Modal.Size.Large);
+        setWindowClosedReloadCallback(execModal);
+        addOuterObject(execModal);
     }
 
     @Override
@@ -79,6 +127,36 @@
     }
 
     @Override
+    public ActionsPanel<MacroTaskTO> getActions(final IModel<MacroTaskTO> model) {
+        ActionsPanel<MacroTaskTO> panel = super.getActions(model);
+
+        panel.getActions().removeIf(action -> action.getType() == ActionLink.ActionType.EXECUTE);
+
+        Action<MacroTaskTO> execute = new Action<>(new ActionLink<>() {
+
+            private static final long serialVersionUID = -3722207913631435501L;
+
+            @Override
+            public void onClick(final AjaxRequestTarget target, final MacroTaskTO ignore) {
+                MacroTaskExecWizardBuilder wb = new MacroTaskExecWizardBuilder(model.getObject(), restClient, pageRef);
+                wb.setEventSink(new ExecModalEventSink());
+
+                target.add(execModal.setContent(wb.build(BaseModal.CONTENT_ID, AjaxWizard.Mode.EDIT)));
+
+                execModal.header(new StringResourceModel(
+                        "exec", MacroTaskDirectoryPanel.this, Model.of(model.getObject())));
+                execModal.show(true);
+            }
+        }, ActionLink.ActionType.EXECUTE);
+        execute.setEntitlements(IdRepoEntitlement.TASK_EXECUTE);
+        execute.setOnConfirm(false);
+
+        panel.add(panel.getActions().size() - 1, execute);
+
+        return panel;
+    }
+
+    @Override
     protected void addFurtherActions(final ActionsPanel<MacroTaskTO> panel, final IModel<MacroTaskTO> model) {
         panel.add(new ActionLink<>() {
 
@@ -87,12 +165,28 @@
             @Override
             public void onClick(final AjaxRequestTarget target, final MacroTaskTO ignore) {
                 target.add(modal.setContent(new CommandComposeDirectoryPanel(
-                        commandRestClient, modal, model.getObject().getKey(), pageRef)));
+                        model.getObject().getKey(), commandRestClient, modal, pageRef)));
 
                 modal.header(new StringResourceModel(
                         "command.conf", MacroTaskDirectoryPanel.this, Model.of(model.getObject())));
                 modal.show(true);
             }
         }, ActionLink.ActionType.COMPOSE, IdRepoEntitlement.TASK_UPDATE);
+
+        panel.add(new ActionLink<>() {
+
+            private static final long serialVersionUID = -3722207913631435501L;
+
+            @Override
+            public void onClick(final AjaxRequestTarget target, final MacroTaskTO ignore) {
+                model.setObject(restClient.readTask(TaskType.MACRO, model.getObject().getKey()));
+                target.add(formPropertyDefModal.setContent(
+                        new FormPropertyDefsPanel(model.getObject(), formPropertyDefModal, pageRef)));
+
+                formPropertyDefModal.header(new StringResourceModel(
+                        "form.def", MacroTaskDirectoryPanel.this, Model.of(model.getObject())));
+                formPropertyDefModal.show(true);
+            }
+        }, ActionLink.ActionType.MAPPING, IdRepoEntitlement.TASK_UPDATE);
     }
 }
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder.java
new file mode 100644
index 0000000..47c980b
--- /dev/null
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.client.console.tasks;
+
+import java.io.Serializable;
+import java.util.Date;
+import org.apache.commons.lang3.time.FastDateFormat;
+import org.apache.syncope.client.console.rest.TaskRestClient;
+import org.apache.syncope.client.console.wizards.BaseAjaxWizardBuilder;
+import org.apache.syncope.client.ui.commons.Constants;
+import org.apache.syncope.client.ui.commons.ajax.form.IndicatorAjaxFormComponentUpdatingBehavior;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxCheckBoxPanel;
+import org.apache.syncope.client.ui.commons.markup.html.form.AjaxDateTimeFieldPanel;
+import org.apache.syncope.client.ui.commons.panels.SyncopeFormPanel;
+import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+import org.apache.syncope.common.lib.to.MacroTaskTO;
+import org.apache.wicket.PageReference;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.extensions.wizard.WizardModel;
+import org.apache.wicket.extensions.wizard.WizardStep;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+public class MacroTaskExecWizardBuilder extends BaseAjaxWizardBuilder<MacroTaskTO> {
+
+    private static final long serialVersionUID = 3318576575286024205L;
+
+    protected final TaskRestClient taskRestClient;
+
+    protected final IModel<SyncopeForm> formModel = Model.of();
+
+    protected final Model<Date> startAtDateModel = new Model<>();
+
+    protected final Model<Boolean> dryRunModel = new Model<>(false);
+
+    public MacroTaskExecWizardBuilder(
+            final MacroTaskTO defaultItem,
+            final TaskRestClient taskRestClient,
+            final PageReference pageRef) {
+
+        super(defaultItem, pageRef);
+        this.taskRestClient = taskRestClient;
+    }
+
+    @Override
+    protected Serializable onApplyInternal(final MacroTaskTO modelObject) {
+        if (formModel.getObject() == null) {
+            taskRestClient.startExecution(modelObject.getKey(),
+                    startAtDateModel.getObject(),
+                    dryRunModel.getObject());
+        } else {
+            taskRestClient.startExecution(modelObject.getKey(),
+                    startAtDateModel.getObject(),
+                    dryRunModel.getObject(),
+                    formModel.getObject());
+        }
+
+        return null;
+    }
+
+    @Override
+    protected WizardModel buildModelSteps(final MacroTaskTO modelObject, final WizardModel wizardModel) {
+        if (!modelObject.getFormPropertyDefs().isEmpty()) {
+            formModel.setObject(taskRestClient.getMacroTaskForm(modelObject.getKey()));
+            wizardModel.add(new Form());
+        }
+        wizardModel.add(new StartAt());
+        return wizardModel;
+    }
+
+    protected class Form extends WizardStep {
+
+        private static final long serialVersionUID = 7352192594863229013L;
+
+        protected Form() {
+            add(new SyncopeFormPanel<>("form", formModel.getObject()));
+        }
+    }
+
+    protected class StartAt extends WizardStep {
+
+        private static final long serialVersionUID = -961082324376783538L;
+
+        protected StartAt() {
+            AjaxDateTimeFieldPanel startAtDate = new AjaxDateTimeFieldPanel(
+                    "startAtDate", "startAtDate", startAtDateModel,
+                    FastDateFormat.getInstance(SyncopeConstants.DATE_PATTERNS[3]));
+            add(startAtDate.setReadOnly(true).hideLabel());
+
+            AjaxCheckBoxPanel startAtCheck = new AjaxCheckBoxPanel(
+                    "startAtCheck", "startAtCheck", new Model<>(false), false);
+            startAtCheck.getField().add(new IndicatorAjaxFormComponentUpdatingBehavior(Constants.ON_CHANGE) {
+
+                private static final long serialVersionUID = -1107858522700306810L;
+
+                @Override
+                protected void onUpdate(final AjaxRequestTarget target) {
+                    target.add(startAtDate.setModelObject(null).setReadOnly(!startAtCheck.getModelObject()));
+                }
+            });
+            add(startAtCheck);
+
+            add(new AjaxCheckBoxPanel("dryRun", "dryRun", dryRunModel, false));
+        }
+    }
+}
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder.java
index 3f2fb17..e7d82c6 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder.java
@@ -134,6 +134,9 @@
         private final IModel<List<String>> reconFilterBuilders = SyncopeWebApplication.get().
                 getImplementationInfoProvider().getReconFilterBuilders();
 
+        private final IModel<List<String>> macroActions = SyncopeWebApplication.get().
+                getImplementationInfoProvider().getMacroActions();
+
         private final IModel<List<String>> pullActions = SyncopeWebApplication.get().
                 getImplementationInfoProvider().getPullActions();
 
@@ -176,9 +179,8 @@
             WebMarkupContainer macroTaskSpecifics = new WebMarkupContainer("macroTaskSpecifics");
             add(macroTaskSpecifics.setRenderBodyOnly(true));
 
-            AjaxSearchFieldPanel realm =
-                    new AjaxSearchFieldPanel("realm", "realm",
-                            new PropertyModel<>(taskTO, "realm"), settings) {
+            AjaxSearchFieldPanel realm = new AjaxSearchFieldPanel(
+                    "realm", "realm", new PropertyModel<>(taskTO, "realm"), settings) {
 
                 private static final long serialVersionUID = -6390474600233486704L;
 
@@ -189,7 +191,6 @@
                             : List.<String>of()).iterator();
                 }
             };
-
             if (taskTO instanceof MacroTaskTO) {
                 realm.addRequiredLabel();
                 if (StringUtils.isBlank(MacroTaskTO.class.cast(taskTO).getRealm())) {
@@ -199,6 +200,10 @@
             }
             macroTaskSpecifics.add(realm);
 
+            macroTaskSpecifics.add(new AjaxDropDownChoicePanel<>(
+                    "macroActions", "macroActions", new PropertyModel<>(taskTO, "macroActions"), false).
+                    setChoices(macroActions));
+
             AjaxCheckBoxPanel continueOnError = new AjaxCheckBoxPanel(
                     "continueOnError", "continueOnError", new PropertyModel<>(taskTO, "continueOnError"), false);
             macroTaskSpecifics.add(continueOnError);
@@ -243,10 +248,8 @@
 
                 @Override
                 protected void onUpdate(final AjaxRequestTarget target) {
-                    reconFilterBuilder.setEnabled(
-                            pullMode.getModelObject() == PullMode.FILTERED_RECONCILIATION);
-                    reconFilterBuilder.setRequired(
-                            pullMode.getModelObject() == PullMode.FILTERED_RECONCILIATION);
+                    reconFilterBuilder.setEnabled(pullMode.getModelObject() == PullMode.FILTERED_RECONCILIATION);
+                    reconFilterBuilder.setRequired(pullMode.getModelObject() == PullMode.FILTERED_RECONCILIATION);
                     target.add(reconFilterBuilder);
                 }
             });
@@ -337,8 +340,7 @@
             provisioningTaskSpecifics.add(matchingRule);
 
             AjaxDropDownChoicePanel<UnmatchingRule> unmatchingRule = new AjaxDropDownChoicePanel<>(
-                    "unmatchingRule", "unmatchingRule", new PropertyModel<>(taskTO, "unmatchingRule"),
-                    false);
+                    "unmatchingRule", "unmatchingRule", new PropertyModel<>(taskTO, "unmatchingRule"), false);
             unmatchingRule.setChoices(List.of(UnmatchingRule.values()));
             provisioningTaskSpecifics.add(unmatchingRule);
 
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/widgets/JobWidget.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/widgets/JobWidget.java
index e4a1bc2..3a9f51b 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/widgets/JobWidget.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/widgets/JobWidget.java
@@ -59,8 +59,8 @@
 import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.JobTO;
 import org.apache.syncope.common.lib.to.ProvisioningTaskTO;
-import org.apache.syncope.common.lib.to.PullTaskTO;
 import org.apache.syncope.common.lib.to.ReportTO;
+import org.apache.syncope.common.lib.to.TaskTO;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
 import org.apache.syncope.common.lib.types.JobAction;
 import org.apache.syncope.common.lib.types.JobType;
@@ -420,29 +420,48 @@
                             break;
 
                         case TASK:
-                            ProvisioningTaskTO schedTaskTO;
-                            try {
-                                schedTaskTO = taskRestClient.readTask(TaskType.PULL, jobTO.getRefKey());
-                            } catch (Exception e) {
-                                LOG.debug("Failed to read {} as {}, attempting {}",
-                                        jobTO.getRefKey(), TaskType.PULL, TaskType.PUSH, e);
-                                schedTaskTO = taskRestClient.readTask(TaskType.PUSH, jobTO.getRefKey());
+                            TaskType taskType = null;
+                            if (jobTO.getRefDesc().startsWith("SCHEDULED")) {
+                                taskType = TaskType.SCHEDULED;
+                            } else if (jobTO.getRefDesc().startsWith("PULL")) {
+                                taskType = TaskType.PULL;
+                            } else if (jobTO.getRefDesc().startsWith("PUSH")) {
+                                taskType = TaskType.PUSH;
+                            } else if (jobTO.getRefDesc().startsWith("MACRO")) {
+                                taskType = TaskType.MACRO;
+                            }
+                            if (taskType == null) {
+                                break;
                             }
 
-                            SchedTaskWizardBuilder<ProvisioningTaskTO> swb =
-                                    new SchedTaskWizardBuilder<>(schedTaskTO instanceof PullTaskTO
-                                            ? TaskType.PULL : TaskType.PUSH, schedTaskTO,
-                                            realmRestClient, taskRestClient, pageRef);
-                            swb.setEventSink(AvailableJobsPanel.this);
+                            TaskTO taskTO = null;
+                            try {
+                                taskTO = taskRestClient.readTask(taskType, jobTO.getRefKey());
+                            } catch (Exception e) {
+                                LOG.debug("Failed to read {} as {}", jobTO.getRefKey(), taskType, e);
+                            }
+                            if (taskTO == null) {
+                                break;
+                            }
 
-                            target.add(jobModal.setContent(swb.build(BaseModal.CONTENT_ID, AjaxWizard.Mode.EDIT)));
+                            if (taskTO instanceof ProvisioningTaskTO) {
+                                SchedTaskWizardBuilder<ProvisioningTaskTO> swb =
+                                        new SchedTaskWizardBuilder<>(taskType, (ProvisioningTaskTO) taskTO,
+                                                realmRestClient, taskRestClient, pageRef);
+                                swb.setEventSink(AvailableJobsPanel.this);
 
-                            jobModal.header(new StringResourceModel(
-                                    "any.edit",
-                                    AvailableJobsPanel.this,
-                                    new Model<>(schedTaskTO)));
+                                target.add(jobModal.setContent(swb.build(BaseModal.CONTENT_ID, AjaxWizard.Mode.EDIT)));
 
-                            jobModal.show(true);
+                                jobModal.header(new StringResourceModel(
+                                        "any.edit",
+                                        AvailableJobsPanel.this,
+                                        new Model<>(taskTO)));
+
+                                jobModal.show(true);
+                            } else {
+                                SyncopeConsoleSession.get().info("Unsupported task type: " + taskType.name());
+                                ((BasePage) pageRef.getPage()).getNotificationPanel().refresh(target);
+                            }
                             break;
 
                         default:
diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/mapping/AbstractMappingPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/mapping/AbstractMappingPanel.java
index 60fd5d0..b555049 100644
--- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/mapping/AbstractMappingPanel.java
+++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/mapping/AbstractMappingPanel.java
@@ -22,6 +22,7 @@
 import de.agilecoders.wicket.core.markup.html.bootstrap.components.PopoverConfig;
 import de.agilecoders.wicket.core.markup.html.bootstrap.components.TooltipConfig;
 import java.io.Serializable;
+import java.util.Comparator;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.client.console.wicket.markup.html.form.ActionLink;
@@ -52,6 +53,45 @@
 
     private static final long serialVersionUID = -8295587900937040104L;
 
+    protected static final Comparator<Item> ITEM_COMPARATOR = (left, right) -> {
+        int compared;
+        if (left == null && right == null) {
+            compared = 0;
+        } else if (left == null) {
+            compared = 1;
+        } else if (right == null) {
+            compared = -1;
+        } else if (left.isConnObjectKey()) {
+            compared = -1;
+        } else if (right.isConnObjectKey()) {
+            compared = 1;
+        } else if (left.isPassword()) {
+            compared = -1;
+        } else if (right.isPassword()) {
+            compared = 1;
+        } else if (left.getPurpose() == MappingPurpose.BOTH && right.getPurpose() != MappingPurpose.BOTH) {
+            compared = -1;
+        } else if (left.getPurpose() != MappingPurpose.BOTH && right.getPurpose() == MappingPurpose.BOTH) {
+            compared = 1;
+        } else if (left.getPurpose() == MappingPurpose.PROPAGATION
+                && (right.getPurpose() == MappingPurpose.PULL
+                || right.getPurpose() == MappingPurpose.NONE)) {
+            compared = -1;
+        } else if (left.getPurpose() == MappingPurpose.PULL
+                && right.getPurpose() == MappingPurpose.PROPAGATION) {
+            compared = 1;
+        } else if (left.getPurpose() == MappingPurpose.PULL
+                && right.getPurpose() == MappingPurpose.NONE) {
+            compared = -1;
+        } else if (left.getPurpose() == MappingPurpose.NONE
+                && right.getPurpose() != MappingPurpose.NONE) {
+            compared = 1;
+        } else {
+            compared = left.getIntAttrName().compareTo(right.getIntAttrName());
+        }
+        return compared;
+    };
+
     protected final Label connObjectKeyLabel;
 
     protected final Label passwordLabel;
@@ -131,44 +171,7 @@
         mandatoryHeader.add(Constants.getJEXLPopover(this, TooltipConfig.Placement.bottom));
         mappingContainer.add(mandatoryHeader);
 
-        model.getObject().sort((left, right) -> {
-            int compared;
-            if (left == null && right == null) {
-                compared = 0;
-            } else if (left == null) {
-                compared = 1;
-            } else if (right == null) {
-                compared = -1;
-            } else if (left.isConnObjectKey()) {
-                compared = -1;
-            } else if (right.isConnObjectKey()) {
-                compared = 1;
-            } else if (left.isPassword()) {
-                compared = -1;
-            } else if (right.isPassword()) {
-                compared = 1;
-            } else if (left.getPurpose() == MappingPurpose.BOTH && right.getPurpose() != MappingPurpose.BOTH) {
-                compared = -1;
-            } else if (left.getPurpose() != MappingPurpose.BOTH && right.getPurpose() == MappingPurpose.BOTH) {
-                compared = 1;
-            } else if (left.getPurpose() == MappingPurpose.PROPAGATION
-                    && (right.getPurpose() == MappingPurpose.PULL
-                    || right.getPurpose() == MappingPurpose.NONE)) {
-                compared = -1;
-            } else if (left.getPurpose() == MappingPurpose.PULL
-                    && right.getPurpose() == MappingPurpose.PROPAGATION) {
-                compared = 1;
-            } else if (left.getPurpose() == MappingPurpose.PULL
-                    && right.getPurpose() == MappingPurpose.NONE) {
-                compared = -1;
-            } else if (left.getPurpose() == MappingPurpose.NONE
-                    && right.getPurpose() != MappingPurpose.NONE) {
-                compared = 1;
-            } else {
-                compared = left.getIntAttrName().compareTo(right.getIntAttrName());
-            }
-            return compared;
-        });
+        model.getObject().sort(ITEM_COMPARATOR);
 
         mappings = new ListView<>("mappings", model) {
 
@@ -289,7 +292,6 @@
                     @Override
                     public void onClick(final AjaxRequestTarget target, final Serializable ignore) {
                         model.getObject().remove(item.getIndex());
-
                         item.getParent().removeAll();
                         target.add(AbstractMappingPanel.this);
                     }
@@ -424,7 +426,7 @@
      * @param connObjectKey connObjectKey checkbox.
      * @param password password checkbox.
      */
-    private static void setConnObjectKey(final AjaxCheckBoxPanel connObjectKey, final AjaxCheckBoxPanel password) {
+    protected void setConnObjectKey(final AjaxCheckBoxPanel connObjectKey, final AjaxCheckBoxPanel password) {
         if (password.getModelObject()) {
             connObjectKey.setReadOnly(true);
             connObjectKey.setModelObject(false);
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyValidator.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyAttrValueValidator.groovy
similarity index 92%
rename from client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyValidator.groovy
rename to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyAttrValueValidator.groovy
index b0c3e37..9e9a79d 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyValidator.groovy
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyAttrValueValidator.groovy
@@ -17,12 +17,12 @@
  * under the License.
  */
 import groovy.transform.CompileStatic
-import org.apache.syncope.core.persistence.api.attrvalue.validation.Validator
+import org.apache.syncope.core.persistence.api.attrvalue.validation.PlainAttrValueValidator
 import org.apache.syncope.core.persistence.api.entity.PlainAttrValue
 import org.apache.syncope.core.persistence.api.entity.PlainSchema
 
 @CompileStatic
-class MyValidator implements Validator {
+class MyAttrValueValidator implements PlainAttrValueValidator {
   
   @Override
   void setSchema(PlainSchema schema) {
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyCommand.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyCommand.groovy
index 3b59310..3dd7c7f 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyCommand.groovy
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyCommand.groovy
@@ -1,4 +1,3 @@
-
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -19,7 +18,7 @@
  */
 import groovy.transform.CompileStatic
 import org.apache.syncope.common.lib.command.CommandArgs
-import org.apache.syncope.core.logic.api.Command
+import org.apache.syncope.core.provisioning.api.macro.Command
 
 @CompileStatic
 class MyCommand implements Command<CommandArgs> {
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyMacroActions.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyMacroActions.groovy
new file mode 100644
index 0000000..c920b03
--- /dev/null
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyMacroActions.groovy
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.
+ */
+import groovy.transform.CompileStatic
+import java.util.Map
+import javax.xml.bind.ValidationException
+import org.apache.syncope.common.lib.command.CommandArgs
+import org.apache.syncope.common.lib.form.MacroTaskForm
+import org.apache.syncope.core.provisioning.api.macro.Command
+import org.apache.syncope.core.provisioning.api.macro.MacroActions
+
+@CompileStatic
+class MyMacroActions implements MacroActions {
+
+  @Override
+  void validate(MacroTaskForm macroTaskForm) throws ValidationException {
+  }
+  
+  @Override
+  Map<String, String> getDropdownValues(String formProperty) {
+    return Map.of();
+  }
+  
+  @Override
+  void beforeAll() {
+  }
+
+  @Override
+  void beforeCommand(Command<CommandArgs> command, CommandArgs args) {
+  }
+
+  @Override
+  void afterCommand(Command<CommandArgs> command, CommandArgs args, String output) {
+  }
+
+  @Override
+  StringBuilder afterAll(StringBuilder output) {
+    return output;
+  }
+}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPropagationActions.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPropagationActions.groovy
index 88a297f..9ad6281 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPropagationActions.groovy
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPropagationActions.groovy
@@ -1,4 +1,3 @@
-
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyProvisionSorter.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyProvisionSorter.groovy
index f9d9bb7..6605e7e 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyProvisionSorter.groovy
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyProvisionSorter.groovy
@@ -1,4 +1,3 @@
-
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPushActions.groovy b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPushActions.groovy
index 9d4cbeb..63ed115 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPushActions.groovy
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/implementations/MyPushActions.groovy
@@ -1,4 +1,3 @@
-
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder$Profile.html b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder$Profile.html
index e76dc6a..ba21e2f 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder$Profile.html
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/CommandComposeWizardBuilder$Profile.html
@@ -20,7 +20,7 @@
   <wicket:panel>
     <div class="form-group">
       <label class="form-label" for="command"><wicket:message key="command"/></label>
-      <input type="text" class="form-control col-xs-4"  wicket:id="command"/>
+      <input type="text" class="form-control col-xs-4" wicket:id="command"/>
     </div>
   </wicket:panel>
 </html>
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.html b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.html
new file mode 100644
index 0000000..f40c2b5
--- /dev/null
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.html
@@ -0,0 +1,84 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF 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.
+-->
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
+  <wicket:extend>
+    <div class="table-responsive no-padding">
+      <table id="mappings"
+             class="table table-hover"
+             style="font-size: 1em;margin-top:2px;"
+             wicket:id="propertyDefContainer">
+        <tbody>
+          <tr>
+            <th><wicket:message key="key"/></th>
+            <th><wicket:message key="name"/></th>
+            <th><wicket:message key="type"/></th>
+            <th><wicket:message key="readable"/></th>
+            <th><wicket:message key="writable"/></th>
+            <th><wicket:message key="required"/></th>
+            <th><wicket:message key="datePattern"/></th>
+            <th><wicket:message key="enumValues"/></th>
+            <th></th>
+          </tr>
+
+          <tr wicket:id="propertyDefs">
+            <td>
+              <span wicket:id="key"/>
+            </td>
+            <td>
+              <span wicket:id="name"/>
+            </td>
+            <td>
+              <span wicket:id="type"/>
+            </td>
+            <td>
+              <span wicket:id="readable"/>
+            </td>
+            <td>
+              <span wicket:id="writable"/>
+            </td>
+            <td>
+              <span wicket:id="required"/>
+            </td>
+            <td>
+              <span wicket:id="datePattern"/>
+            </td>
+            <td>
+              <span wicket:id="enumValues"/>
+            </td>
+            <td>
+              <div id="inline-actions">
+                <span wicket:id="toRemove"/>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+
+        <tfoot>
+          <tr>
+            <td colspan="10" style="padding: 5px; text-align: right">
+              <button type="submit" class="btn btn-success btn-circle btn-lg" wicket:id="addPropertyDef">
+                <i class="fa fa-plus"></i>
+              </button>
+            </td>
+          </tr>
+        </tfoot>        
+      </table>
+    </div>
+  </wicket:extend>
+</html>
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.properties
index 5a9cc2d..8ba3a3a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Type
+readable=Readable
+writable=Writable
+required=Required
+datePattern=Date pattern
+enumValues=Enum values
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_fr_CA.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_fr_CA.properties
index 5a9cc2d..8ba3a3a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_fr_CA.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Type
+readable=Readable
+writable=Writable
+required=Required
+datePattern=Date pattern
+enumValues=Enum values
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_it.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_it.properties
index 5a9cc2d..62a3a2d 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_it.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Tipo
+readable=Lettura
+writable=Scrittura
+required=Obbligatorio
+datePattern=Modello data
+enumValues=Valori enum
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ja.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ja.properties
index 5a9cc2d..8ba3a3a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ja.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Type
+readable=Readable
+writable=Writable
+required=Required
+datePattern=Date pattern
+enumValues=Enum values
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_pt_BR.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_pt_BR.properties
index 5a9cc2d..8ba3a3a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_pt_BR.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Type
+readable=Readable
+writable=Writable
+required=Required
+datePattern=Date pattern
+enumValues=Enum values
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ru.properties
similarity index 86%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ru.properties
index 5a9cc2d..8ba3a3a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/FormPropertyDefsPanel_ru.properties
@@ -14,5 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+type=Type
+readable=Readable
+writable=Writable
+required=Required
+datePattern=Date pattern
+enumValues=Enum values
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.properties
index ed52486..0595325 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel.properties
@@ -19,3 +19,7 @@
 continueOnError=Continue on error
 saveExecs=Save executions
 any.new=New Macro
+macroActions=Macro Actions
+form.def=Form definition
+mapping.title=form properties
+exec=Exec Macro ${name}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_fr_CA.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_fr_CA.properties
index ed52486..0595325 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_fr_CA.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_fr_CA.properties
@@ -19,3 +19,7 @@
 continueOnError=Continue on error
 saveExecs=Save executions
 any.new=New Macro
+macroActions=Macro Actions
+form.def=Form definition
+mapping.title=form properties
+exec=Exec Macro ${name}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_it.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_it.properties
index 006bf2d..442c5f3 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_it.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_it.properties
@@ -19,3 +19,7 @@
 continueOnError=Continuare in caso di errore
 saveExecs=Salvare esecuzioni
 any.new=Nuova Macro
+macroActions=Azioni macro
+form.def=Definizione della form
+mapping.title=propriet\u00e0 della form
+exec=Esegui Macro ${name}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ja.properties
index ed52486..0595325 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ja.properties
@@ -19,3 +19,7 @@
 continueOnError=Continue on error
 saveExecs=Save executions
 any.new=New Macro
+macroActions=Macro Actions
+form.def=Form definition
+mapping.title=form properties
+exec=Exec Macro ${name}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_pt_BR.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_pt_BR.properties
index ed52486..0595325 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_pt_BR.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_pt_BR.properties
@@ -19,3 +19,7 @@
 continueOnError=Continue on error
 saveExecs=Save executions
 any.new=New Macro
+macroActions=Macro Actions
+form.def=Form definition
+mapping.title=form properties
+exec=Exec Macro ${name}
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ru.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ru.properties
index ed52486..0595325 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ru.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskDirectoryPanel_ru.properties
@@ -19,3 +19,7 @@
 continueOnError=Continue on error
 saveExecs=Save executions
 any.new=New Macro
+macroActions=Macro Actions
+form.def=Form definition
+mapping.title=form properties
+exec=Exec Macro ${name}
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$Form.html
similarity index 73%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$Form.html
index aef81e5..d684b5e 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$Form.html
@@ -18,14 +18,6 @@
 -->
 <html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
   <wicket:panel>
-    <div wicket:id="propView">
-      <span wicket:id="value">[value]</span>
-    </div>
-
-    <div style="margin: 20px 0">
-      <a href="#" alt="user details" class="btn btn-success btn-circle btn-lg" wicket:id="userDetails" wicket:message="title:userDetails">
-        <i class="fas fa-eye"></i>
-      </a>
-    </div>
+    <span wicket:id="form"/>
   </wicket:panel>
 </html>
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.html
similarity index 74%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.html
index aef81e5..ad9b884 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.html
@@ -18,14 +18,12 @@
 -->
 <html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
   <wicket:panel>
-    <div wicket:id="propView">
-      <span wicket:id="value">[value]</span>
+    <div class="form-group">
+      <span wicket:id="startAtCheck"/>
+      <span wicket:id="startAtDate"/>
     </div>
-
-    <div style="margin: 20px 0">
-      <a href="#" alt="user details" class="btn btn-success btn-circle btn-lg" wicket:id="userDetails" wicket:message="title:userDetails">
-        <i class="fas fa-eye"></i>
-      </a>
+    <div class="input-group">
+      <span wicket:id="dryRun"/>
     </div>
   </wicket:panel>
 </html>
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.properties
similarity index 92%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.properties
index 450ff50..65e6c54 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt.properties
@@ -14,5 +14,5 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=User details
-userForm=Edit User
+startAtCheck=Specify a starting date
+dryRun=Dry Run
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_fr_CA.properties
similarity index 91%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_fr_CA.properties
index 450ff50..e7e198d 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_fr_CA.properties
@@ -14,5 +14,5 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=User details
-userForm=Edit User
+startAtCheck=Pr\u00e9ciser date de d\u00e9but
+dryRun=Dry Run
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_it.properties
similarity index 90%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_it.properties
index 450ff50..32e399e 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_it.properties
@@ -14,5 +14,5 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=User details
-userForm=Edit User
+startAtCheck=Specifica una data di partenza
+dryRun=Esecuzione di prova
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ja.properties
similarity index 91%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ja.properties
index 450ff50..d6ed0d3 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ja.properties
@@ -14,5 +14,5 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=User details
-userForm=Edit User
+startAtCheck=\u958b\u59cb\u65e5\u3092\u6307\u5b9a
+dryRun=Dry Run
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_pt_BR.properties
similarity index 92%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_pt_BR.properties
index 450ff50..65e6c54 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_pt_BR.properties
@@ -14,5 +14,5 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=User details
-userForm=Edit User
+startAtCheck=Specify a starting date
+dryRun=Dry Run
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ru.properties
similarity index 68%
copy from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
copy to client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ru.properties
index 5a9cc2d..167d378 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/MacroTaskExecWizardBuilder$StartAt_ru.properties
@@ -14,5 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
+#
+# startAtCheck=\u00d0\u00a3\u00d0\u00ba\u00d0\u00b0\u00d0\u00b6\u00d0\u00b8\u00d1\u0082\u00d0\u00b5 \u00d0\u00b4\u00d0\u00b0\u00d1\u0082\u00d1\u0083 \u00d0\u00bd\u00d0\u00b0\u00d1\u0087\u00d0\u00b0\u00d0\u00bb\u00d0\u00b0
+startAtCheck=\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u0434\u0430\u0442\u0443 \u043d\u0430\u0447\u0430\u043b\u0430
+dryRun=Dry Run
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder$Profile.html b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder$Profile.html
index 8d5a2e1..7c4f21b 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder$Profile.html
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/tasks/SchedTaskWizardBuilder$Profile.html
@@ -26,6 +26,7 @@
 
     <span wicket:id="macroTaskSpecifics">
       <div class="form-group"><span wicket:id="realm">[realm]</span></div>
+      <div class="form-group"><span wicket:id="macroActions">[macroActions]</span></div>
       <div class="form-group"><span wicket:id="continueOnError">[continueOnError]</span></div>
       <div class="form-group"><span wicket:id="saveExecs">[saveExecs]</span></div>
     </span>      
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties
index 1cb70c4..0e53e9e 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties
@@ -141,7 +141,7 @@
 dryrun.title=dry-run
 dryrun.alt=dry-run icon
 
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=claim
 claim.alt=claim icon
 
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties
index 24ddf1d..48d13be 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties
@@ -113,7 +113,7 @@
 dryrun.class=fas fa-cogs
 dryrun.title=test \u00e0 blanc
 dryrun.alt=ic\u00f4ne test \u00e0 blanc
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=obtenir
 claim.alt=ic\u00f4ne obtenir
 unclaim.class=fa fa-undo
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties
index d9c56eb..dbbab26 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties
@@ -142,7 +142,7 @@
 dryrun.title=dry-run
 dryrun.alt=dry-run icon
 
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=claim
 claim.alt=claim icon
 
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties
index 4c11ce1..12f5a73 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties
@@ -142,7 +142,7 @@
 dryrun.title=\u4e88\u884c\u6f14\u7fd2
 dryrun.alt=\u4e88\u884c\u6f14\u7fd2
 
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=\u7533\u8acb
 claim.alt=\u7533\u8acb
 
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties
index 8426802..368df37 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties
@@ -141,7 +141,7 @@
 dryrun.title=dry-run
 dryrun.alt=dry-run icon
 
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=claim
 claim.alt=claim icon
 
diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties
index 53bba87..8228495 100644
--- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties
+++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties
@@ -142,7 +142,7 @@
 dryrun.title=dry-run
 dryrun.alt=dry-run icon
 
-claim.class=fa fa-ticket
+claim.class=fas fa-ticket-alt
 claim.title=claim
 claim.alt=claim icon
 
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormProperty.java
similarity index 84%
rename from ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java
rename to common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormProperty.java
index 603e7fc..471f072 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormProperty.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.common.lib.to;
+package org.apache.syncope.common.lib.form;
 
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
@@ -25,9 +25,8 @@
 import java.util.List;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
-import org.apache.syncope.common.lib.types.UserRequestFormPropertyType;
 
-public class UserRequestFormProperty implements Serializable {
+public class FormProperty implements Serializable {
 
     private static final long serialVersionUID = 9139969592634304261L;
 
@@ -35,7 +34,7 @@
 
     private String name;
 
-    private UserRequestFormPropertyType type;
+    private FormPropertyType type;
 
     private boolean readable;
 
@@ -45,9 +44,9 @@
 
     private String datePattern;
 
-    private final List<UserRequestFormPropertyValue> enumValues = new ArrayList<>();
+    private final List<FormPropertyValue> enumValues = new ArrayList<>();
 
-    private final List<UserRequestFormPropertyValue> dropdownValues = new ArrayList<>();
+    private final List<FormPropertyValue> dropdownValues = new ArrayList<>();
 
     private String value;
 
@@ -83,11 +82,11 @@
         this.required = required;
     }
 
-    public UserRequestFormPropertyType getType() {
+    public FormPropertyType getType() {
         return type;
     }
 
-    public void setType(final UserRequestFormPropertyType type) {
+    public void setType(final FormPropertyType type) {
         this.type = type;
     }
 
@@ -109,13 +108,13 @@
 
     @JacksonXmlElementWrapper(localName = "enumValues")
     @JacksonXmlProperty(localName = "enumValue")
-    public List<UserRequestFormPropertyValue> getEnumValues() {
+    public List<FormPropertyValue> getEnumValues() {
         return enumValues;
     }
 
     @JacksonXmlElementWrapper(localName = "dropdownValues")
     @JacksonXmlProperty(localName = "dropdownValue")
-    public List<UserRequestFormPropertyValue> getDropdownValues() {
+    public List<FormPropertyValue> getDropdownValues() {
         return dropdownValues;
     }
 
@@ -154,7 +153,7 @@
         if (getClass() != obj.getClass()) {
             return false;
         }
-        UserRequestFormProperty other = (UserRequestFormProperty) obj;
+        FormProperty other = (FormProperty) obj;
         return new EqualsBuilder().
                 append(id, other.id).
                 append(name, other.name).
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyType.java
similarity index 90%
rename from ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java
rename to common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyType.java
index 5cd31c7..a2ca54f 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyType.java
@@ -16,9 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.common.lib.types;
+package org.apache.syncope.common.lib.form;
 
-public enum UserRequestFormPropertyType {
+public enum FormPropertyType {
 
     String,
     Long,
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormPropertyValue.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyValue.java
similarity index 89%
rename from ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormPropertyValue.java
rename to common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyValue.java
index 3a166bc..2d9135c 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormPropertyValue.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/FormPropertyValue.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.common.lib.to;
+package org.apache.syncope.common.lib.form;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
@@ -24,7 +24,7 @@
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
 
-public class UserRequestFormPropertyValue implements Serializable {
+public class FormPropertyValue implements Serializable {
 
     private static final long serialVersionUID = 9139969597634304261L;
 
@@ -33,7 +33,7 @@
     private final String value;
 
     @JsonCreator
-    public UserRequestFormPropertyValue(
+    public FormPropertyValue(
             @JsonProperty("key") final String key,
             @JsonProperty("value") final String value) {
 
@@ -68,7 +68,7 @@
         if (getClass() != obj.getClass()) {
             return false;
         }
-        UserRequestFormPropertyValue other = (UserRequestFormPropertyValue) obj;
+        FormPropertyValue other = (FormPropertyValue) obj;
         return new EqualsBuilder().
                 append(key, other.key).
                 append(value, other.value).
diff --git a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/SyncopeForm.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/SyncopeForm.java
new file mode 100644
index 0000000..7ea4a1e
--- /dev/null
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/form/SyncopeForm.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.common.lib.form;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.apache.syncope.common.lib.BaseBean;
+
+public class SyncopeForm implements BaseBean {
+
+    private static final long serialVersionUID = 8388697351958834257L;
+
+    private final List<FormProperty> properties = new ArrayList<>();
+
+    @JsonIgnore
+    public Optional<FormProperty> getProperty(final String id) {
+        return properties.stream().filter(property -> id.equals(property.getId())).findFirst();
+    }
+
+    @JacksonXmlElementWrapper(localName = "properties")
+    @JacksonXmlProperty(localName = "property")
+    public List<FormProperty> getProperties() {
+        return properties;
+    }
+
+    @Override
+    public int hashCode() {
+        return new HashCodeBuilder().
+                append(properties).
+                build();
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        SyncopeForm other = (SyncopeForm) obj;
+        return new EqualsBuilder().
+                append(properties, other.properties).
+                build();
+    }
+}
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/FormPropertyDefTO.java
similarity index 61%
copy from ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java
copy to common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/FormPropertyDefTO.java
index 603e7fc..5357b1d 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestFormProperty.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/FormPropertyDefTO.java
@@ -18,55 +18,60 @@
  */
 package org.apache.syncope.common.lib.to;
 
-import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
-import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
-import org.apache.syncope.common.lib.types.UserRequestFormPropertyType;
+import org.apache.syncope.common.lib.form.FormPropertyType;
 
-public class UserRequestFormProperty implements Serializable {
+public class FormPropertyDefTO implements NamedEntityTO {
 
-    private static final long serialVersionUID = 9139969592634304261L;
+    private static final long serialVersionUID = -6862109424380661428L;
 
-    private String id;
+    private String key;
 
     private String name;
 
-    private UserRequestFormPropertyType type;
+    private FormPropertyType type;
 
-    private boolean readable;
+    private boolean readable = true;
 
-    private boolean writable;
+    private boolean writable = true;
 
     private boolean required;
 
     private String datePattern;
 
-    private final List<UserRequestFormPropertyValue> enumValues = new ArrayList<>();
+    private final Map<String, String> enumValues = new LinkedHashMap<>();
 
-    private final List<UserRequestFormPropertyValue> dropdownValues = new ArrayList<>();
-
-    private String value;
-
-    public String getId() {
-        return id;
+    @Override
+    public String getKey() {
+        return key;
     }
 
-    public void setId(final String id) {
-        this.id = id;
+    @Override
+    public void setKey(final String key) {
+        this.key = key;
     }
 
+    @Override
     public String getName() {
         return name;
     }
 
+    @Override
     public void setName(final String name) {
         this.name = name;
     }
 
+    public FormPropertyType getType() {
+        return type;
+    }
+
+    public void setType(final FormPropertyType type) {
+        this.type = type;
+    }
+
     public boolean isReadable() {
         return readable;
     }
@@ -75,22 +80,6 @@
         this.readable = readable;
     }
 
-    public boolean isRequired() {
-        return required;
-    }
-
-    public void setRequired(final boolean required) {
-        this.required = required;
-    }
-
-    public UserRequestFormPropertyType getType() {
-        return type;
-    }
-
-    public void setType(final UserRequestFormPropertyType type) {
-        this.type = type;
-    }
-
     public boolean isWritable() {
         return writable;
     }
@@ -99,6 +88,14 @@
         this.writable = writable;
     }
 
+    public boolean isRequired() {
+        return required;
+    }
+
+    public void setRequired(final boolean required) {
+        this.required = required;
+    }
+
     public String getDatePattern() {
         return datePattern;
     }
@@ -107,30 +104,14 @@
         this.datePattern = datePattern;
     }
 
-    @JacksonXmlElementWrapper(localName = "enumValues")
-    @JacksonXmlProperty(localName = "enumValue")
-    public List<UserRequestFormPropertyValue> getEnumValues() {
+    public Map<String, String> getEnumValues() {
         return enumValues;
     }
 
-    @JacksonXmlElementWrapper(localName = "dropdownValues")
-    @JacksonXmlProperty(localName = "dropdownValue")
-    public List<UserRequestFormPropertyValue> getDropdownValues() {
-        return dropdownValues;
-    }
-
-    public String getValue() {
-        return value;
-    }
-
-    public void setValue(final String value) {
-        this.value = value;
-    }
-
     @Override
     public int hashCode() {
         return new HashCodeBuilder().
-                append(id).
+                append(key).
                 append(name).
                 append(type).
                 append(readable).
@@ -138,8 +119,6 @@
                 append(required).
                 append(datePattern).
                 append(enumValues).
-                append(dropdownValues).
-                append(value).
                 build();
     }
 
@@ -154,9 +133,9 @@
         if (getClass() != obj.getClass()) {
             return false;
         }
-        UserRequestFormProperty other = (UserRequestFormProperty) obj;
+        FormPropertyDefTO other = (FormPropertyDefTO) obj;
         return new EqualsBuilder().
-                append(id, other.id).
+                append(key, other.key).
                 append(name, other.name).
                 append(type, other.type).
                 append(readable, other.readable).
@@ -164,8 +143,6 @@
                 append(required, other.required).
                 append(datePattern, other.datePattern).
                 append(enumValues, other.enumValues).
-                append(dropdownValues, other.dropdownValues).
-                append(value, other.value).
                 build();
     }
 }
diff --git a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/MacroTaskTO.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/MacroTaskTO.java
index 8f92778..1685d72 100644
--- a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/MacroTaskTO.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/to/MacroTaskTO.java
@@ -41,6 +41,10 @@
 
     private boolean saveExecs = true;
 
+    private final List<FormPropertyDefTO> formPropertyDefs = new ArrayList<>();
+
+    private String macroActions;
+
     @JacksonXmlProperty(localName = "_class", isAttribute = true)
     @JsonProperty("_class")
     @Schema(name = "_class", requiredMode = Schema.RequiredMode.REQUIRED,
@@ -80,6 +84,20 @@
         this.saveExecs = saveExecs;
     }
 
+    @JacksonXmlElementWrapper(localName = "formPropertyDefs")
+    @JacksonXmlProperty(localName = "formPropertyDef")
+    public List<FormPropertyDefTO> getFormPropertyDefs() {
+        return formPropertyDefs;
+    }
+
+    public String getMacroActions() {
+        return macroActions;
+    }
+
+    public void setMacroActions(final String macroActions) {
+        this.macroActions = macroActions;
+    }
+
     @Override
     public int hashCode() {
         return new HashCodeBuilder().
@@ -88,6 +106,8 @@
                 append(commands).
                 append(continueOnError).
                 append(saveExecs).
+                append(formPropertyDefs).
+                append(macroActions).
                 build();
     }
 
@@ -109,6 +129,8 @@
                 append(commands, other.commands).
                 append(continueOnError, other.continueOnError).
                 append(saveExecs, other.saveExecs).
+                append(formPropertyDefs, other.formPropertyDefs).
+                append(macroActions, other.macroActions).
                 build();
     }
 }
diff --git a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/EntityViolationType.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/EntityViolationType.java
index 513a0a1..5891f4a 100644
--- a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/EntityViolationType.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/EntityViolationType.java
@@ -25,6 +25,7 @@
     InvalidADynMemberships,
     InvalidConnInstanceLocation,
     InvalidConnPoolConf,
+    InvalidFormPropertyDef,
     InvalidMapping,
     InvalidKey,
     InvalidName,
diff --git a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/IdRepoImplementationType.java b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/IdRepoImplementationType.java
index 64870f1..581d1d2 100644
--- a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/IdRepoImplementationType.java
+++ b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/IdRepoImplementationType.java
@@ -33,7 +33,9 @@
 
     public static final String LOGIC_ACTIONS = "LOGIC_ACTIONS";
 
-    public static final String VALIDATOR = "VALIDATOR";
+    public static final String MACRO_ACTIONS = "MACRO_ACTIONS";
+
+    public static final String ATTR_VALUE_VALIDATOR = "ATTR_VALUE_VALIDATOR";
 
     public static final String COMMAND = "COMMAND";
 
@@ -47,8 +49,10 @@
             Pair.of(TASKJOB_DELEGATE, "org.apache.syncope.core.provisioning.api.job.SchedTaskJobDelegate"),
             Pair.of(REPORT_DELEGATE, "org.apache.syncope.core.provisioning.api.job.report.ReportJobDelegate"),
             Pair.of(LOGIC_ACTIONS, "org.apache.syncope.core.logic.api.LogicActions"),
-            Pair.of(VALIDATOR, "org.apache.syncope.core.persistence.api.attrvalue.PlainAttrValueValidator"),
-            Pair.of(COMMAND, "org.apache.syncope.core.logic.api.Command"),
+            Pair.of(MACRO_ACTIONS, "org.apache.syncope.core.provisioning.api.macro.MacroActions"),
+            Pair.of(ATTR_VALUE_VALIDATOR,
+                    "org.apache.syncope.core.persistence.api.attrvalue.validation.PlainAttrValueValidator"),
+            Pair.of(COMMAND, "org.apache.syncope.core.provisioning.api.macro.Command"),
             Pair.of(RECIPIENTS_PROVIDER, "org.apache.syncope.core.provisioning.api.notification.RecipientsProvider"),
             Pair.of(ITEM_TRANSFORMER, "org.apache.syncope.core.provisioning.api.data.ItemTransformer"));
 
diff --git a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/TaskService.java b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/TaskService.java
index f1b873b..8b2ceb8 100644
--- a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/TaskService.java
+++ b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/TaskService.java
@@ -44,12 +44,15 @@
 import jakarta.ws.rs.core.Response;
 import java.time.OffsetDateTime;
 import java.util.List;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.PagedResult;
 import org.apache.syncope.common.lib.to.SchedTaskTO;
 import org.apache.syncope.common.lib.to.TaskTO;
 import org.apache.syncope.common.lib.types.ExecStatus;
 import org.apache.syncope.common.lib.types.TaskType;
 import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
 import org.apache.syncope.common.rest.api.beans.TaskQuery;
 
 /**
@@ -161,4 +164,28 @@
             @QueryParam("since") OffsetDateTime since,
             @QueryParam("statuses") List<ExecStatus> statuses,
             @QueryParam("resources") List<String> resources);
+
+    /**
+     * Fetches the form to fill and submit for execution, for the given macro task (if defined).
+     *
+     * @param key macro task key
+     * @return the form to fill and submit for execution, for the given macro task (if defined)
+     */
+    @GET
+    @Path("MACRO/{key}/form")
+    @Produces({ MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML })
+    SyncopeForm getMacroTaskForm(@NotNull @PathParam("key") String key);
+
+    /**
+     * Executes the macro task matching the given specs, with the provided form as input.
+     *
+     * @param specs conditions to exec
+     * @param macroTaskForm macro task form
+     * @return execution report for the macro task matching the given specs
+     */
+    @POST
+    @Path("MACRO/{key}/execute")
+    @Consumes({ MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML })
+    @Produces({ MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML })
+    ExecTO execute(@BeanParam ExecSpecs specs, SyncopeForm macroTaskForm);
 }
diff --git a/common/keymaster/client-api/src/main/resources/defaultContent.jpa.xml b/common/keymaster/client-api/src/main/resources/defaultContent.jpa.xml
index a9dd041..fcb7013 100644
--- a/common/keymaster/client-api/src/main/resources/defaultContent.jpa.xml
+++ b/common/keymaster/client-api/src/main/resources/defaultContent.jpa.xml
@@ -26,18 +26,18 @@
 
   <AnyType id="GROUP" kind="GROUP"/>
 
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <SyncopeSchema id="email"/>
   <PlainSchema id="email" type="String" anyTypeClass_id="BaseUser"
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                validator_id="EmailAddressValidator"/>
 
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/idm/logic/src/main/java/org/apache/syncope/core/logic/ReconciliationLogic.java b/core/idm/logic/src/main/java/org/apache/syncope/core/logic/ReconciliationLogic.java
index 48f7f85..dd64fe0 100644
--- a/core/idm/logic/src/main/java/org/apache/syncope/core/logic/ReconciliationLogic.java
+++ b/core/idm/logic/src/main/java/org/apache/syncope/core/logic/ReconciliationLogic.java
@@ -27,6 +27,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Stream;
+import org.apache.commons.lang3.BooleanUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.commons.lang3.tuple.Triple;
@@ -71,6 +72,7 @@
 import org.apache.syncope.core.persistence.api.entity.Realm;
 import org.apache.syncope.core.persistence.api.entity.VirSchema;
 import org.apache.syncope.core.persistence.api.entity.user.LinkedAccount;
+import org.apache.syncope.core.persistence.api.entity.user.User;
 import org.apache.syncope.core.persistence.api.utils.RealmUtils;
 import org.apache.syncope.core.provisioning.api.ConnectorManager;
 import org.apache.syncope.core.provisioning.api.MappingManager;
@@ -98,6 +100,7 @@
 import org.identityconnectors.framework.common.objects.ConnectorObject;
 import org.identityconnectors.framework.common.objects.ObjectClass;
 import org.identityconnectors.framework.common.objects.OperationOptions;
+import org.identityconnectors.framework.common.objects.OperationalAttributes;
 import org.identityconnectors.framework.common.objects.SearchResult;
 import org.identityconnectors.framework.common.objects.SyncDeltaBuilder;
 import org.identityconnectors.framework.common.objects.SyncDeltaType;
@@ -190,6 +193,7 @@
     protected ConnObject getOnSyncope(
             final Item connObjectKeyItem,
             final String connObjectKeyValue,
+            final Boolean suspended,
             final Set<Attribute> attrs) {
 
         ConnObject connObjectTO = ConnObjectUtils.getConnObjectTO(null, attrs);
@@ -197,6 +201,11 @@
                 value(connObjectKeyValue).build());
         connObjectTO.getAttrs().add(new Attr.Builder(Uid.NAME).
                 value(connObjectKeyValue).build());
+        Optional.ofNullable(suspended).ifPresent(s -> {
+            connObjectTO.getAttrs().removeIf(a -> OperationalAttributes.ENABLE_NAME.equals(a.getSchema()));
+            connObjectTO.getAttrs().add(new Attr.Builder(OperationalAttributes.ENABLE_NAME).
+                    value(BooleanUtils.negate(s).toString()).build());
+        });
 
         return connObjectTO;
     }
@@ -209,7 +218,11 @@
 
         Pair<String, Set<Attribute>> prepared = mappingManager.prepareAttrsFromAny(
                 any, null, false, true, resource, provision);
-        return getOnSyncope(connObjectKeyItem, prepared.getLeft(), prepared.getRight());
+        return getOnSyncope(
+                connObjectKeyItem,
+                prepared.getLeft(),
+                any instanceof User ? ((User) any).isSuspended() : null,
+                prepared.getRight());
     }
 
     protected ConnObject getOnSyncope(
@@ -219,7 +232,11 @@
 
         Set<Attribute> attrs = mappingManager.prepareAttrsFromLinkedAccount(
                 account.getOwner(), account, null, false, provision);
-        return getOnSyncope(connObjectKeyItem, account.getConnObjectKeyValue(), attrs);
+        return getOnSyncope(
+                connObjectKeyItem,
+                account.getConnObjectKeyValue(),
+                account.isSuspended(),
+                attrs);
     }
 
     protected Any<?> getAny(final Provision provision, final AnyTypeKind anyTypeKind, final String anyKey) {
@@ -234,11 +251,9 @@
                             : ((AnyObjectDAO) dao).findKey(provision.getAnyType(), anyKey)).
                     orElse(null);
         }
-        Any<?> any = dao.authFind(actualKey);
-        if (any == null) {
-            throw new NotFoundException(provision.getAnyType() + " '" + anyKey + "'");
-        }
-        return any;
+
+        return Optional.ofNullable(dao.authFind(actualKey)).
+                orElseThrow(() -> new NotFoundException(provision.getAnyType() + " '" + anyKey + "'"));
     }
 
     @PreAuthorize("hasRole('" + IdMEntitlement.RESOURCE_GET_CONNOBJECT + "')")
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AbstractExecutableLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AbstractExecutableLogic.java
index 5ca851a..f7987fa 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AbstractExecutableLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AbstractExecutableLogic.java
@@ -25,6 +25,7 @@
 import org.apache.syncope.common.lib.to.JobTO;
 import org.apache.syncope.common.lib.types.JobAction;
 import org.apache.syncope.common.rest.api.batch.BatchResponseItem;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
 import org.apache.syncope.core.persistence.api.dao.JobStatusDAO;
 import org.apache.syncope.core.provisioning.api.job.JobManager;
 import org.apache.syncope.core.provisioning.java.job.SyncopeTaskScheduler;
@@ -41,7 +42,7 @@
         super(jobManager, scheduler, jobStatusDAO);
     }
 
-    public abstract ExecTO execute(String key, OffsetDateTime startAt, boolean dryRun);
+    public abstract ExecTO execute(ExecSpecs specs);
 
     public abstract Page<ExecTO> listExecutions(
             String key,
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java
index 23d2a2c..89c9718 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java
@@ -26,12 +26,14 @@
 import java.util.List;
 import java.util.Set;
 import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.lib.SyncopeClientException;
 import org.apache.syncope.common.lib.to.AuditConfTO;
 import org.apache.syncope.common.lib.to.AuditEventTO;
 import org.apache.syncope.common.lib.types.AnyTypeKind;
 import org.apache.syncope.common.lib.types.ClientExceptionType;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
 import org.apache.syncope.common.lib.types.MatchingRule;
 import org.apache.syncope.common.lib.types.OpEvent;
 import org.apache.syncope.common.lib.types.ResourceOperation;
@@ -44,9 +46,8 @@
 import org.apache.syncope.core.persistence.api.entity.EntityFactory;
 import org.apache.syncope.core.persistence.api.search.SyncopePage;
 import org.apache.syncope.core.provisioning.api.AuditManager;
+import org.apache.syncope.core.provisioning.api.ImplementationLookup;
 import org.apache.syncope.core.provisioning.api.data.AuditDataBinder;
-import org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate;
-import org.apache.syncope.core.provisioning.java.pushpull.PushJobDelegate;
 import org.apache.syncope.core.spring.security.AuthContextUtils;
 import org.springframework.core.io.Resource;
 import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
@@ -84,6 +85,8 @@
 
     protected final EntityFactory entityFactory;
 
+    protected final ImplementationLookup implementationLookup;
+
     protected final AuditDataBinder binder;
 
     protected final AuditManager auditManager;
@@ -93,6 +96,7 @@
             final AuditEventDAO auditEventDAO,
             final ExternalResourceDAO resourceDAO,
             final EntityFactory entityFactory,
+            final ImplementationLookup implementationLookup,
             final AuditDataBinder binder,
             final AuditManager auditManager) {
 
@@ -100,6 +104,7 @@
         this.auditEventDAO = auditEventDAO;
         this.resourceDAO = resourceDAO;
         this.entityFactory = entityFactory;
+        this.implementationLookup = implementationLookup;
         this.binder = binder;
         this.auditManager = auditManager;
     }
@@ -153,19 +158,13 @@
                 null,
                 OpEvent.LOGIN_OP);
 
-        addForOutcomes(
+        implementationLookup.getClassNames(IdRepoImplementationType.TASKJOB_DELEGATE).
+                forEach(clazz -> addForOutcomes(
                 events,
                 OpEvent.CategoryType.TASK,
-                PullJobDelegate.class.getSimpleName(),
+                StringUtils.substringAfterLast(clazz, '.'),
                 null,
-                null);
-
-        addForOutcomes(
-                events,
-                OpEvent.CategoryType.TASK,
-                PushJobDelegate.class.getSimpleName(),
-                null,
-                null);
+                null));
 
         addForOutcomes(
                 events,
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/CommandLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/CommandLogic.java
index 80e8786..0056113 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/CommandLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/CommandLogic.java
@@ -34,12 +34,12 @@
 import org.apache.syncope.common.lib.types.ClientExceptionType;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
 import org.apache.syncope.common.lib.types.IdRepoImplementationType;
-import org.apache.syncope.core.logic.api.Command;
 import org.apache.syncope.core.persistence.api.attrvalue.InvalidEntityException;
 import org.apache.syncope.core.persistence.api.dao.ImplementationDAO;
 import org.apache.syncope.core.persistence.api.dao.NotFoundException;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.search.SyncopePage;
+import org.apache.syncope.core.provisioning.api.macro.Command;
 import org.apache.syncope.core.spring.implementation.ImplementationManager;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
@@ -134,7 +134,7 @@
         }
 
         try {
-            return runnable.run(command.getArgs());
+            return runnable.run(command.getArgs() == null ? ImplementationManager.emptyArgs(impl) : command.getArgs());
         } catch (Exception e) {
             LOG.error("While running {} on {}", command.getKey(), command.getArgs(), e);
 
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
index c67542c..c8013bd 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
@@ -209,6 +209,7 @@
             final AuditEventDAO auditEventDAO,
             final ExternalResourceDAO resourceDAO,
             final EntityFactory entityFactory,
+            final ImplementationLookup implementationLookup,
             final AuditDataBinder binder,
             final AuditManager auditManager) {
 
@@ -217,6 +218,7 @@
                 auditEventDAO,
                 resourceDAO,
                 entityFactory,
+                implementationLookup,
                 binder,
                 auditManager);
     }
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ImplementationLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ImplementationLogic.java
index db2d83a..617bac9 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ImplementationLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ImplementationLogic.java
@@ -219,7 +219,7 @@
                 inUse = !policyDAO.findByPushCorrelationRule(implementation).isEmpty();
                 break;
 
-            case IdRepoImplementationType.VALIDATOR:
+            case IdRepoImplementationType.ATTR_VALUE_VALIDATOR:
                 inUse = !plainSchemaDAO.findByValidator(implementation).isEmpty();
                 break;
 
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ReportLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ReportLogic.java
index edd1837..a1a9966 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ReportLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/ReportLogic.java
@@ -40,6 +40,7 @@
 import org.apache.syncope.common.lib.types.JobType;
 import org.apache.syncope.common.rest.api.RESTHeaders;
 import org.apache.syncope.common.rest.api.batch.BatchResponseItem;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
 import org.apache.syncope.core.persistence.api.dao.JobStatusDAO;
 import org.apache.syncope.core.persistence.api.dao.NotFoundException;
 import org.apache.syncope.core.persistence.api.dao.ReportDAO;
@@ -149,22 +150,28 @@
 
     @PreAuthorize("hasRole('" + IdRepoEntitlement.REPORT_EXECUTE + "')")
     @Override
-    public ExecTO execute(final String key, final OffsetDateTime startAt, final boolean dryRun) {
-        Report report = reportDAO.findById(key).
-                orElseThrow(() -> new NotFoundException("Report " + key));
+    public ExecTO execute(final ExecSpecs specs) {
+        Report report = reportDAO.findById(specs.getKey()).
+                orElseThrow(() -> new NotFoundException("Report " + specs.getKey()));
 
         if (!report.isActive()) {
             SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Scheduling);
-            sce.getElements().add("Report " + key + " is not active");
+            sce.getElements().add("Report " + specs.getKey() + " is not active");
+            throw sce;
+        }
+
+        if (specs.getStartAt() != null && specs.getStartAt().isBefore(OffsetDateTime.now())) {
+            SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Scheduling);
+            sce.getElements().add("Cannot schedule in the past");
             throw sce;
         }
 
         try {
             jobManager.register(
                     report,
-                    Optional.ofNullable(startAt).orElseGet(() -> OffsetDateTime.now()),
+                    Optional.ofNullable(specs.getStartAt()).orElseGet(() -> OffsetDateTime.now()),
                     AuthContextUtils.getUsername(),
-                    dryRun);
+                    specs.getDryRun());
         } catch (Exception e) {
             LOG.error("While executing report {}", report, e);
 
@@ -213,8 +220,8 @@
         }
 
         // streaming output from a compressed byte array stream
-        try (ByteArrayInputStream bais = new ByteArrayInputStream(reportExec.getExecResult()); ZipInputStream zis =
-                new ZipInputStream(bais)) {
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(reportExec.getExecResult());
+                ZipInputStream zis = new ZipInputStream(bais)) {
 
             // a single ZipEntry in the ZipInputStream
             zis.getNextEntry();
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/TaskLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/TaskLogic.java
index e2a5c89..3030630 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/TaskLogic.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/TaskLogic.java
@@ -30,6 +30,7 @@
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.commons.lang3.tuple.Triple;
 import org.apache.syncope.common.lib.SyncopeClientException;
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.JobTO;
 import org.apache.syncope.common.lib.to.MacroTaskTO;
@@ -45,6 +46,7 @@
 import org.apache.syncope.common.lib.types.TaskType;
 import org.apache.syncope.common.rest.api.RESTHeaders;
 import org.apache.syncope.common.rest.api.batch.BatchResponseItem;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
 import org.apache.syncope.core.persistence.api.dao.ExternalResourceDAO;
 import org.apache.syncope.core.persistence.api.dao.JobStatusDAO;
 import org.apache.syncope.core.persistence.api.dao.NotFoundException;
@@ -69,6 +71,7 @@
 import org.apache.syncope.core.provisioning.api.notification.NotificationJobDelegate;
 import org.apache.syncope.core.provisioning.api.propagation.PropagationTaskExecutor;
 import org.apache.syncope.core.provisioning.api.propagation.PropagationTaskInfo;
+import org.apache.syncope.core.provisioning.java.job.MacroJobDelegate;
 import org.apache.syncope.core.provisioning.java.job.SyncopeTaskScheduler;
 import org.apache.syncope.core.provisioning.java.propagation.DefaultPropagationReporter;
 import org.apache.syncope.core.spring.security.AuthContextUtils;
@@ -272,10 +275,24 @@
         return binder.getTaskTO(task, taskUtilsFactory.getInstance(task), details);
     }
 
-    @PreAuthorize("hasRole('" + IdRepoEntitlement.TASK_EXECUTE + "')")
-    @Override
-    public ExecTO execute(final String key, final OffsetDateTime startAt, final boolean dryRun) {
-        Task<?> task = taskDAO.findById(key).orElseThrow(() -> new NotFoundException("Task " + key));
+    @PreAuthorize("hasRole('" + IdRepoEntitlement.TASK_READ + "')")
+    @Transactional(readOnly = true)
+    public SyncopeForm getMacroTaskForm(final String key) {
+        MacroTask task = taskDAO.findById(TaskType.MACRO, key).
+                filter(MacroTask.class::isInstance).map(MacroTask.class::cast).
+                orElseThrow(() -> new NotFoundException("MacroTask " + key));
+
+        securityChecks(IdRepoEntitlement.TASK_READ, task.getRealm().getFullPath());
+
+        return binder.getMacroTaskForm(task);
+    }
+
+    protected ExecTO doExecute(
+            final Task<?> task,
+            final OffsetDateTime startAt,
+            final boolean dryRun,
+            final Map<String, Object> additionalDataMap) {
+
         if (startAt != null && startAt.isBefore(OffsetDateTime.now())) {
             SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Scheduling);
             sce.getElements().add("Cannot schedule in the past");
@@ -316,23 +333,23 @@
             case PULL:
             case PUSH:
             case MACRO:
-                if (taskUtils.getType() == TaskType.MACRO) {
-                    securityChecks(IdRepoEntitlement.TASK_EXECUTE, ((MacroTask) task).getRealm().getFullPath());
-                }
-
                 if (!((SchedTask) task).isActive()) {
                     SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Scheduling);
-                    sce.getElements().add("Task " + key + " is not active");
+                    sce.getElements().add("Task " + task.getKey() + " is not active");
                     throw sce;
                 }
 
+                if (taskUtils.getType() == TaskType.MACRO) {
+                    securityChecks(IdRepoEntitlement.TASK_EXECUTE, ((MacroTask) task).getRealm().getFullPath());
+                }
+
                 try {
                     jobManager.register(
                             (SchedTask) task,
                             Optional.ofNullable(startAt).orElseGet(() -> OffsetDateTime.now()),
                             executor,
                             dryRun,
-                            Map.of());
+                            additionalDataMap);
                 } catch (Exception e) {
                     LOG.error("While executing task {}", task, e);
 
@@ -357,6 +374,32 @@
         return result;
     }
 
+    @PreAuthorize("hasRole('" + IdRepoEntitlement.TASK_EXECUTE + "')")
+    @Override
+    public ExecTO execute(final ExecSpecs specs) {
+        Task<?> task = taskDAO.findById(specs.getKey()).
+                orElseThrow(() -> new NotFoundException("Task " + specs.getKey()));
+
+        return doExecute(
+                task,
+                specs.getStartAt(),
+                specs.getDryRun(),
+                Map.of());
+    }
+
+    @PreAuthorize("hasRole('" + IdRepoEntitlement.TASK_EXECUTE + "')")
+    public ExecTO execute(final ExecSpecs specs, final SyncopeForm macroTaskForm) {
+        MacroTask task = taskDAO.findById(specs.getKey()).
+                filter(MacroTask.class::isInstance).map(MacroTask.class::cast).
+                orElseThrow(() -> new NotFoundException("MacroTask " + specs.getKey()));
+
+        return doExecute(
+                task,
+                specs.getStartAt(),
+                specs.getDryRun(),
+                Map.of(MacroJobDelegate.MACRO_TASK_FORM_JOBDETAIL_KEY, macroTaskForm));
+    }
+
     @PreAuthorize("hasRole('" + IdRepoEntitlement.TASK_DELETE + "')")
     public <T extends TaskTO> T delete(final TaskType type, final String key) {
         Task<?> task = taskDAO.findById(type, key).
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/init/ClassPathScanImplementationLookup.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/init/ClassPathScanImplementationLookup.java
index f594233..5e3d2e1 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/init/ClassPathScanImplementationLookup.java
+++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/init/ClassPathScanImplementationLookup.java
@@ -35,7 +35,6 @@
 import org.apache.syncope.common.lib.types.IdMImplementationType;
 import org.apache.syncope.common.lib.types.IdRepoImplementationType;
 import org.apache.syncope.common.lib.types.ImplementationTypesHolder;
-import org.apache.syncope.core.logic.api.Command;
 import org.apache.syncope.core.logic.api.LogicActions;
 import org.apache.syncope.core.persistence.api.attrvalue.PlainAttrValueValidator;
 import org.apache.syncope.core.provisioning.api.ImplementationLookup;
@@ -44,6 +43,7 @@
 import org.apache.syncope.core.provisioning.api.job.SchedTaskJobDelegate;
 import org.apache.syncope.core.provisioning.api.job.report.ReportConfClass;
 import org.apache.syncope.core.provisioning.api.job.report.ReportJobDelegate;
+import org.apache.syncope.core.provisioning.api.macro.Command;
 import org.apache.syncope.core.provisioning.api.notification.RecipientsProvider;
 import org.apache.syncope.core.provisioning.api.propagation.PropagationActions;
 import org.apache.syncope.core.provisioning.api.pushpull.PullActions;
@@ -199,7 +199,7 @@
                 } else if (PushActions.class.isAssignableFrom(clazz)) {
                     classNames.get(IdMImplementationType.PUSH_ACTIONS).add(bd.getBeanClassName());
                 } else if (PlainAttrValueValidator.class.isAssignableFrom(clazz)) {
-                    classNames.get(IdRepoImplementationType.VALIDATOR).add(bd.getBeanClassName());
+                    classNames.get(IdRepoImplementationType.ATTR_VALUE_VALIDATOR).add(bd.getBeanClassName());
                 } else if (RecipientsProvider.class.isAssignableFrom(clazz)) {
                     classNames.get(IdRepoImplementationType.RECIPIENTS_PROVIDER).add(bd.getBeanClassName());
                 } else if (ProvisionSorter.class.isAssignableFrom(clazz)) {
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/job/MacroRunJobDelegate.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/job/MacroRunJobDelegate.java
deleted file mode 100644
index 38e5587..0000000
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/job/MacroRunJobDelegate.java
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF 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.apache.syncope.core.logic.job;
-
-import jakarta.validation.ConstraintViolation;
-import jakarta.validation.Validator;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import org.apache.syncope.common.lib.command.CommandArgs;
-import org.apache.syncope.core.logic.api.Command;
-import org.apache.syncope.core.persistence.api.dao.ImplementationDAO;
-import org.apache.syncope.core.persistence.api.entity.Implementation;
-import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
-import org.apache.syncope.core.persistence.api.entity.task.TaskExec;
-import org.apache.syncope.core.provisioning.api.job.JobExecutionContext;
-import org.apache.syncope.core.provisioning.api.job.JobExecutionException;
-import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
-import org.apache.syncope.core.provisioning.java.job.AbstractSchedTaskJobDelegate;
-import org.apache.syncope.core.spring.implementation.ImplementationManager;
-import org.springframework.beans.factory.annotation.Autowired;
-
-public class MacroRunJobDelegate extends AbstractSchedTaskJobDelegate<MacroTask> {
-
-    @Autowired
-    protected ImplementationDAO implementationDAO;
-
-    @Autowired
-    protected Validator validator;
-
-    protected final Map<String, Command<?>> perContextCommands = new ConcurrentHashMap<>();
-
-    @SuppressWarnings("unchecked")
-    @Override
-    protected String doExecute(final boolean dryRun, final String executor, final JobExecutionContext context)
-            throws JobExecutionException {
-
-        StringBuilder output = new StringBuilder();
-        for (int i = 0; i < task.getCommands().size(); i++) {
-            Implementation command = task.getCommands().get(i);
-
-            Command<CommandArgs> runnable;
-            try {
-                runnable = (Command<CommandArgs>) ImplementationManager.build(
-                        command,
-                        () -> perContextCommands.get(command.getKey()),
-                        instance -> perContextCommands.put(command.getKey(), instance));
-            } catch (Exception e) {
-                throw new JobExecutionException("Could not build " + command.getKey(), e);
-            }
-
-            String args = POJOHelper.serialize(task.getCommandArgs().get(i));
-
-            output.append("Command[").append(i).append("]: ").
-                    append(command.getKey()).append(" ").append(args).append("\n");
-            if (dryRun) {
-                output.append(command).append(' ').append(args);
-            } else {
-                try {
-                    if (task.getCommandArgs().get(i) != null) {
-                        Set<ConstraintViolation<Object>> violations = validator.validate(task.getCommandArgs().get(i));
-                        if (!violations.isEmpty()) {
-                            LOG.error("Errors while validating {}: {}", task.getCommandArgs().get(i), violations);
-                            throw new IllegalArgumentException(task.getCommandArgs().get(i).getClass().getName());
-                        }
-                    }
-
-                    output.append(runnable.run(task.getCommandArgs().get(i)));
-                } catch (Exception e) {
-                    if (task.isContinueOnError()) {
-                        output.append("Continuing on error: <").append(e.getMessage()).append('>');
-                        LOG.error("While running {} with args {}, continuing on error", command.getKey(), args, e);
-                    } else {
-                        throw new RuntimeException("While running " + command.getKey(), e);
-                    }
-                }
-            }
-            output.append("\n\n");
-        }
-
-        output.append("COMPLETED");
-        return output.toString();
-    }
-
-    @Override
-    protected boolean hasToBeRegistered(final TaskExec<?> execution) {
-        return task.isSaveExecs();
-    }
-}
diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AbstractExecutableService.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AbstractExecutableService.java
index b784e75..3c07df4 100644
--- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AbstractExecutableService.java
+++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AbstractExecutableService.java
@@ -74,8 +74,8 @@
     }
 
     @Override
-    public ExecTO execute(final ExecSpecs query) {
-        return getExecutableLogic().execute(query.getKey(), query.getStartAt(), query.getDryRun());
+    public ExecTO execute(final ExecSpecs execSpecs) {
+        return getExecutableLogic().execute(execSpecs);
     }
 
     @Override
diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/TaskServiceImpl.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/TaskServiceImpl.java
index 6aa590a..f116eb2 100644
--- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/TaskServiceImpl.java
+++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/TaskServiceImpl.java
@@ -23,12 +23,15 @@
 import java.net.URI;
 import java.time.OffsetDateTime;
 import java.util.List;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.PagedResult;
 import org.apache.syncope.common.lib.to.SchedTaskTO;
 import org.apache.syncope.common.lib.to.TaskTO;
 import org.apache.syncope.common.lib.types.ExecStatus;
 import org.apache.syncope.common.lib.types.TaskType;
 import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
 import org.apache.syncope.common.rest.api.beans.TaskQuery;
 import org.apache.syncope.common.rest.api.service.TaskService;
 import org.apache.syncope.core.logic.AbstractExecutableLogic;
@@ -107,4 +110,14 @@
 
         return Response.ok(logic.purgePropagations(since, statuses, resources)).build();
     }
+
+    @Override
+    public SyncopeForm getMacroTaskForm(final String key) {
+        return logic.getMacroTaskForm(key);
+    }
+
+    @Override
+    public ExecTO execute(final ExecSpecs specs, final SyncopeForm macroTaskForm) {
+        return logic.execute(specs, macroTaskForm);
+    }
 }
diff --git a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/FormPropertyDef.java b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/FormPropertyDef.java
new file mode 100644
index 0000000..dbfcadd
--- /dev/null
+++ b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/FormPropertyDef.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.api.entity.task;
+
+import java.util.Map;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.core.persistence.api.entity.ProvidedKeyEntity;
+
+public interface FormPropertyDef extends ProvidedKeyEntity {
+
+    MacroTask getMacroTask();
+
+    void setMacroTask(MacroTask macroTask);
+
+    String getName();
+
+    void setName(String name);
+
+    FormPropertyType getType();
+
+    void setType(FormPropertyType type);
+
+    boolean isReadable();
+
+    void setReadable(boolean readable);
+
+    boolean isWritable();
+
+    void setWritable(boolean writable);
+
+    boolean isRequired();
+
+    void setRequired(boolean required);
+
+    String getDatePattern();
+
+    void setDatePattern(String datePattern);
+
+    Map<String, String> getEnumValues();
+
+    void setEnumValues(Map<String, String> enumValues);
+}
diff --git a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTask.java b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTask.java
index 29c68e0..e9a6846 100644
--- a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTask.java
+++ b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTask.java
@@ -19,7 +19,6 @@
 package org.apache.syncope.core.persistence.api.entity.task;
 
 import java.util.List;
-import org.apache.syncope.common.lib.command.CommandArgs;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.entity.Realm;
 
@@ -29,11 +28,17 @@
 
     void setRealm(Realm realm);
 
-    void add(Implementation command, CommandArgs args);
+    void add(MacroTaskCommand macroTaskCommand);
 
-    List<? extends Implementation> getCommands();
+    List<? extends MacroTaskCommand> getCommands();
 
-    List<CommandArgs> getCommandArgs();
+    void add(FormPropertyDef formPropertyDef);
+
+    List<? extends FormPropertyDef> getFormPropertyDefs();
+
+    Implementation getMacroActions();
+
+    void setMacroAction(Implementation macroActions);
 
     boolean isContinueOnError();
 
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTaskCommand.java
similarity index 64%
copy from core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java
copy to core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTaskCommand.java
index 88047c8..ed0cb4f 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java
+++ b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/task/MacroTaskCommand.java
@@ -16,12 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.logic.api;
+package org.apache.syncope.core.persistence.api.entity.task;
 
 import org.apache.syncope.common.lib.command.CommandArgs;
+import org.apache.syncope.core.persistence.api.entity.Entity;
+import org.apache.syncope.core.persistence.api.entity.Implementation;
 
-@FunctionalInterface
-public interface Command<A extends CommandArgs> {
+public interface MacroTaskCommand extends Entity {
 
-    String run(A args);
+    MacroTask getMacroTask();
+
+    void setMacroTask(MacroTask macroTask);
+
+    Implementation getCommand();
+
+    void setCommand(Implementation command);
+
+    CommandArgs getArgs();
+
+    void setArgs(CommandArgs args);
 }
diff --git a/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefCheck.java b/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefCheck.java
new file mode 100644
index 0000000..42d615e
--- /dev/null
+++ b/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefCheck.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.common.validation;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = FormPropertyDefValidator.class)
+@Documented
+public @interface FormPropertyDefCheck {
+
+    String message() default "{org.apache.syncope.core.persistence.validation.formpropertydef}";
+
+    Class<?>[] groups() default {};
+
+    Class<? extends Payload>[] payload() default {};
+}
diff --git a/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefValidator.java b/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefValidator.java
new file mode 100644
index 0000000..34c7fd0
--- /dev/null
+++ b/core/persistence-common/src/main/java/org/apache/syncope/core/persistence/common/validation/FormPropertyDefValidator.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.common.validation;
+
+import jakarta.validation.ConstraintValidatorContext;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.common.lib.types.EntityViolationType;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+
+public class FormPropertyDefValidator extends AbstractValidator<FormPropertyDefCheck, FormPropertyDef> {
+
+    @Override
+    public boolean isValid(final FormPropertyDef formPropertyDef, final ConstraintValidatorContext context) {
+        context.disableDefaultConstraintViolation();
+
+        if (formPropertyDef.getDatePattern() != null
+                && formPropertyDef.getType() != FormPropertyType.Date) {
+
+            context.buildConstraintViolationWithTemplate(getTemplate(
+                    EntityViolationType.InvalidFormPropertyDef, "Date pattern found but type not set to Date")).
+                    addPropertyNode("datePattern").addConstraintViolation();
+            return false;
+        }
+
+        if (!formPropertyDef.getEnumValues().isEmpty()
+                && formPropertyDef.getType() != FormPropertyType.Enum) {
+
+            context.buildConstraintViolationWithTemplate(getTemplate(
+                    EntityViolationType.InvalidFormPropertyDef, "Enum values found but type not set to Enum")).
+                    addPropertyNode("enumValues").addConstraintViolation();
+            return false;
+        }
+
+        if (formPropertyDef.getEnumValues().isEmpty()
+                && formPropertyDef.getType() == FormPropertyType.Enum) {
+
+            context.buildConstraintViolationWithTemplate(getTemplate(
+                    EntityViolationType.InvalidFormPropertyDef, "No enum values provided")).
+                    addPropertyNode("enumValues").addConstraintViolation();
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/core/persistence-jpa-json/src/main/resources/domains/jpa-json/MasterContent.xml b/core/persistence-jpa-json/src/main/resources/domains/jpa-json/MasterContent.xml
index adff541..256e121 100644
--- a/core/persistence-jpa-json/src/main/resources/domains/jpa-json/MasterContent.xml
+++ b/core/persistence-jpa-json/src/main/resources/domains/jpa-json/MasterContent.xml
@@ -29,18 +29,18 @@
   <AnyType_AnyTypeClass anyType_id="GROUP" anyTypeClass_id="BaseGroup"/>
         
   <!-- Actual plain schemas -->
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <SyncopeSchema id="email"/>
   <PlainSchema id="email" type="String" anyTypeClass_id="BaseUser"
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                validator_id="EmailAddressValidator"/>
   
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-jpa-json/src/test/resources/domains/MasterContent.xml b/core/persistence-jpa-json/src/test/resources/domains/MasterContent.xml
index a2702c8..8817203 100644
--- a/core/persistence-jpa-json/src/test/resources/domains/MasterContent.xml
+++ b/core/persistence-jpa-json/src/test/resources/domains/MasterContent.xml
@@ -124,7 +124,7 @@
   <PlainSchema id="fullname" type="String" anyTypeClass_id="minimal user"
                mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"/>
   <SyncopeSchema id="userId"/>
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <PlainSchema id="userId" type="String" anyTypeClass_id="minimal user"
                mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"
@@ -175,7 +175,7 @@
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                mimeType="image/jpeg"/>
   
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
   <SyncopeSchema id="csvuserid"/>
@@ -680,8 +680,8 @@
   <VirSchema id="virtualdata" READONLY="0" anyTypeClass_id="minimal user"
              resource_id="resource-db-virattr" anyType_id="USER" extAttrName="USERNAME"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPATaskDAO.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPATaskDAO.java
index a6aaf13..bae0170 100644
--- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPATaskDAO.java
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPATaskDAO.java
@@ -504,8 +504,6 @@
                 jpaNotificationTask.list2json();
             case JPAPushTask jpaPushTask ->
                 jpaPushTask.map2json();
-            case JPAMacroTask macroTask ->
-                macroTask.list2json();
             default -> {
             }
         }
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAEntityFactory.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAEntityFactory.java
index a3aac9e..6b376e9 100644
--- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAEntityFactory.java
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAEntityFactory.java
@@ -88,7 +88,9 @@
 import org.apache.syncope.core.persistence.api.entity.policy.PushPolicy;
 import org.apache.syncope.core.persistence.api.entity.policy.TicketExpirationPolicy;
 import org.apache.syncope.core.persistence.api.entity.task.AnyTemplatePullTask;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.NotificationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PullTask;
@@ -143,7 +145,9 @@
 import org.apache.syncope.core.persistence.jpa.entity.policy.JPAPushPolicy;
 import org.apache.syncope.core.persistence.jpa.entity.policy.JPATicketExpirationPolicy;
 import org.apache.syncope.core.persistence.jpa.entity.task.JPAAnyTemplatePullTask;
+import org.apache.syncope.core.persistence.jpa.entity.task.JPAFormPropertyDef;
 import org.apache.syncope.core.persistence.jpa.entity.task.JPAMacroTask;
+import org.apache.syncope.core.persistence.jpa.entity.task.JPAMacroTaskCommand;
 import org.apache.syncope.core.persistence.jpa.entity.task.JPANotificationTask;
 import org.apache.syncope.core.persistence.jpa.entity.task.JPAPropagationTask;
 import org.apache.syncope.core.persistence.jpa.entity.task.JPAPullTask;
@@ -280,6 +284,10 @@
             result = (E) new JPASchedTask();
         } else if (reference.equals(AnyTemplatePullTask.class)) {
             result = (E) new JPAAnyTemplatePullTask();
+        } else if (reference.equals(MacroTaskCommand.class)) {
+            result = (E) new JPAMacroTaskCommand();
+        } else if (reference.equals(FormPropertyDef.class)) {
+            result = (E) new JPAFormPropertyDef();
         } else if (reference.equals(SecurityQuestion.class)) {
             result = (E) new JPASecurityQuestion();
         } else if (reference.equals(AuditConf.class)) {
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAPlainSchema.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAPlainSchema.java
index 3bb3c6f..af76cc7 100644
--- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAPlainSchema.java
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/JPAPlainSchema.java
@@ -155,7 +155,7 @@
     @Override
     public void setValidator(final Implementation validator) {
         checkType(validator, JPAImplementation.class);
-        checkImplementationType(validator, IdRepoImplementationType.VALIDATOR);
+        checkImplementationType(validator, IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         this.validator = (JPAImplementation) validator;
     }
 
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAFormPropertyDef.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAFormPropertyDef.java
new file mode 100644
index 0000000..0d86db3
--- /dev/null
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAFormPropertyDef.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.jpa.entity.task;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Lob;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.common.validation.FormPropertyDefCheck;
+import org.apache.syncope.core.persistence.jpa.entity.AbstractProvidedKeyEntity;
+import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
+
+@Entity
+@Table(name = JPAFormPropertyDef.TABLE)
+@FormPropertyDefCheck
+public class JPAFormPropertyDef extends AbstractProvidedKeyEntity implements FormPropertyDef {
+
+    private static final long serialVersionUID = -5839990371546587373L;
+
+    public static final String TABLE = "FormPropertyDef";
+
+    @ManyToOne(optional = false)
+    private JPAMacroTask macroTask;
+
+    @NotNull
+    private String name;
+
+    @NotNull
+    @Enumerated(EnumType.STRING)
+    private FormPropertyType type;
+
+    @NotNull
+    private Boolean readable = Boolean.TRUE;
+
+    @NotNull
+    private Boolean writable = Boolean.TRUE;
+
+    @NotNull
+    private Boolean required = Boolean.FALSE;
+
+    private String datePattern;
+
+    @Lob
+    private String enumValues;
+
+    @Override
+    public JPAMacroTask getMacroTask() {
+        return macroTask;
+    }
+
+    @Override
+    public void setMacroTask(final MacroTask macroTask) {
+        checkType(macroTask, JPAMacroTask.class);
+        this.macroTask = (JPAMacroTask) macroTask;
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public void setName(final String name) {
+        this.name = name;
+    }
+
+    @Override
+    public FormPropertyType getType() {
+        return type;
+    }
+
+    @Override
+    public void setType(final FormPropertyType type) {
+        this.type = type;
+    }
+
+    @Override
+    public boolean isReadable() {
+        return readable == null ? true : readable;
+    }
+
+    @Override
+    public void setReadable(final boolean readable) {
+        this.readable = readable;
+    }
+
+    @Override
+    public boolean isWritable() {
+        return writable == null ? true : writable;
+    }
+
+    @Override
+    public void setWritable(final boolean writable) {
+        this.writable = writable;
+    }
+
+    @Override
+    public boolean isRequired() {
+        return required == null ? false : required;
+    }
+
+    @Override
+    public void setRequired(final boolean required) {
+        this.required = required;
+    }
+
+    @Override
+    public String getDatePattern() {
+        return datePattern;
+    }
+
+    @Override
+    public void setDatePattern(final String datePattern) {
+        this.datePattern = datePattern;
+    }
+
+    @Override
+    public Map<String, String> getEnumValues() {
+        return Optional.ofNullable(enumValues).
+                map(v -> POJOHelper.deserialize(v, new TypeReference<Map<String, String>>() {
+        })).orElse(Map.of());
+    }
+
+    @Override
+    public void setEnumValues(final Map<String, String> enumValues) {
+        this.enumValues = Optional.ofNullable(enumValues).map(POJOHelper::serialize).orElse(null);
+    }
+}
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTask.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTask.java
index ed87ec3..8660cd8 100644
--- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTask.java
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTask.java
@@ -18,37 +18,26 @@
  */
 package org.apache.syncope.core.persistence.jpa.entity.task;
 
-import com.fasterxml.jackson.core.type.TypeReference;
 import jakarta.persistence.CascadeType;
 import jakarta.persistence.Entity;
 import jakarta.persistence.FetchType;
-import jakarta.persistence.JoinColumn;
-import jakarta.persistence.JoinTable;
-import jakarta.persistence.Lob;
-import jakarta.persistence.ManyToMany;
 import jakarta.persistence.ManyToOne;
 import jakarta.persistence.OneToMany;
-import jakarta.persistence.PostLoad;
-import jakarta.persistence.PostPersist;
-import jakarta.persistence.PostUpdate;
-import jakarta.persistence.PrePersist;
-import jakarta.persistence.PreUpdate;
 import jakarta.persistence.Table;
-import jakarta.persistence.Transient;
-import jakarta.persistence.UniqueConstraint;
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotNull;
 import java.util.ArrayList;
 import java.util.List;
-import org.apache.syncope.common.lib.command.CommandArgs;
 import org.apache.syncope.common.lib.types.IdRepoImplementationType;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.entity.Realm;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.SchedTask;
 import org.apache.syncope.core.persistence.api.entity.task.TaskExec;
 import org.apache.syncope.core.persistence.jpa.entity.JPAImplementation;
 import org.apache.syncope.core.persistence.jpa.entity.JPARealm;
-import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
 
 @Entity
 @Table(name = JPAMacroTask.TABLE)
@@ -58,9 +47,6 @@
 
     public static final String TABLE = "MacroTask";
 
-    protected static final TypeReference<List<CommandArgs>> TYPEREF = new TypeReference<List<CommandArgs>>() {
-    };
-
     @ManyToOne(fetch = FetchType.EAGER, optional = false)
     private JPARealm realm;
 
@@ -70,21 +56,15 @@
     @NotNull
     private Boolean saveExecs = true;
 
-    @ManyToMany(fetch = FetchType.EAGER)
-    @JoinTable(name = TABLE + "Commands",
-            joinColumns =
-            @JoinColumn(name = "task_id"),
-            inverseJoinColumns =
-            @JoinColumn(name = "implementation_id"),
-            uniqueConstraints =
-            @UniqueConstraint(columnNames = { "task_id", "implementation_id" }))
-    private List<JPAImplementation> commands = new ArrayList<>();
+    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true, mappedBy = "macroTask")
+    private List<JPAMacroTaskCommand> macroTaskCommands = new ArrayList<>();
 
-    @Lob
-    private String commandArgs;
+    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true, mappedBy = "macroTask")
+    @Valid
+    private List<JPAFormPropertyDef> formPropertyDefs = new ArrayList<>();
 
-    @Transient
-    private final List<CommandArgs> commandArgsList = new ArrayList<>();
+    @ManyToOne(fetch = FetchType.EAGER)
+    private JPAImplementation macroActions;
 
     @OneToMany(targetEntity = JPAMacroTaskExec.class,
             cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "task")
@@ -102,25 +82,6 @@
     }
 
     @Override
-    public void add(final Implementation command, final CommandArgs args) {
-        checkType(command, JPAImplementation.class);
-        checkImplementationType(command, IdRepoImplementationType.COMMAND);
-        commands.add((JPAImplementation) command);
-
-        getCommandArgs().add(args);
-    }
-
-    @Override
-    public List<JPAImplementation> getCommands() {
-        return commands;
-    }
-
-    @Override
-    public List<CommandArgs> getCommandArgs() {
-        return commandArgsList;
-    }
-
-    @Override
     public boolean isContinueOnError() {
         return continueOnError == null ? false : continueOnError;
     }
@@ -150,29 +111,37 @@
         return executions;
     }
 
-    protected void json2list(final boolean clearFirst) {
-        if (clearFirst) {
-            getCommandArgs().clear();
-        }
-        if (commandArgs != null) {
-            getCommandArgs().addAll(POJOHelper.deserialize(commandArgs, TYPEREF));
-        }
+    @Override
+    public void add(final MacroTaskCommand macroTaskCommand) {
+        checkType(macroTaskCommand, JPAMacroTaskCommand.class);
+        this.macroTaskCommands.add((JPAMacroTaskCommand) macroTaskCommand);
     }
 
-    @PostLoad
-    public void postLoad() {
-        json2list(false);
+    @Override
+    public List<? extends MacroTaskCommand> getCommands() {
+        return macroTaskCommands;
     }
 
-    @PostPersist
-    @PostUpdate
-    public void postSave() {
-        json2list(true);
+    @Override
+    public void add(final FormPropertyDef formPropertyDef) {
+        checkType(formPropertyDef, JPAFormPropertyDef.class);
+        this.formPropertyDefs.add((JPAFormPropertyDef) formPropertyDef);
     }
 
-    @PrePersist
-    @PreUpdate
-    public void list2json() {
-        commandArgs = POJOHelper.serialize(getCommandArgs(), TYPEREF);
+    @Override
+    public List<? extends FormPropertyDef> getFormPropertyDefs() {
+        return formPropertyDefs;
+    }
+
+    @Override
+    public Implementation getMacroActions() {
+        return macroActions;
+    }
+
+    @Override
+    public void setMacroAction(final Implementation macroActions) {
+        checkType(macroActions, JPAImplementation.class);
+        checkImplementationType(macroActions, IdRepoImplementationType.MACRO_ACTIONS);
+        this.macroActions = (JPAImplementation) macroActions;
     }
 }
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTaskCommand.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTaskCommand.java
new file mode 100644
index 0000000..df3bea0
--- /dev/null
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/task/JPAMacroTaskCommand.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.jpa.entity.task;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Lob;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import java.util.Optional;
+import org.apache.syncope.common.lib.command.CommandArgs;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
+import org.apache.syncope.core.persistence.api.entity.Implementation;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
+import org.apache.syncope.core.persistence.jpa.entity.AbstractGeneratedKeyEntity;
+import org.apache.syncope.core.persistence.jpa.entity.JPAImplementation;
+import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
+
+@Entity
+@Table(name = JPAMacroTaskCommand.TABLE)
+public class JPAMacroTaskCommand extends AbstractGeneratedKeyEntity implements MacroTaskCommand {
+
+    private static final long serialVersionUID = -8388668645348044783L;
+
+    public static final String TABLE = "MacroTaskCommand";
+
+    @ManyToOne(optional = false)
+    private JPAMacroTask macroTask;
+
+    @OneToOne(optional = false)
+    private JPAImplementation command;
+
+    @Lob
+    private String args;
+
+    @Override
+    public JPAMacroTask getMacroTask() {
+        return macroTask;
+    }
+
+    @Override
+    public void setMacroTask(final MacroTask macroTask) {
+        checkType(macroTask, JPAMacroTask.class);
+        this.macroTask = (JPAMacroTask) macroTask;
+    }
+
+    @Override
+    public Implementation getCommand() {
+        return command;
+    }
+
+    @Override
+    public void setCommand(final Implementation command) {
+        checkType(command, JPAImplementation.class);
+        checkImplementationType(command, IdRepoImplementationType.COMMAND);
+        this.command = (JPAImplementation) command;
+    }
+
+    @Override
+    public CommandArgs getArgs() {
+        return Optional.ofNullable(args).
+                map(a -> POJOHelper.deserialize(a, CommandArgs.class)).
+                orElse(null);
+    }
+
+    @Override
+    public void setArgs(final CommandArgs args) {
+        this.args = Optional.ofNullable(args).map(POJOHelper::serialize).orElse(null);
+    }
+}
diff --git a/core/persistence-jpa/src/main/resources/domains/MasterContent.xml b/core/persistence-jpa/src/main/resources/domains/MasterContent.xml
index 434d824..eef0510 100644
--- a/core/persistence-jpa/src/main/resources/domains/MasterContent.xml
+++ b/core/persistence-jpa/src/main/resources/domains/MasterContent.xml
@@ -28,18 +28,18 @@
   <AnyTypeClass id="BaseGroup"/>
   <AnyType_AnyTypeClass anyType_id="GROUP" anyTypeClass_id="BaseGroup"/>
         
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <SyncopeSchema id="email"/>
   <PlainSchema id="email" type="String" anyTypeClass_id="BaseUser"
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                validator_id="EmailAddressValidator"/>
 
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/ImplementationTest.java b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/ImplementationTest.java
index 5401ef4..db95519 100644
--- a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/ImplementationTest.java
+++ b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/ImplementationTest.java
@@ -71,7 +71,7 @@
         implementations = implementationDAO.findByType(IdRepoImplementationType.PASSWORD_RULE);
         assertEquals(3, implementations.size());
 
-        implementations = implementationDAO.findByType(IdRepoImplementationType.VALIDATOR);
+        implementations = implementationDAO.findByType(IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         assertEquals(2, implementations.size());
 
         implementations = implementationDAO.findByType(IdMImplementationType.PULL_CORRELATION_RULE);
@@ -86,7 +86,7 @@
         Implementation impl = entityFactory.newEntity(Implementation.class);
         impl.setKey("new");
         impl.setEngine(ImplementationEngine.GROOVY);
-        impl.setType(IdRepoImplementationType.VALIDATOR);
+        impl.setType(IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         impl.setBody("");
 
         Implementation actual = implementationDAO.save(impl);
diff --git a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/TaskTest.java b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/TaskTest.java
index 62ff598..ab6717e 100644
--- a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/TaskTest.java
+++ b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/TaskTest.java
@@ -22,20 +22,32 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.common.lib.form.FormPropertyType;
 import org.apache.syncope.common.lib.types.AnyTypeKind;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
+import org.apache.syncope.common.lib.types.ImplementationEngine;
 import org.apache.syncope.common.lib.types.ResourceOperation;
 import org.apache.syncope.common.lib.types.TaskType;
+import org.apache.syncope.core.persistence.api.attrvalue.InvalidEntityException;
 import org.apache.syncope.core.persistence.api.dao.ExternalResourceDAO;
+import org.apache.syncope.core.persistence.api.dao.ImplementationDAO;
+import org.apache.syncope.core.persistence.api.dao.RealmDAO;
 import org.apache.syncope.core.persistence.api.dao.TaskDAO;
 import org.apache.syncope.core.persistence.api.entity.ExternalResource;
+import org.apache.syncope.core.persistence.api.entity.Implementation;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationData;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.SchedTask;
@@ -62,6 +74,12 @@
     @Autowired
     private ExternalResourceDAO resourceDAO;
 
+    @Autowired
+    private RealmDAO realmDAO;
+
+    @Autowired
+    private ImplementationDAO implementationDAO;
+
     @Test
     public void findByName() {
         Optional<SchedTask> task = taskDAO.findByName(TaskType.SCHEDULED, "SampleJob Task");
@@ -149,6 +167,62 @@
     }
 
     @Test
+    public void saveMacroTask() throws Exception {
+        MacroTask task = entityFactory.newEntity(MacroTask.class);
+        task.setRealm(realmDAO.getRoot());
+        task.setJobDelegate(implementationDAO.findById("MacroJobDelegate").orElseThrow());
+        task.setName("Macro test");
+        task.setContinueOnError(true);
+
+        Implementation command = entityFactory.newEntity(Implementation.class);
+        command.setKey("command");
+        command.setType(IdRepoImplementationType.COMMAND);
+        command.setEngine(ImplementationEngine.JAVA);
+        command.setBody("clazz");
+        command = implementationDAO.save(command);
+        assertNotNull(command);
+
+        MacroTaskCommand macroTaskCommand = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand.setCommand(command);
+        macroTaskCommand.setMacroTask(task);
+        task.add(macroTaskCommand);
+
+        FormPropertyDef formPropertyDef = entityFactory.newEntity(FormPropertyDef.class);
+        formPropertyDef.setKey("one");
+        formPropertyDef.setName("One");
+        formPropertyDef.setType(FormPropertyType.Enum);
+        formPropertyDef.setMacroTask(task);
+        task.add(formPropertyDef);
+
+        Implementation macroActions = entityFactory.newEntity(Implementation.class);
+        macroActions.setKey("macroActions");
+        macroActions.setType(IdRepoImplementationType.MACRO_ACTIONS);
+        macroActions.setEngine(ImplementationEngine.JAVA);
+        macroActions.setBody("clazz");
+        macroActions = implementationDAO.save(macroActions);
+        assertNotNull(macroActions);
+        task.setMacroAction(macroActions);
+
+        try {
+            taskDAO.save(task);
+            fail();
+        } catch (InvalidEntityException e) {
+            assertNotNull(e);
+        }
+        formPropertyDef.setEnumValues(Map.of("key", "value"));
+
+        task = taskDAO.save(task);
+        assertNotNull(task);
+        assertEquals(1, task.getCommands().size());
+        assertEquals(command, task.getCommands().get(0).getCommand());
+        assertEquals(1, task.getFormPropertyDefs().size());
+        assertEquals(formPropertyDef, task.getFormPropertyDefs().get(0));
+
+        MacroTask actual = (MacroTask) taskDAO.findById(TaskType.MACRO, task.getKey()).orElseThrow();
+        assertEquals(task, actual);
+    }
+
+    @Test
     public void delete() {
         PropagationTask task = (PropagationTask) taskDAO.findById(
                 TaskType.PROPAGATION, "1e697572-b896-484c-ae7f-0c8f63fcbc6c").orElseThrow();
diff --git a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/outer/TaskTest.java b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/outer/TaskTest.java
index 468cc74..42f6fe7 100644
--- a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/outer/TaskTest.java
+++ b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/outer/TaskTest.java
@@ -33,6 +33,7 @@
 import org.apache.syncope.common.lib.types.AnyTypeKind;
 import org.apache.syncope.common.lib.types.ExecStatus;
 import org.apache.syncope.common.lib.types.IdMImplementationType;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
 import org.apache.syncope.common.lib.types.ImplementationEngine;
 import org.apache.syncope.common.lib.types.MatchingRule;
 import org.apache.syncope.common.lib.types.PullMode;
@@ -47,6 +48,8 @@
 import org.apache.syncope.core.persistence.api.dao.TaskExecDAO;
 import org.apache.syncope.core.persistence.api.entity.ExternalResource;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationData;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PullTask;
@@ -294,6 +297,56 @@
     }
 
     @Test
+    public void saveMacroTaskSameCommandMultipleOccurrencies() {
+        MacroTask task = entityFactory.newEntity(MacroTask.class);
+        task.setRealm(realmDAO.getRoot());
+        task.setJobDelegate(implementationDAO.findById("MacroJobDelegate").orElseThrow());
+        task.setName("saveMacroTaskSameCommandMultipleOccurrencies");
+        task.setContinueOnError(true);
+
+        Implementation command1 = entityFactory.newEntity(Implementation.class);
+        command1.setKey("command1");
+        command1.setType(IdRepoImplementationType.COMMAND);
+        command1.setEngine(ImplementationEngine.JAVA);
+        command1.setBody("clazz1");
+        command1 = implementationDAO.save(command1);
+        assertNotNull(command1);
+
+        Implementation command2 = entityFactory.newEntity(Implementation.class);
+        command2.setKey("command2");
+        command2.setType(IdRepoImplementationType.COMMAND);
+        command2.setEngine(ImplementationEngine.JAVA);
+        command2.setBody("clazz2");
+        command2 = implementationDAO.save(command2);
+        assertNotNull(command2);
+
+        MacroTaskCommand macroTaskCommand1 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand1.setCommand(command1);
+        macroTaskCommand1.setMacroTask(task);
+        task.add(macroTaskCommand1);
+
+        MacroTaskCommand macroTaskCommand2 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand2.setCommand(command2);
+        macroTaskCommand2.setMacroTask(task);
+        task.add(macroTaskCommand2);
+
+        MacroTaskCommand macroTaskCommand3 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand3.setCommand(command1);
+        macroTaskCommand3.setMacroTask(task);
+        task.add(macroTaskCommand3);
+
+        task = (MacroTask) taskDAO.save(task);
+        assertNotNull(task);
+        assertEquals(3, task.getCommands().size());
+        assertEquals(command1, task.getCommands().get(0).getCommand());
+        assertEquals(command2, task.getCommands().get(1).getCommand());
+        assertEquals(command1, task.getCommands().get(2).getCommand());
+
+        MacroTask actual = (MacroTask) taskDAO.findById(TaskType.MACRO, task.getKey()).orElseThrow();
+        assertEquals(task, actual);
+    }
+
+    @Test
     public void issueSYNCOPE144() {
         ExternalResource resource = resourceDAO.findById("ws-target-resource-1").orElseThrow();
         assertNotNull(resource);
diff --git a/core/persistence-jpa/src/test/resources/domains/MasterContent.xml b/core/persistence-jpa/src/test/resources/domains/MasterContent.xml
index f84831c..207a9d6 100644
--- a/core/persistence-jpa/src/test/resources/domains/MasterContent.xml
+++ b/core/persistence-jpa/src/test/resources/domains/MasterContent.xml
@@ -301,7 +301,7 @@
   <PlainSchema id="fullname" type="String" anyTypeClass_id="minimal user"
                mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"/>
   <SyncopeSchema id="userId"/>
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <PlainSchema id="userId" type="String" anyTypeClass_id="minimal user"
                mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"
@@ -352,7 +352,7 @@
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                mimeType="image/jpeg"/>
   
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
   <SyncopeSchema id="csvuserid"/>
@@ -766,8 +766,8 @@
   <VirSchema id="virtualdata" READONLY="0" anyTypeClass_id="minimal user"
              resource_id="resource-db-virattr" anyType_id="USER" extAttrName="USERNAME"/>
   
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-jpa/src/test/resources/domains/TwoContent.xml b/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
index 805d0ac..1a9a25d 100644
--- a/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
+++ b/core/persistence-jpa/src/test/resources/domains/TwoContent.xml
@@ -26,18 +26,18 @@
 
   <AnyType id="GROUP" kind="GROUP"/>
         
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <SyncopeSchema id="email"/>
   <PlainSchema id="email" type="String" anyTypeClass_id="BaseUser"
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                validator_id="EmailAddressValidator"/>
 
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/ContentLoaderHandler.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/ContentLoaderHandler.java
index 653605e..83a4f68 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/ContentLoaderHandler.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/ContentLoaderHandler.java
@@ -45,6 +45,7 @@
 import org.apache.syncope.core.persistence.neo4j.entity.policy.Neo4jPushPolicy;
 import org.apache.syncope.core.persistence.neo4j.entity.policy.Neo4jTicketExpirationPolicy;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTaskCommandRelationship;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jProvisioningTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPullTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPushTask;
@@ -281,8 +282,9 @@
                 rightId,
                 rel.getType(),
                 Optional.ofNullable(rel.getRelationshipPropertiesEntity()).
-                        filter(rpe -> Neo4jImplementationRelationship.class.getSimpleName().
-                        equals(rpe.getPrimaryLabel())).map(rpe -> indexValue).orElse(null)));
+                        filter(e -> Neo4jImplementationRelationship.class.getSimpleName().equals(e.getPrimaryLabel())
+                        || Neo4jMacroTaskCommandRelationship.class.getSimpleName().equals(e.getPrimaryLabel())).
+                        map(e -> indexValue).orElse(null)));
     }
 
     @Override
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/XMLContentExporter.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/XMLContentExporter.java
index 107f421..015e7c6 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/XMLContentExporter.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/content/XMLContentExporter.java
@@ -43,6 +43,7 @@
 import org.apache.syncope.core.persistence.neo4j.entity.Neo4jSchema;
 import org.apache.syncope.core.persistence.neo4j.entity.policy.Neo4jPolicy;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTaskCommandRelationship;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jProvisioningTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jSchedTask;
 import org.neo4j.driver.Driver;
@@ -169,16 +170,16 @@
                 rattrs.addAttribute("", "", "right", "CDATA", rightId);
 
                 Optional.ofNullable(relDesc.get().getRelationshipPropertiesEntity()).
-                        filter(rpe -> Neo4jImplementationRelationship.class.getSimpleName().
-                        equals(rpe.getPrimaryLabel())).ifPresent(rpe -> {
-
-                    String index = String.valueOf(session.run(
-                            "MATCH (n {id: $left})-[r:" + relDesc.get().getType() + "]-" + "(m {id: $right}) "
-                            + "RETURN r.index",
-                            Map.of("left", node.get("id").asString(), "right", rightId)).
-                            single().get("r.index").asInt());
-                    rattrs.addAttribute("", "", "index", "CDATA", index);
-                });
+                        filter(e -> Neo4jImplementationRelationship.class.getSimpleName().equals(e.getPrimaryLabel())
+                        || Neo4jMacroTaskCommandRelationship.class.getSimpleName().equals(e.getPrimaryLabel())).
+                        ifPresent(rpe -> {
+                            String index = String.valueOf(session.run(
+                                    "MATCH (n {id: $left})-[r:" + relDesc.get().getType() + "]-" + "(m {id: $right}) "
+                                    + "RETURN r.index",
+                                    Map.of("left", node.get("id").asString(), "right", rightId)).
+                                    single().get("r.index").asInt());
+                            rattrs.addAttribute("", "", "index", "CDATA", index);
+                        });
 
                 String elementName = entity.getPrimaryLabel()
                         + "_"
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jTaskDAO.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jTaskDAO.java
index 8441aa9..cc8a3db 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jTaskDAO.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jTaskDAO.java
@@ -54,7 +54,9 @@
 import org.apache.syncope.core.persistence.neo4j.entity.Neo4jRealm;
 import org.apache.syncope.core.persistence.neo4j.entity.task.AbstractTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jAnyTemplatePullTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jFormPropertyDef;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTaskCommand;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jNotificationTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPropagationTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPropagationTaskExec;
@@ -452,8 +454,6 @@
                 notificationTask.list2json();
             case Neo4jPushTask pushTask ->
                 pushTask.map2json();
-            case Neo4jMacroTask macroTask ->
-                macroTask.list2json();
             default -> {
             }
         }
@@ -497,18 +497,17 @@
                         Neo4jPushTask.PUSH_TASK_PUSH_ACTIONS_REL)));
             }
 
-            case Neo4jMacroTask macroTask -> {
-                macroTask.postSave();
-
-                neo4jTemplate.findById(macroTask.getKey(), Neo4jMacroTask.class).
-                        ifPresent(t -> t.getCommands().stream().filter(cmd -> !macroTask.getCommands().contains(cmd)).
-                        forEach(impl -> deleteRelationship(
-                        Neo4jMacroTask.NODE,
-                        Neo4jImplementation.NODE,
-                        macroTask.getKey(),
-                        impl.getKey(),
-                        Neo4jMacroTask.MACRO_TASK_COMMANDS_REL)));
-            }
+            case Neo4jMacroTask macroTask ->
+                neo4jTemplate.findById(macroTask.getKey(), Neo4jMacroTask.class).ifPresent(t -> {
+                    if (t.getMacroActions() != null && macroTask.getMacroActions() == null) {
+                        deleteRelationship(
+                                Neo4jMacroTask.NODE,
+                                Neo4jImplementation.NODE,
+                                macroTask.getKey(),
+                                t.getMacroActions().getKey(),
+                                Neo4jMacroTask.MACRO_TASK_MACRO_ACTIONS_REL);
+                    }
+                });
 
             default -> {
             }
@@ -529,10 +528,22 @@
 
     @Override
     public void delete(final Task<?> task) {
-        if (task instanceof PullTask pullTask) {
-            remediationDAO.findByPullTask(pullTask).forEach(remediation -> remediation.setPullTask(null));
-            pullTask.getTemplates().
-                    forEach(template -> neo4jTemplate.deleteById(template.getKey(), Neo4jAnyTemplatePullTask.class));
+        switch (task) {
+            case PullTask pullTask -> {
+                remediationDAO.findByPullTask(pullTask).forEach(remediation -> remediation.setPullTask(null));
+                pullTask.getTemplates().
+                        forEach(e -> neo4jTemplate.deleteById(e.getKey(), Neo4jAnyTemplatePullTask.class));
+            }
+
+            case MacroTask macroTask -> {
+                macroTask.getCommands().
+                        forEach(e -> neo4jTemplate.deleteById(e.getKey(), Neo4jMacroTaskCommand.class));
+                macroTask.getFormPropertyDefs().
+                        forEach(e -> neo4jTemplate.deleteById(e.getKey(), Neo4jFormPropertyDef.class));
+            }
+
+            default -> {
+            }
         }
 
         TaskUtils taskUtils = taskUtilsFactory.getInstance(task);
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jEntityFactory.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jEntityFactory.java
index 8db549c..5c8932c 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jEntityFactory.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jEntityFactory.java
@@ -88,7 +88,9 @@
 import org.apache.syncope.core.persistence.api.entity.policy.PushPolicy;
 import org.apache.syncope.core.persistence.api.entity.policy.TicketExpirationPolicy;
 import org.apache.syncope.core.persistence.api.entity.task.AnyTemplatePullTask;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.NotificationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PullTask;
@@ -143,7 +145,9 @@
 import org.apache.syncope.core.persistence.neo4j.entity.policy.Neo4jPushPolicy;
 import org.apache.syncope.core.persistence.neo4j.entity.policy.Neo4jTicketExpirationPolicy;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jAnyTemplatePullTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jFormPropertyDef;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTask;
+import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jMacroTaskCommand;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jNotificationTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPropagationTask;
 import org.apache.syncope.core.persistence.neo4j.entity.task.Neo4jPullTask;
@@ -280,6 +284,10 @@
             result = (E) new Neo4jSchedTask();
         } else if (reference.equals(AnyTemplatePullTask.class)) {
             result = (E) new Neo4jAnyTemplatePullTask();
+        } else if (reference.equals(MacroTaskCommand.class)) {
+            result = (E) new Neo4jMacroTaskCommand();
+        } else if (reference.equals(FormPropertyDef.class)) {
+            result = (E) new Neo4jFormPropertyDef();
         } else if (reference.equals(SecurityQuestion.class)) {
             result = (E) new Neo4jSecurityQuestion();
         } else if (reference.equals(AuditConf.class)) {
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jExternalResource.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jExternalResource.java
index 12d564c..433a83b 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jExternalResource.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jExternalResource.java
@@ -157,7 +157,8 @@
     private SortedSet<Neo4jImplementationRelationship> propagationActions = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedPropagationActions = new SortedSetList(propagationActions);
+    private List<Neo4jImplementation> sortedPropagationActions = new SortedSetList<>(
+            propagationActions, Neo4jImplementationRelationship.builder());
 
     @Override
     public boolean isEnforceMandatoryCondition() {
@@ -383,7 +384,7 @@
 
     @PostLoad
     public void postLoad() {
-        sortedPropagationActions = new SortedSetList(propagationActions);
+        sortedPropagationActions = new SortedSetList<>(propagationActions, Neo4jImplementationRelationship.builder());
         json2list(false);
     }
 
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jImplementationRelationship.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jImplementationRelationship.java
index 2ac5c75..7e148d3 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jImplementationRelationship.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jImplementationRelationship.java
@@ -18,6 +18,7 @@
  */
 package org.apache.syncope.core.persistence.neo4j.entity;
 
+import java.util.function.BiFunction;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
 import org.springframework.data.neo4j.core.schema.RelationshipId;
@@ -25,7 +26,13 @@
 import org.springframework.data.neo4j.core.schema.TargetNode;
 
 @RelationshipProperties
-public class Neo4jImplementationRelationship implements Comparable<Neo4jImplementationRelationship> {
+public class Neo4jImplementationRelationship
+        extends Neo4jSortedRelationsihip<Neo4jImplementation>
+        implements Comparable<Neo4jImplementationRelationship> {
+
+    public static BiFunction<Integer, Neo4jImplementation, Neo4jImplementationRelationship> builder() {
+        return (Integer i, Neo4jImplementation e) -> new Neo4jImplementationRelationship(i, e);
+    }
 
     @RelationshipId
     private Long id;
@@ -40,11 +47,13 @@
         this.implementation = implementation;
     }
 
+    @Override
     public int getIndex() {
         return index;
     }
 
-    public Neo4jImplementation getImplementation() {
+    @Override
+    public Neo4jImplementation getEntity() {
         return implementation;
     }
 
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jPlainSchema.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jPlainSchema.java
index 76af029..d9dcf06 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jPlainSchema.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jPlainSchema.java
@@ -196,7 +196,7 @@
     @Override
     public void setValidator(final Implementation validator) {
         checkType(validator, Neo4jImplementation.class);
-        checkImplementationType(validator, IdRepoImplementationType.VALIDATOR);
+        checkImplementationType(validator, IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         this.validator = (Neo4jImplementation) validator;
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jRealm.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jRealm.java
index 9241456..5fa19b1 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jRealm.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jRealm.java
@@ -106,7 +106,8 @@
     private SortedSet<Neo4jImplementationRelationship> actions = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedActions = new SortedSetList(actions);
+    private List<Neo4jImplementation> sortedActions = new SortedSetList<>(
+            actions, Neo4jImplementationRelationship.builder());
 
     @Relationship(type = Neo4jAnyTemplateRealm.REALM_ANY_TEMPLATE_REL, direction = Relationship.Direction.INCOMING)
     private List<Neo4jAnyTemplateRealm> templates = new ArrayList<>();
@@ -258,6 +259,6 @@
 
     @PostLoad
     public void postLoad() {
-        sortedActions = new SortedSetList(actions);
+        sortedActions = new SortedSetList<>(actions, Neo4jImplementationRelationship.builder());
     }
 }
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jSortedRelationsihip.java
similarity index 75%
copy from ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java
copy to core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jSortedRelationsihip.java
index 5cd31c7..d4d5156 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/types/UserRequestFormPropertyType.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/Neo4jSortedRelationsihip.java
@@ -16,16 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.common.lib.types;
+package org.apache.syncope.core.persistence.neo4j.entity;
 
-public enum UserRequestFormPropertyType {
+import org.apache.syncope.core.persistence.api.entity.Entity;
 
-    String,
-    Long,
-    Enum,
-    Date,
-    Boolean,
-    Dropdown,
-    Password
+public abstract class Neo4jSortedRelationsihip<E extends Entity> {
 
+    public abstract int getIndex();
+
+    public abstract E getEntity();
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/SortedSetList.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/SortedSetList.java
index 6fef725..5a679c8 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/SortedSetList.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/SortedSetList.java
@@ -25,16 +25,18 @@
 import java.util.ListIterator;
 import java.util.SortedSet;
 import java.util.Spliterator;
+import java.util.function.BiFunction;
 import java.util.function.Consumer;
 import java.util.stream.Stream;
+import org.apache.syncope.core.persistence.api.entity.Entity;
 
-public class SortedSetList implements List<Neo4jImplementation> {
+public class SortedSetList<E extends Entity, R extends Neo4jSortedRelationsihip<E>> implements List<E> {
 
-    private static class SortedSetListIterator implements Iterator<Neo4jImplementation> {
+    private class SortedSetListIterator implements Iterator<E> {
 
-        private final Iterator<Neo4jImplementationRelationship> ditor;
+        private final Iterator<R> ditor;
 
-        SortedSetListIterator(final Iterator<Neo4jImplementationRelationship> ditor) {
+        SortedSetListIterator(final Iterator<R> ditor) {
             this.ditor = ditor;
         }
 
@@ -44,8 +46,8 @@
         }
 
         @Override
-        public Neo4jImplementation next() {
-            return ditor.next().getImplementation();
+        public E next() {
+            return ditor.next().getEntity();
         }
 
         @Override
@@ -54,21 +56,21 @@
         }
     }
 
-    private static class SortedSetListSplitIterator implements Spliterator<Neo4jImplementation> {
+    private class SortedSetListSplitIterator implements Spliterator<E> {
 
-        private final Spliterator<Neo4jImplementationRelationship> ditor;
+        private final Spliterator<R> ditor;
 
-        SortedSetListSplitIterator(final Spliterator<Neo4jImplementationRelationship> ditor) {
+        SortedSetListSplitIterator(final Spliterator<R> ditor) {
             this.ditor = ditor;
         }
 
         @Override
-        public boolean tryAdvance(final Consumer<? super Neo4jImplementation> action) {
-            return ditor.tryAdvance(t -> action.accept(t.getImplementation()));
+        public boolean tryAdvance(final Consumer<? super E> action) {
+            return ditor.tryAdvance(t -> action.accept(t.getEntity()));
         }
 
         @Override
-        public Spliterator<Neo4jImplementation> trySplit() {
+        public Spliterator<E> trySplit() {
             return new SortedSetListSplitIterator(ditor.trySplit());
         }
 
@@ -83,15 +85,21 @@
         }
 
         @Override
-        public Comparator<? super Neo4jImplementation> getComparator() {
+        public Comparator<? super E> getComparator() {
             throw new UnsupportedOperationException("NOT FOR NOW");
         }
     }
 
-    private final SortedSet<Neo4jImplementationRelationship> delegate;
+    private final SortedSet<R> delegate;
 
-    public SortedSetList(final SortedSet<Neo4jImplementationRelationship> delegate) {
+    private final BiFunction<Integer, E, R> builder;
+
+    public SortedSetList(
+            final SortedSet<R> delegate,
+            final BiFunction<Integer, E, R> builder) {
+
         this.delegate = delegate;
+        this.builder = builder;
     }
 
     @Override
@@ -106,47 +114,44 @@
 
     @Override
     public boolean contains(final Object o) {
-        return delegate.stream().anyMatch(e -> e.getImplementation().equals(o));
+        return delegate.stream().anyMatch(e -> e.getEntity().equals(o));
     }
 
     @Override
-    public Iterator<Neo4jImplementation> iterator() {
+    public Iterator<E> iterator() {
         return new SortedSetListIterator(delegate.iterator());
     }
 
     @Override
-    public Spliterator<Neo4jImplementation> spliterator() {
+    public Spliterator<E> spliterator() {
         return new SortedSetListSplitIterator(delegate.spliterator());
     }
 
     @Override
     public Object[] toArray() {
         return delegate.stream().
-                sorted(Comparator.comparing(Neo4jImplementationRelationship::getIndex)).
-                map(Neo4jImplementationRelationship::getImplementation).toList().toArray();
+                sorted(Comparator.comparing(Neo4jSortedRelationsihip::getIndex)).
+                map(Neo4jSortedRelationsihip::getEntity).toList().toArray();
     }
 
     @Override
     public <T> T[] toArray(final T[] a) {
         return delegate.stream().
-                sorted(Comparator.comparing(Neo4jImplementationRelationship::getIndex)).
-                map(Neo4jImplementationRelationship::getImplementation).toList().toArray(a);
+                sorted(Comparator.comparing(Neo4jSortedRelationsihip::getIndex)).
+                map(Neo4jSortedRelationsihip::getEntity).toList().toArray(a);
     }
 
     @Override
-    public boolean add(final Neo4jImplementation e) {
-        return delegate.add(new Neo4jImplementationRelationship(
-                delegate.stream().map(Neo4jImplementationRelationship::getIndex).
+    public boolean add(final E e) {
+        return delegate.add(builder.apply(
+                delegate.stream().map(Neo4jSortedRelationsihip::getIndex).
                         max(Comparator.naturalOrder()).orElse(0) + 1,
                 e));
     }
 
     @Override
     public boolean remove(final Object o) {
-        if (o instanceof Neo4jImplementation impl) {
-            return delegate.removeIf(impl::equals);
-        }
-        return false;
+        return delegate.removeIf(o::equals);
     }
 
     @Override
@@ -155,12 +160,12 @@
     }
 
     @Override
-    public boolean addAll(final Collection<? extends Neo4jImplementation> c) {
+    public boolean addAll(final Collection<? extends E> c) {
         throw new UnsupportedOperationException();
     }
 
     @Override
-    public boolean addAll(final int index, final Collection<? extends Neo4jImplementation> c) {
+    public boolean addAll(final int index, final Collection<? extends E> c) {
         throw new UnsupportedOperationException();
     }
 
@@ -180,19 +185,18 @@
     }
 
     @Override
-    public Neo4jImplementation get(final int index) {
+    public E get(final int index) {
         if (index < 0 || index >= delegate.size()) {
             throw new IndexOutOfBoundsException();
         }
 
         int idx = 0;
-        for (Iterator<Neo4jImplementationRelationship> itor = delegate.iterator();
-                idx <= index && itor.hasNext();
-                idx++) {
+        for (Iterator<R> itor = delegate.iterator();
+                idx <= index && itor.hasNext(); idx++) {
 
-            Neo4jImplementationRelationship next = itor.next();
+            Neo4jSortedRelationsihip<E> next = itor.next();
             if (idx == index) {
-                return next.getImplementation();
+                return next.getEntity();
             }
         }
 
@@ -200,8 +204,8 @@
     }
 
     @Override
-    public Stream<Neo4jImplementation> stream() {
-        return delegate.stream().map(Neo4jImplementationRelationship::getImplementation);
+    public Stream<E> stream() {
+        return delegate.stream().map(Neo4jSortedRelationsihip::getEntity);
     }
 
     @Override
@@ -210,17 +214,17 @@
     }
 
     @Override
-    public Neo4jImplementation set(final int index, final Neo4jImplementation element) {
+    public E set(final int index, final E element) {
         throw new UnsupportedOperationException();
     }
 
     @Override
-    public void add(final int index, final Neo4jImplementation element) {
+    public void add(final int index, final E element) {
         throw new UnsupportedOperationException();
     }
 
     @Override
-    public Neo4jImplementation remove(final int index) {
+    public E remove(final int index) {
         throw new UnsupportedOperationException();
     }
 
@@ -235,17 +239,17 @@
     }
 
     @Override
-    public ListIterator<Neo4jImplementation> listIterator() {
+    public ListIterator<E> listIterator() {
         throw new UnsupportedOperationException();
     }
 
     @Override
-    public ListIterator<Neo4jImplementation> listIterator(final int index) {
+    public ListIterator<E> listIterator(final int index) {
         throw new UnsupportedOperationException();
     }
 
     @Override
-    public List<Neo4jImplementation> subList(final int fromIndex, final int toIndex) {
+    public List<E> subList(final int fromIndex, final int toIndex) {
         throw new UnsupportedOperationException();
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jAccountPolicy.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jAccountPolicy.java
index 8d680ea..4a8ccd6 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jAccountPolicy.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jAccountPolicy.java
@@ -51,7 +51,8 @@
     private SortedSet<Neo4jImplementationRelationship> rules = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedRules = new SortedSetList(rules);
+    private List<Neo4jImplementation> sortedRules = new SortedSetList<>(
+            rules, Neo4jImplementationRelationship.builder());
 
     @Override
     public boolean isPropagateSuspension() {
@@ -87,6 +88,6 @@
 
     @PostLoad
     public void postLoad() {
-        sortedRules = new SortedSetList(rules);
+        sortedRules = new SortedSetList<>(rules, Neo4jImplementationRelationship.builder());
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jPasswordPolicy.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jPasswordPolicy.java
index 7f6c023..f4d1614 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jPasswordPolicy.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/policy/Neo4jPasswordPolicy.java
@@ -51,7 +51,8 @@
     private SortedSet<Neo4jImplementationRelationship> rules = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedRules = new SortedSetList(rules);
+    private List<Neo4jImplementation> sortedRules = new SortedSetList<>(
+            rules, Neo4jImplementationRelationship.builder());
 
     @Override
     public boolean isAllowNullPassword() {
@@ -87,6 +88,6 @@
 
     @PostLoad
     public void postLoad() {
-        sortedRules = new SortedSetList(rules);
+        sortedRules = new SortedSetList<>(rules, Neo4jImplementationRelationship.builder());
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jFormPropertyDef.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jFormPropertyDef.java
new file mode 100644
index 0000000..af494c8
--- /dev/null
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jFormPropertyDef.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.neo4j.entity.task;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import jakarta.validation.constraints.NotNull;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.common.validation.FormPropertyDefCheck;
+import org.apache.syncope.core.persistence.neo4j.entity.AbstractProvidedKeyNode;
+import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
+import org.springframework.data.neo4j.core.schema.Node;
+import org.springframework.data.neo4j.core.schema.Relationship;
+
+@Node(Neo4jFormPropertyDef.NODE)
+@FormPropertyDefCheck
+public class Neo4jFormPropertyDef extends AbstractProvidedKeyNode implements FormPropertyDef {
+
+    private static final long serialVersionUID = -5839990371546587373L;
+
+    public static final String NODE = "FormPropertyDef";
+
+    @NotNull
+    @Relationship(type = Neo4jMacroTask.MACRO_TASK_FORM_PROPERTY_DEF_REL, direction = Relationship.Direction.OUTGOING)
+    private Neo4jMacroTask macroTask;
+
+    @NotNull
+    private String name;
+
+    @NotNull
+    private FormPropertyType type;
+
+    @NotNull
+    private Boolean readable = Boolean.TRUE;
+
+    @NotNull
+    private Boolean writable = Boolean.TRUE;
+
+    @NotNull
+    private Boolean required = Boolean.FALSE;
+
+    private String datePattern;
+
+    private String enumValues;
+
+    @Override
+    public Neo4jMacroTask getMacroTask() {
+        return macroTask;
+    }
+
+    @Override
+    public void setMacroTask(final MacroTask macroTask) {
+        checkType(macroTask, Neo4jMacroTask.class);
+        this.macroTask = (Neo4jMacroTask) macroTask;
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public void setName(final String name) {
+        this.name = name;
+    }
+
+    @Override
+    public FormPropertyType getType() {
+        return type;
+    }
+
+    @Override
+    public void setType(final FormPropertyType type) {
+        this.type = type;
+    }
+
+    @Override
+    public boolean isReadable() {
+        return readable == null ? true : readable;
+    }
+
+    @Override
+    public void setReadable(final boolean readable) {
+        this.readable = readable;
+    }
+
+    @Override
+    public boolean isWritable() {
+        return writable == null ? true : writable;
+    }
+
+    @Override
+    public void setWritable(final boolean writable) {
+        this.writable = writable;
+    }
+
+    @Override
+    public boolean isRequired() {
+        return required == null ? false : required;
+    }
+
+    @Override
+    public void setRequired(final boolean required) {
+        this.required = required;
+    }
+
+    @Override
+    public String getDatePattern() {
+        return datePattern;
+    }
+
+    @Override
+    public void setDatePattern(final String datePattern) {
+        this.datePattern = datePattern;
+    }
+
+    @Override
+    public Map<String, String> getEnumValues() {
+        return Optional.ofNullable(enumValues).
+                map(v -> POJOHelper.deserialize(v, new TypeReference<Map<String, String>>() {
+        })).orElse(Map.of());
+    }
+
+    @Override
+    public void setEnumValues(final Map<String, String> enumValues) {
+        this.enumValues = Optional.ofNullable(enumValues).map(POJOHelper::serialize).orElse(null);
+    }
+}
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTask.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTask.java
index a9f1e06..ff83035 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTask.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTask.java
@@ -18,24 +18,23 @@
  */
 package org.apache.syncope.core.persistence.neo4j.entity.task;
 
-import com.fasterxml.jackson.core.type.TypeReference;
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotNull;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.SortedSet;
 import java.util.TreeSet;
-import org.apache.syncope.common.lib.command.CommandArgs;
 import org.apache.syncope.common.lib.types.IdRepoImplementationType;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.entity.Realm;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.SchedTask;
 import org.apache.syncope.core.persistence.api.entity.task.TaskExec;
 import org.apache.syncope.core.persistence.neo4j.entity.Neo4jImplementation;
-import org.apache.syncope.core.persistence.neo4j.entity.Neo4jImplementationRelationship;
 import org.apache.syncope.core.persistence.neo4j.entity.Neo4jRealm;
 import org.apache.syncope.core.persistence.neo4j.entity.SortedSetList;
-import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
 import org.springframework.data.annotation.Transient;
 import org.springframework.data.neo4j.core.schema.Node;
 import org.springframework.data.neo4j.core.schema.PostLoad;
@@ -48,13 +47,14 @@
 
     public static final String NODE = "MacroTask";
 
+    public static final String MACRO_TASK_FORM_PROPERTY_DEF_REL = "MACRO_TASK_FORM_PROPERTY_DEF_REL";
+
+    public static final String MACRO_TASK_MACRO_ACTIONS_REL = "MACRO_TASK_MACRO_ACTIONS";
+
     public static final String MACRO_TASK_EXEC_REL = "MACRO_TASK_EXEC";
 
     public static final String MACRO_TASK_COMMANDS_REL = "MACRO_TASK_COMMANDS";
 
-    protected static final TypeReference<List<CommandArgs>> TYPEREF = new TypeReference<List<CommandArgs>>() {
-    };
-
     @NotNull
     @Relationship(direction = Relationship.Direction.OUTGOING)
     private Neo4jRealm realm;
@@ -66,15 +66,18 @@
     private Boolean saveExecs = true;
 
     @Relationship(type = MACRO_TASK_COMMANDS_REL, direction = Relationship.Direction.OUTGOING)
-    private SortedSet<Neo4jImplementationRelationship> commands = new TreeSet<>();
+    private SortedSet<Neo4jMacroTaskCommandRelationship> commands = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedCommands = new SortedSetList(commands);
+    private List<Neo4jMacroTaskCommand> sortedCommands = new SortedSetList<>(
+            commands, Neo4jMacroTaskCommandRelationship.builder());
 
-    private String commandArgs;
+    @Relationship(type = MACRO_TASK_FORM_PROPERTY_DEF_REL, direction = Relationship.Direction.INCOMING)
+    @Valid
+    private List<Neo4jFormPropertyDef> formPropertyDefs = new ArrayList<>();
 
-    @Transient
-    private final List<CommandArgs> commandArgsList = new ArrayList<>();
+    @Relationship(type = MACRO_TASK_MACRO_ACTIONS_REL, direction = Relationship.Direction.OUTGOING)
+    private Neo4jImplementation macroActions;
 
     @Relationship(type = MACRO_TASK_EXEC_REL, direction = Relationship.Direction.INCOMING)
     private List<Neo4jMacroTaskExec> executions = new ArrayList<>();
@@ -91,24 +94,6 @@
     }
 
     @Override
-    public void add(final Implementation command, final CommandArgs args) {
-        checkType(command, Neo4jImplementation.class);
-        checkImplementationType(command, IdRepoImplementationType.COMMAND);
-        sortedCommands.add((Neo4jImplementation) command);
-        getCommandArgs().add(args);
-    }
-
-    @Override
-    public List<Neo4jImplementation> getCommands() {
-        return sortedCommands;
-    }
-
-    @Override
-    public List<CommandArgs> getCommandArgs() {
-        return commandArgsList;
-    }
-
-    @Override
     public boolean isContinueOnError() {
         return continueOnError == null ? false : continueOnError;
     }
@@ -143,26 +128,42 @@
         return executions;
     }
 
-    protected void json2list(final boolean clearFirst) {
-        if (clearFirst) {
-            getCommandArgs().clear();
-        }
-        if (commandArgs != null) {
-            getCommandArgs().addAll(POJOHelper.deserialize(commandArgs, TYPEREF));
-        }
+    @Override
+    public void add(final MacroTaskCommand macroTaskCommand) {
+        checkType(macroTaskCommand, Neo4jMacroTaskCommand.class);
+        sortedCommands.add((Neo4jMacroTaskCommand) macroTaskCommand);
+    }
+
+    @Override
+    public List<? extends MacroTaskCommand> getCommands() {
+        return sortedCommands;
+    }
+
+    @Override
+    public void add(final FormPropertyDef formPropertyDef) {
+        checkType(formPropertyDef, Neo4jFormPropertyDef.class);
+        this.formPropertyDefs.add((Neo4jFormPropertyDef) formPropertyDef);
+    }
+
+    @Override
+    public List<? extends FormPropertyDef> getFormPropertyDefs() {
+        return formPropertyDefs;
+    }
+
+    @Override
+    public Implementation getMacroActions() {
+        return macroActions;
+    }
+
+    @Override
+    public void setMacroAction(final Implementation macroActions) {
+        checkType(macroActions, Neo4jImplementation.class);
+        checkImplementationType(macroActions, IdRepoImplementationType.MACRO_ACTIONS);
+        this.macroActions = (Neo4jImplementation) macroActions;
     }
 
     @PostLoad
     public void postLoad() {
-        sortedCommands = new SortedSetList(commands);
-        json2list(false);
-    }
-
-    public void postSave() {
-        json2list(true);
-    }
-
-    public void list2json() {
-        commandArgs = POJOHelper.serialize(getCommandArgs(), TYPEREF);
+        sortedCommands = new SortedSetList<>(commands, Neo4jMacroTaskCommandRelationship.builder());
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommand.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommand.java
new file mode 100644
index 0000000..11af50b
--- /dev/null
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommand.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.neo4j.entity.task;
+
+import jakarta.validation.constraints.NotNull;
+import java.util.Optional;
+import org.apache.syncope.common.lib.command.CommandArgs;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
+import org.apache.syncope.core.persistence.api.entity.Implementation;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
+import org.apache.syncope.core.persistence.neo4j.entity.AbstractProvidedKeyNode;
+import org.apache.syncope.core.persistence.neo4j.entity.Neo4jImplementation;
+import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
+import org.springframework.data.neo4j.core.schema.Node;
+import org.springframework.data.neo4j.core.schema.Relationship;
+
+@Node(Neo4jMacroTaskCommand.NODE)
+public class Neo4jMacroTaskCommand extends AbstractProvidedKeyNode implements MacroTaskCommand {
+
+    private static final long serialVersionUID = -8388668645348044783L;
+
+    public static final String NODE = "MacroTaskCommand";
+
+    @NotNull
+    @Relationship(type = Neo4jMacroTask.MACRO_TASK_COMMANDS_REL, direction = Relationship.Direction.OUTGOING)
+    private Neo4jMacroTask macroTask;
+
+    @NotNull
+    @Relationship(direction = Relationship.Direction.OUTGOING)
+    private Neo4jImplementation command;
+
+    private String args;
+
+    @Override
+    public Neo4jMacroTask getMacroTask() {
+        return macroTask;
+    }
+
+    @Override
+    public void setMacroTask(final MacroTask macroTask) {
+        checkType(macroTask, Neo4jMacroTask.class);
+        this.macroTask = (Neo4jMacroTask) macroTask;
+    }
+
+    @Override
+    public Implementation getCommand() {
+        return command;
+    }
+
+    @Override
+    public void setCommand(final Implementation command) {
+        checkType(command, Neo4jImplementation.class);
+        checkImplementationType(command, IdRepoImplementationType.COMMAND);
+        this.command = (Neo4jImplementation) command;
+    }
+
+    @Override
+    public CommandArgs getArgs() {
+        return Optional.ofNullable(args).
+                map(a -> POJOHelper.deserialize(a, CommandArgs.class)).
+                orElse(null);
+    }
+
+    @Override
+    public void setArgs(final CommandArgs args) {
+        this.args = Optional.ofNullable(args).map(POJOHelper::serialize).orElse(null);
+    }
+}
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommandRelationship.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommandRelationship.java
new file mode 100644
index 0000000..4aae340
--- /dev/null
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jMacroTaskCommandRelationship.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.persistence.neo4j.entity.task;
+
+import java.util.function.BiFunction;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.apache.syncope.core.persistence.neo4j.entity.Neo4jSortedRelationsihip;
+import org.springframework.data.neo4j.core.schema.RelationshipId;
+import org.springframework.data.neo4j.core.schema.RelationshipProperties;
+import org.springframework.data.neo4j.core.schema.TargetNode;
+
+@RelationshipProperties
+public class Neo4jMacroTaskCommandRelationship
+        extends Neo4jSortedRelationsihip<Neo4jMacroTaskCommand>
+        implements Comparable<Neo4jMacroTaskCommandRelationship> {
+
+    public static BiFunction<Integer, Neo4jMacroTaskCommand, Neo4jMacroTaskCommandRelationship> builder() {
+        return (Integer i, Neo4jMacroTaskCommand e) -> new Neo4jMacroTaskCommandRelationship(i, e);
+    }
+
+    @RelationshipId
+    private Long id;
+
+    private int index;
+
+    @TargetNode
+    private Neo4jMacroTaskCommand command;
+
+    public Neo4jMacroTaskCommandRelationship(final int index, final Neo4jMacroTaskCommand command) {
+        this.index = index;
+        this.command = command;
+    }
+
+    @Override
+    public int getIndex() {
+        return index;
+    }
+
+    @Override
+    public Neo4jMacroTaskCommand getEntity() {
+        return command;
+    }
+
+    @Override
+    public int compareTo(final Neo4jMacroTaskCommandRelationship object) {
+        return Integer.compare(index, object.getIndex());
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        Neo4jMacroTaskCommandRelationship other = (Neo4jMacroTaskCommandRelationship) obj;
+        return new EqualsBuilder().
+                append(index, other.index).
+                append(command, other.command).
+                build();
+    }
+
+    @Override
+    public int hashCode() {
+        return new HashCodeBuilder().
+                append(index).
+                append(command).
+                build();
+    }
+
+    @Override
+    public String toString() {
+        return "Neo4jMacroTaskCommandRelationship{"
+                + "id=" + id
+                + ", index=" + index
+                + ", command=" + command
+                + '}';
+    }
+}
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPullTask.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPullTask.java
index 8aba681..23a5f1f 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPullTask.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPullTask.java
@@ -70,7 +70,8 @@
     private SortedSet<Neo4jImplementationRelationship> actions = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedActions = new SortedSetList(actions);
+    private List<Neo4jImplementation> sortedActions = new SortedSetList<>(
+            actions, Neo4jImplementationRelationship.builder());
 
     @Relationship(type = PULL_TASK_TEMPLATE_REL, direction = Relationship.Direction.INCOMING)
     private List<Neo4jAnyTemplatePullTask> templates = new ArrayList<>();
@@ -171,6 +172,6 @@
 
     @PostLoad
     public void postLoad() {
-        sortedActions = new SortedSetList(actions);
+        sortedActions = new SortedSetList<>(actions, Neo4jImplementationRelationship.builder());
     }
 }
diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPushTask.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPushTask.java
index 41a895d..1343cb6 100644
--- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPushTask.java
+++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/entity/task/Neo4jPushTask.java
@@ -71,7 +71,8 @@
     private SortedSet<Neo4jImplementationRelationship> actions = new TreeSet<>();
 
     @Transient
-    private List<Neo4jImplementation> sortedActions = new SortedSetList(actions);
+    private List<Neo4jImplementation> sortedActions = new SortedSetList<>(
+            actions, Neo4jImplementationRelationship.builder());
 
     @Relationship(type = PUSH_TASK_EXEC_REL, direction = Relationship.Direction.INCOMING)
     private List<Neo4jPushTaskExec> executions = new ArrayList<>();
@@ -135,7 +136,7 @@
 
     @PostLoad
     public void postLoad() {
-        sortedActions = new SortedSetList(actions);
+        sortedActions = new SortedSetList<>(actions, Neo4jImplementationRelationship.builder());
         json2map(false);
     }
 
diff --git a/core/persistence-neo4j/src/main/resources/domains/MasterContent.xml b/core/persistence-neo4j/src/main/resources/domains/MasterContent.xml
index adff541..256e121 100644
--- a/core/persistence-neo4j/src/main/resources/domains/MasterContent.xml
+++ b/core/persistence-neo4j/src/main/resources/domains/MasterContent.xml
@@ -29,18 +29,18 @@
   <AnyType_AnyTypeClass anyType_id="GROUP" anyTypeClass_id="BaseGroup"/>
         
   <!-- Actual plain schemas -->
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <SyncopeSchema id="email"/>
   <PlainSchema id="email" type="String" anyTypeClass_id="BaseUser"
                mandatoryCondition="false" multivalue="0" uniqueConstraint="0" readonly="0"
                validator_id="EmailAddressValidator"/>
   
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/ImplementationTest.java b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/ImplementationTest.java
index 839739e..169aa71 100644
--- a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/ImplementationTest.java
+++ b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/ImplementationTest.java
@@ -71,7 +71,7 @@
         implementations = implementationDAO.findByType(IdRepoImplementationType.PASSWORD_RULE);
         assertEquals(3, implementations.size());
 
-        implementations = implementationDAO.findByType(IdRepoImplementationType.VALIDATOR);
+        implementations = implementationDAO.findByType(IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         assertEquals(2, implementations.size());
 
         implementations = implementationDAO.findByType(IdMImplementationType.PULL_CORRELATION_RULE);
@@ -86,7 +86,7 @@
         Implementation impl = entityFactory.newEntity(Implementation.class);
         impl.setKey("new");
         impl.setEngine(ImplementationEngine.GROOVY);
-        impl.setType(IdRepoImplementationType.VALIDATOR);
+        impl.setType(IdRepoImplementationType.ATTR_VALUE_VALIDATOR);
         impl.setBody("");
 
         Implementation actual = implementationDAO.save(impl);
diff --git a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/TaskTest.java b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/TaskTest.java
index 49c6927..50abb88 100644
--- a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/TaskTest.java
+++ b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/inner/TaskTest.java
@@ -22,20 +22,32 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.common.lib.form.FormPropertyType;
 import org.apache.syncope.common.lib.types.AnyTypeKind;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
+import org.apache.syncope.common.lib.types.ImplementationEngine;
 import org.apache.syncope.common.lib.types.ResourceOperation;
 import org.apache.syncope.common.lib.types.TaskType;
+import org.apache.syncope.core.persistence.api.attrvalue.InvalidEntityException;
 import org.apache.syncope.core.persistence.api.dao.ExternalResourceDAO;
+import org.apache.syncope.core.persistence.api.dao.ImplementationDAO;
+import org.apache.syncope.core.persistence.api.dao.RealmDAO;
 import org.apache.syncope.core.persistence.api.dao.TaskDAO;
 import org.apache.syncope.core.persistence.api.entity.ExternalResource;
+import org.apache.syncope.core.persistence.api.entity.Implementation;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationData;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.SchedTask;
@@ -62,6 +74,12 @@
     @Autowired
     private ExternalResourceDAO resourceDAO;
 
+    @Autowired
+    private RealmDAO realmDAO;
+
+    @Autowired
+    private ImplementationDAO implementationDAO;
+
     @Test
     public void findByName() {
         Optional<SchedTask> task = taskDAO.findByName(TaskType.SCHEDULED, "SampleJob Task");
@@ -149,6 +167,62 @@
     }
 
     @Test
+    public void saveMacroTask() throws Exception {
+        MacroTask task = entityFactory.newEntity(MacroTask.class);
+        task.setRealm(realmDAO.getRoot());
+        task.setJobDelegate(implementationDAO.findById("MacroJobDelegate").orElseThrow());
+        task.setName("Macro test");
+        task.setContinueOnError(true);
+
+        Implementation command = entityFactory.newEntity(Implementation.class);
+        command.setKey("command");
+        command.setType(IdRepoImplementationType.COMMAND);
+        command.setEngine(ImplementationEngine.JAVA);
+        command.setBody("clazz");
+        command = implementationDAO.save(command);
+        assertNotNull(command);
+
+        MacroTaskCommand macroTaskCommand = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand.setCommand(command);
+        macroTaskCommand.setMacroTask(task);
+        task.add(macroTaskCommand);
+
+        FormPropertyDef formPropertyDef = entityFactory.newEntity(FormPropertyDef.class);
+        formPropertyDef.setKey("one");
+        formPropertyDef.setName("One");
+        formPropertyDef.setType(FormPropertyType.Enum);
+        formPropertyDef.setMacroTask(task);
+        task.add(formPropertyDef);
+
+        Implementation macroActions = entityFactory.newEntity(Implementation.class);
+        macroActions.setKey("macroActions");
+        macroActions.setType(IdRepoImplementationType.MACRO_ACTIONS);
+        macroActions.setEngine(ImplementationEngine.JAVA);
+        macroActions.setBody("clazz");
+        macroActions = implementationDAO.save(macroActions);
+        assertNotNull(macroActions);
+        task.setMacroAction(macroActions);
+
+        try {
+            taskDAO.save(task);
+            fail();
+        } catch (InvalidEntityException e) {
+            assertNotNull(e);
+        }
+        formPropertyDef.setEnumValues(Map.of("key", "value"));
+
+        task = taskDAO.save(task);
+        assertNotNull(task);
+        assertEquals(1, task.getCommands().size());
+        assertEquals(command, task.getCommands().get(0).getCommand());
+        assertEquals(1, task.getFormPropertyDefs().size());
+        assertEquals(formPropertyDef, task.getFormPropertyDefs().get(0));
+
+        MacroTask actual = (MacroTask) taskDAO.findById(TaskType.MACRO, task.getKey()).orElseThrow();
+        assertEquals(task, actual);
+    }
+
+    @Test
     public void delete() {
         PropagationTask task = (PropagationTask) taskDAO.findById(
                 TaskType.PROPAGATION, "1e697572-b896-484c-ae7f-0c8f63fcbc6c").orElseThrow();
diff --git a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/outer/TaskTest.java b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/outer/TaskTest.java
index 3640dfc..64942f8 100644
--- a/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/outer/TaskTest.java
+++ b/core/persistence-neo4j/src/test/java/org/apache/syncope/core/persistence/neo4j/outer/TaskTest.java
@@ -31,7 +31,6 @@
 import java.util.List;
 import java.util.Set;
 import java.util.UUID;
-import org.apache.syncope.common.lib.command.CommandArgs;
 import org.apache.syncope.common.lib.types.AnyTypeKind;
 import org.apache.syncope.common.lib.types.ExecStatus;
 import org.apache.syncope.common.lib.types.IdMImplementationType;
@@ -51,6 +50,7 @@
 import org.apache.syncope.core.persistence.api.entity.ExternalResource;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationData;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PullTask;
@@ -358,38 +358,94 @@
     @Rollback(false)
     @Test
     public void macroTaskCommandsOrdering() {
-        Implementation impl1 = entityFactory.newEntity(Implementation.class);
-        impl1.setKey("impl1");
-        impl1.setEngine(ImplementationEngine.JAVA);
-        impl1.setType(IdRepoImplementationType.COMMAND);
-        impl1.setBody("TestCommand");
-        impl1 = implementationDAO.save(impl1);
-
-        Implementation impl2 = entityFactory.newEntity(Implementation.class);
-        impl2.setKey("impl2");
-        impl2.setEngine(ImplementationEngine.GROOVY);
-        impl2.setType(IdRepoImplementationType.COMMAND);
-        impl2.setBody("class GroovyCommand implements Command<CommandArgs> {}");
-        impl2 = implementationDAO.save(impl2);
-
         MacroTask task = entityFactory.newEntity(MacroTask.class);
         task.setName("macro");
-        task.setJobDelegate(implementationDAO.findById("MacroRunJobDelegate").orElseThrow());
+        task.setJobDelegate(implementationDAO.findById("MacroJobDelegate").orElseThrow());
         task.setRealm(realmDAO.getRoot());
-        task.add(impl1, new CommandArgs());
-        task.add(impl2, new CommandArgs());
+
+        Implementation command1 = entityFactory.newEntity(Implementation.class);
+        command1.setKey("impl1");
+        command1.setEngine(ImplementationEngine.JAVA);
+        command1.setType(IdRepoImplementationType.COMMAND);
+        command1.setBody("TestCommand");
+        command1 = implementationDAO.save(command1);
+
+        Implementation command2 = entityFactory.newEntity(Implementation.class);
+        command2.setKey("impl2");
+        command2.setEngine(ImplementationEngine.GROOVY);
+        command2.setType(IdRepoImplementationType.COMMAND);
+        command2.setBody("class GroovyCommand implements Command<CommandArgs> {}");
+        command2 = implementationDAO.save(command2);
+
+        MacroTaskCommand macroTaskCommand1 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand1.setCommand(command1);
+        macroTaskCommand1.setMacroTask(task);
+        task.add(macroTaskCommand1);
+
+        MacroTaskCommand macroTaskCommand2 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand2.setCommand(command2);
+        macroTaskCommand2.setMacroTask(task);
+        task.add(macroTaskCommand2);
 
         task = taskDAO.save(task);
         assertEquals(2, task.getCommands().size());
-        assertEquals(2, task.getCommandArgs().size());
-        assertEquals(impl1, task.getCommands().get(0));
-        assertEquals(impl2, task.getCommands().get(1));
+        assertEquals(macroTaskCommand1, task.getCommands().get(0));
+        assertEquals(macroTaskCommand2, task.getCommands().get(1));
 
         task = (MacroTask) taskDAO.findById(TaskType.MACRO, task.getKey()).orElseThrow();
         assertEquals(2, task.getCommands().size());
-        assertEquals(2, task.getCommandArgs().size());
-        assertEquals(impl1, task.getCommands().get(0));
-        assertEquals(impl2, task.getCommands().get(1));
+        assertEquals(macroTaskCommand1, task.getCommands().get(0));
+        assertEquals(macroTaskCommand2, task.getCommands().get(1));
+    }
+
+    @Test
+    public void saveMacroTaskSameCommandMultipleOccurrencies() {
+        MacroTask task = entityFactory.newEntity(MacroTask.class);
+        task.setRealm(realmDAO.getRoot());
+        task.setJobDelegate(implementationDAO.findById("MacroJobDelegate").orElseThrow());
+        task.setName("saveMacroTaskSameCommandMultipleOccurrencies");
+        task.setContinueOnError(true);
+
+        Implementation command1 = entityFactory.newEntity(Implementation.class);
+        command1.setKey("command1");
+        command1.setType(IdRepoImplementationType.COMMAND);
+        command1.setEngine(ImplementationEngine.JAVA);
+        command1.setBody("clazz1");
+        command1 = implementationDAO.save(command1);
+        assertNotNull(command1);
+
+        Implementation command2 = entityFactory.newEntity(Implementation.class);
+        command2.setKey("command2");
+        command2.setType(IdRepoImplementationType.COMMAND);
+        command2.setEngine(ImplementationEngine.JAVA);
+        command2.setBody("clazz2");
+        command2 = implementationDAO.save(command2);
+        assertNotNull(command2);
+
+        MacroTaskCommand macroTaskCommand1 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand1.setCommand(command1);
+        macroTaskCommand1.setMacroTask(task);
+        task.add(macroTaskCommand1);
+
+        MacroTaskCommand macroTaskCommand2 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand2.setCommand(command2);
+        macroTaskCommand2.setMacroTask(task);
+        task.add(macroTaskCommand2);
+
+        MacroTaskCommand macroTaskCommand3 = entityFactory.newEntity(MacroTaskCommand.class);
+        macroTaskCommand3.setCommand(command1);
+        macroTaskCommand3.setMacroTask(task);
+        task.add(macroTaskCommand3);
+
+        task = taskDAO.save(task);
+        assertNotNull(task);
+        assertEquals(3, task.getCommands().size());
+        assertEquals(command1, task.getCommands().get(0).getCommand());
+        assertEquals(command2, task.getCommands().get(1).getCommand());
+        assertEquals(command1, task.getCommands().get(2).getCommand());
+
+        MacroTask actual = (MacroTask) taskDAO.findById(TaskType.MACRO, task.getKey()).orElseThrow();
+        assertEquals(task, actual);
     }
 
     @Test
diff --git a/core/persistence-neo4j/src/test/resources/domains/MasterContent.xml b/core/persistence-neo4j/src/test/resources/domains/MasterContent.xml
index 42680a0..d446254 100644
--- a/core/persistence-neo4j/src/test/resources/domains/MasterContent.xml
+++ b/core/persistence-neo4j/src/test/resources/domains/MasterContent.xml
@@ -122,7 +122,7 @@
 
   <PlainSchema id="fullname" type="String" mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"/>
   <PlainSchema_AnyTypeClass left="fullname" right="minimal user"/>
-  <Implementation id="EmailAddressValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="EmailAddressValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.EmailAddressValidator"/>
   <PlainSchema id="userId" type="String" mandatoryCondition="true" multivalue="0" uniqueConstraint="1" readonly="0"/>
   <PlainSchema_AnyTypeClass left="userId" right="minimal user"/>
@@ -163,7 +163,7 @@
                mimeType="image/jpeg"/>
   <PlainSchema_AnyTypeClass left="photo" right="other"/>
   
-  <Implementation id="BinaryValidator" type="VALIDATOR" engine="JAVA"
+  <Implementation id="BinaryValidator" type="ATTR_VALUE_VALIDATOR" engine="JAVA"
                   body="org.apache.syncope.core.persistence.common.attrvalue.BinaryValidator"/>
 
   <DerSchema id="csvuserid" expression="firstname + ',' + surname"/>
@@ -692,8 +692,8 @@
   <VirSchema_AnyType left="virtualdata" right="USER"/>
   <VirSchema_ExternalResource left="virtualdata" right="resource-db-virattr"/>
 
-  <Implementation id="MacroRunJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
-                  body="org.apache.syncope.core.logic.job.MacroRunJobDelegate"/>
+  <Implementation id="MacroJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
+                  body="org.apache.syncope.core.provisioning.java.job.MacroJobDelegate"/>
 
   <Implementation id="PullJobDelegate" type="TASKJOB_DELEGATE" engine="JAVA"
                   body="org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate"/>
diff --git a/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/data/TaskDataBinder.java b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/data/TaskDataBinder.java
index eacbb5c..a8a1dc5 100644
--- a/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/data/TaskDataBinder.java
+++ b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/data/TaskDataBinder.java
@@ -18,9 +18,11 @@
  */
 package org.apache.syncope.core.provisioning.api.data;
 
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.syncope.common.lib.to.ExecTO;
 import org.apache.syncope.common.lib.to.SchedTaskTO;
 import org.apache.syncope.common.lib.to.TaskTO;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
 import org.apache.syncope.core.persistence.api.entity.task.SchedTask;
 import org.apache.syncope.core.persistence.api.entity.task.Task;
 import org.apache.syncope.core.persistence.api.entity.task.TaskExec;
@@ -37,4 +39,6 @@
     ExecTO getExecTO(TaskExec<?> execution);
 
     <T extends TaskTO> T getTaskTO(Task<?> task, TaskUtils taskUtil, boolean details);
+
+    SyncopeForm getMacroTaskForm(MacroTask task);
 }
diff --git a/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtils.java b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtils.java
index 45b22af..b7c8c72 100644
--- a/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtils.java
+++ b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtils.java
@@ -21,6 +21,7 @@
 import java.beans.IntrospectionException;
 import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
+import java.io.StringWriter;
 import java.lang.reflect.Field;
 import java.time.temporal.TemporalAccessor;
 import java.util.Collection;
@@ -29,6 +30,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.commons.jexl3.JexlBuilder;
 import org.apache.commons.jexl3.JexlContext;
@@ -70,7 +72,9 @@
 
     private static JexlEngine JEXL_ENGINE;
 
-    private static JexlEngine getEngine() {
+    private static JxltEngine JXTL_ENGINE;
+
+    private static JexlEngine getJexlEngine() {
         synchronized (LOG) {
             if (JEXL_ENGINE == null) {
                 JEXL_ENGINE = new JexlBuilder().
@@ -87,29 +91,35 @@
         return JEXL_ENGINE;
     }
 
-    public static JxltEngine newJxltEngine() {
-        return getEngine().createJxltEngine(false);
+    private static JxltEngine getJxltEngine() {
+        synchronized (LOG) {
+            if (JXTL_ENGINE == null) {
+                JXTL_ENGINE = getJexlEngine().createJxltEngine(false);
+            }
+        }
+
+        return JXTL_ENGINE;
     }
 
     public static boolean isExpressionValid(final String expression) {
         boolean result;
         try {
-            getEngine().createExpression(expression);
+            getJexlEngine().createExpression(expression);
             result = true;
         } catch (JexlException e) {
-            LOG.error("Invalid jexl expression: " + expression, e);
+            LOG.error("Invalid JEXL expression: " + expression, e);
             result = false;
         }
 
         return result;
     }
 
-    public static Object evaluate(final String expression, final JexlContext jexlContext) {
+    public static Object evaluateExpr(final String expression, final JexlContext jexlContext) {
         Object result = null;
 
         if (StringUtils.isNotBlank(expression) && jexlContext != null) {
             try {
-                JexlExpression jexlExpression = getEngine().createExpression(expression);
+                JexlExpression jexlExpression = getJexlEngine().createExpression(expression);
                 result = jexlExpression.evaluate(jexlContext);
             } catch (Exception e) {
                 LOG.error("Error while evaluating JEXL expression: " + expression, e);
@@ -118,7 +128,25 @@
             LOG.debug("Expression not provided or invalid context");
         }
 
-        return result == null ? StringUtils.EMPTY : result;
+        return Optional.ofNullable(result).orElse(StringUtils.EMPTY);
+    }
+
+    public static String evaluateTemplate(final String template, final JexlContext jexlContext) {
+        String result = null;
+
+        if (StringUtils.isNotBlank(template) && jexlContext != null) {
+            try {
+                StringWriter writer = new StringWriter();
+                getJxltEngine().createTemplate(template).evaluate(jexlContext, writer);
+                result = writer.toString();
+            } catch (Exception e) {
+                LOG.error("Error while evaluating JEXL template: " + template, e);
+            }
+        } else {
+            LOG.debug("Template not provided or invalid context");
+        }
+
+        return Optional.ofNullable(result).orElse(template);
     }
 
     public static void addFieldsToContext(final Object object, final JexlContext jexlContext) {
@@ -256,7 +284,7 @@
         addPlainAttrsToContext(any.getPlainAttrs(), jexlContext);
         addDerAttrsToContext(any, derAttrHandler, jexlContext);
 
-        return Boolean.parseBoolean(evaluate(mandatoryCondition, jexlContext).toString());
+        return Boolean.parseBoolean(evaluateExpr(mandatoryCondition, jexlContext).toString());
     }
 
     /**
diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/Command.java
similarity index 94%
rename from core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java
rename to core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/Command.java
index 88047c8..aea95cd 100644
--- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/Command.java
+++ b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/Command.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.logic.api;
+package org.apache.syncope.core.provisioning.api.macro;
 
 import org.apache.syncope.common.lib.command.CommandArgs;
 
diff --git a/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/MacroActions.java b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/MacroActions.java
new file mode 100644
index 0000000..a631586
--- /dev/null
+++ b/core/provisioning-api/src/main/java/org/apache/syncope/core/provisioning/api/macro/MacroActions.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.provisioning.api.macro;
+
+import jakarta.validation.ValidationException;
+import java.util.Map;
+import org.apache.syncope.common.lib.command.CommandArgs;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+
+/**
+ * Interface for actions to be performed during macro execution.
+ */
+public interface MacroActions {
+
+    default void validate(SyncopeForm macroTaskForm) throws ValidationException {
+        // does nothing by default
+    }
+
+    default Map<String, String> getDropdownValues(String formProperty) {
+        return Map.of();
+    }
+
+    default void beforeAll() {
+        // does nothing by default
+    }
+
+    default void beforeCommand(Command<CommandArgs> command, CommandArgs args) {
+        // does nothing by default
+    }
+
+    default void afterCommand(Command<CommandArgs> command, CommandArgs args, String output) {
+        // does nothing by default
+    }
+
+    default StringBuilder afterAll(StringBuilder output) {
+        return output;
+    }
+}
diff --git a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtilsTest.java b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtilsTest.java
index 82d8226..5f38aaf 100644
--- a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtilsTest.java
+++ b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/JexlUtilsTest.java
@@ -20,7 +20,6 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -34,7 +33,6 @@
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.commons.jexl3.JexlContext;
-import org.apache.commons.jexl3.JxltEngine;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.lib.Attr;
 import org.apache.syncope.common.lib.to.AnyTO;
@@ -54,12 +52,6 @@
     private JexlContext context;
 
     @Test
-    public void newJxltEngine() {
-        JxltEngine engine = JexlUtils.newJxltEngine();
-        assertNotNull(engine);
-    }
-
-    @Test
     public void isExpressionValid() {
         String expression = "6 * 12 + 5 / 2.6";
         assertTrue(JexlUtils.isExpressionValid(expression));
@@ -71,11 +63,11 @@
     @Test
     public void evaluate() {
         String expression = null;
-        assertEquals(StringUtils.EMPTY, JexlUtils.evaluate(expression, context));
+        assertEquals(StringUtils.EMPTY, JexlUtils.evaluateExpr(expression, context));
 
         expression = "6 * 12 + 5 / 2.6";
         double result = 73.92307692307692;
-        assertEquals(result, JexlUtils.evaluate(expression, context));
+        assertEquals(result, JexlUtils.evaluateExpr(expression, context));
     }
 
     @Test
diff --git a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MailTemplateTest.java b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MailTemplateTest.java
index bfb6a2c..9cc4e36 100644
--- a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MailTemplateTest.java
+++ b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MailTemplateTest.java
@@ -23,7 +23,6 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
-import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -65,17 +64,9 @@
             + " $$ for(membership : user.memberships) {\n   <li>${membership.groupName}</li>\n $$ }\n"
             + " </ul>\n $$ }\n </body> </html>";
 
-    private static String evaluate(final String template, final Map<String, Object> jexlVars) {
-        StringWriter writer = new StringWriter();
-        JexlUtils.newJxltEngine().
-                createTemplate(template).
-                evaluate(new MapContext(jexlVars), writer);
-        return writer.toString();
-    }
-
     @Test
     public void confirmPasswordReset() throws IOException {
-        String htmlBody = evaluate(CONFIRM_PASSWORD_RESET_TEMPLATE, new HashMap<>());
+        String htmlBody = JexlUtils.evaluateTemplate(CONFIRM_PASSWORD_RESET_TEMPLATE, new MapContext());
         assertNotNull(htmlBody);
     }
 
@@ -93,7 +84,7 @@
         input.add(token);
         ctx.put("input", input);
 
-        String textBody = evaluate(REQUEST_PASSWORD_RESET_TEMPLATE, ctx);
+        String textBody = JexlUtils.evaluateTemplate(REQUEST_PASSWORD_RESET_TEMPLATE, new MapContext(ctx));
 
         assertNotNull(textBody);
         assertTrue(textBody.contains("a password reset was requested for " + username + "."));
@@ -129,7 +120,7 @@
 
         ctx.put("events", List.of("event1"));
 
-        String htmlBody = evaluate(OPTIN_TEMPLATE, ctx);
+        String htmlBody = JexlUtils.evaluateTemplate(OPTIN_TEMPLATE, new MapContext(ctx));
 
         assertNotNull(htmlBody);
 
diff --git a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MappingTest.java b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MappingTest.java
index cd84f47..9badb8f 100644
--- a/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MappingTest.java
+++ b/core/provisioning-api/src/test/java/org/apache/syncope/core/provisioning/api/jexl/MappingTest.java
@@ -47,10 +47,10 @@
         JexlUtils.addFieldsToContext(user, jexlContext);
 
         String connObjectLink = "'uid=' + username + ',ou=people,o=isp'";
-        assertEquals("uid=rossini,ou=people,o=isp", JexlUtils.evaluate(connObjectLink, jexlContext));
+        assertEquals("uid=rossini,ou=people,o=isp", JexlUtils.evaluateExpr(connObjectLink, jexlContext));
 
         connObjectLink = "'uid=' + username + realm.replaceAll('/', ',o=') + ',ou=people,o=isp'";
-        assertEquals("uid=rossini,o=even,ou=people,o=isp", JexlUtils.evaluate(connObjectLink, jexlContext));
+        assertEquals("uid=rossini,o=even,ou=people,o=isp", JexlUtils.evaluateExpr(connObjectLink, jexlContext));
     }
 
     @Test
@@ -63,7 +63,7 @@
         JexlUtils.addFieldsToContext(realm, jexlContext);
 
         String connObjectLink = "syncope:fullPath2Dn(fullPath, 'ou') + ',o=isp'";
-        assertEquals("ou=two,ou=even,o=isp", JexlUtils.evaluate(connObjectLink, jexlContext));
+        assertEquals("ou=two,ou=even,o=isp", JexlUtils.evaluateExpr(connObjectLink, jexlContext));
 
         when(realm.getFullPath()).thenReturn("/even");
         assertNotNull(realm);
@@ -71,7 +71,7 @@
         jexlContext = new MapContext();
         JexlUtils.addFieldsToContext(realm, jexlContext);
 
-        assertEquals("ou=even,o=isp", JexlUtils.evaluate(connObjectLink, jexlContext));
+        assertEquals("ou=even,o=isp", JexlUtils.evaluateExpr(connObjectLink, jexlContext));
     }
 
     @Test
@@ -82,6 +82,6 @@
         jexlContext.set("value", now);
 
         String expression = "value.toInstant().toEpochMilli()";
-        assertEquals(now.toInstant().toEpochMilli(), JexlUtils.evaluate(expression, jexlContext));
+        assertEquals(now.toInstant().toEpochMilli(), JexlUtils.evaluateExpr(expression, jexlContext));
     }
 }
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultDerAttrHandler.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultDerAttrHandler.java
index 3ff55e6..84cdb5e 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultDerAttrHandler.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultDerAttrHandler.java
@@ -47,7 +47,7 @@
             JexlUtils.addPlainAttrsToContext(any.getPlainAttrs(), jexlContext);
             JexlUtils.addFieldsToContext(any, jexlContext);
 
-            result.put(schema, JexlUtils.evaluate(schema.getExpression(), jexlContext).toString());
+            result.put(schema, JexlUtils.evaluateExpr(schema.getExpression(), jexlContext).toString());
         });
 
         return result;
@@ -98,7 +98,7 @@
             JexlUtils.addPlainAttrsToContext(any.getPlainAttrs(membership), jexlContext);
             JexlUtils.addFieldsToContext(any, jexlContext);
 
-            result.put(schema, JexlUtils.evaluate(schema.getExpression(), jexlContext).toString());
+            result.put(schema, JexlUtils.evaluateExpr(schema.getExpression(), jexlContext).toString());
         });
 
         return result;
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultMappingManager.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultMappingManager.java
index 2105868..43db073 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultMappingManager.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/DefaultMappingManager.java
@@ -254,7 +254,7 @@
             JexlUtils.addFieldsToContext(any, jexlContext);
             JexlUtils.addPlainAttrsToContext(any.getPlainAttrs(), jexlContext);
             JexlUtils.addDerAttrsToContext(any, derAttrHandler, jexlContext);
-            evalConnObjectLink = JexlUtils.evaluate(connObjectLink, jexlContext).toString();
+            evalConnObjectLink = JexlUtils.evaluateExpr(connObjectLink, jexlContext).toString();
         }
 
         return getName(evalConnObjectLink, connObjectKey);
@@ -282,7 +282,7 @@
         if (StringUtils.isNotBlank(connObjectLink)) {
             JexlContext jexlContext = new MapContext();
             JexlUtils.addFieldsToContext(realm, jexlContext);
-            evalConnObjectLink = JexlUtils.evaluate(connObjectLink, jexlContext).toString();
+            evalConnObjectLink = JexlUtils.evaluateExpr(connObjectLink, jexlContext).toString();
         }
 
         return getName(evalConnObjectLink, connObjectKey);
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/ProvisioningContext.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/ProvisioningContext.java
index fb67d03..4ff60ae 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/ProvisioningContext.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/ProvisioningContext.java
@@ -476,8 +476,8 @@
 
     @ConditionalOnMissingBean
     @Bean
-    public ConnIdBundleManager connIdBundleManager(final ProvisioningProperties provisioningProperties) {
-        return new DefaultConnIdBundleManager(provisioningProperties.getConnIdLocation());
+    public ConnIdBundleManager connIdBundleManager(final ProvisioningProperties props) {
+        return new DefaultConnIdBundleManager(props.getConnIdLocation());
     }
 
     @ConditionalOnMissingBean
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/JEXLItemTransformerImpl.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/JEXLItemTransformerImpl.java
index d59324e..b709a3c 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/JEXLItemTransformerImpl.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/JEXLItemTransformerImpl.java
@@ -102,7 +102,7 @@
         }
         jexlContext.set("value", oValue);
 
-        Object tValue = JexlUtils.evaluate(propagationJEXL, jexlContext);
+        Object tValue = JexlUtils.evaluateExpr(propagationJEXL, jexlContext);
 
         value.setBinaryValue(null);
         value.setBooleanValue(null);
@@ -183,7 +183,7 @@
                     JexlUtils.addAttrsToContext(((AnyTO) entityTO).getVirAttrs(), jexlContext);
                 }
 
-                newValues.add(JexlUtils.evaluate(pullJEXL, jexlContext));
+                newValues.add(JexlUtils.evaluateExpr(pullJEXL, jexlContext));
             });
 
             return newValues;
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/TaskDataBinderImpl.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/TaskDataBinderImpl.java
index 119eeea..d4eb849 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/TaskDataBinderImpl.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/data/TaskDataBinderImpl.java
@@ -19,12 +19,20 @@
 package org.apache.syncope.core.provisioning.java.data;
 
 import java.util.Comparator;
+import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.lib.SyncopeClientException;
 import org.apache.syncope.common.lib.command.CommandArgs;
 import org.apache.syncope.common.lib.command.CommandTO;
+import org.apache.syncope.common.lib.form.FormProperty;
+import org.apache.syncope.common.lib.form.FormPropertyValue;
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.syncope.common.lib.to.ExecTO;
+import org.apache.syncope.common.lib.to.FormPropertyDefTO;
 import org.apache.syncope.common.lib.to.MacroTaskTO;
 import org.apache.syncope.common.lib.to.NotificationTaskTO;
 import org.apache.syncope.common.lib.to.PropagationTaskTO;
@@ -49,7 +57,9 @@
 import org.apache.syncope.core.persistence.api.entity.EntityFactory;
 import org.apache.syncope.core.persistence.api.entity.Implementation;
 import org.apache.syncope.core.persistence.api.entity.task.AnyTemplatePullTask;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
 import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
 import org.apache.syncope.core.persistence.api.entity.task.NotificationTask;
 import org.apache.syncope.core.persistence.api.entity.task.PropagationTask;
 import org.apache.syncope.core.persistence.api.entity.task.ProvisioningTask;
@@ -62,6 +72,8 @@
 import org.apache.syncope.core.persistence.api.entity.task.TaskUtilsFactory;
 import org.apache.syncope.core.provisioning.api.data.TaskDataBinder;
 import org.apache.syncope.core.provisioning.api.job.JobNamer;
+import org.apache.syncope.core.provisioning.api.macro.MacroActions;
+import org.apache.syncope.core.provisioning.java.job.MacroJobDelegate;
 import org.apache.syncope.core.provisioning.java.job.SyncopeTaskScheduler;
 import org.apache.syncope.core.provisioning.java.pushpull.PullJobDelegate;
 import org.apache.syncope.core.provisioning.java.pushpull.PushJobDelegate;
@@ -75,8 +87,6 @@
 
     protected static final Logger LOG = LoggerFactory.getLogger(TaskDataBinder.class);
 
-    protected static final String MACRO_RUN_JOB_DELEGATE = "org.apache.syncope.core.logic.job.MacroRunJobDelegate";
-
     protected final RealmSearchDAO realmSearchDAO;
 
     protected final ExternalResourceDAO resourceDAO;
@@ -93,6 +103,8 @@
 
     protected final TaskUtilsFactory taskUtilsFactory;
 
+    protected final Map<String, MacroActions> perContextMacroActions = new ConcurrentHashMap<>();
+
     public TaskDataBinderImpl(
             final RealmSearchDAO realmSearchDAO,
             final ExternalResourceDAO resourceDAO,
@@ -225,6 +237,7 @@
         macroTask.setRealm(realmSearchDAO.findByFullPath(macroTaskTO.getRealm()).
                 orElseThrow(() -> new NotFoundException("Realm " + macroTaskTO.getRealm())));
 
+        macroTask.getCommands().clear();
         macroTaskTO.getCommands().
                 forEach(command -> implementationDAO.findById(command.getKey()).ifPresentOrElse(
                 impl -> {
@@ -234,7 +247,12 @@
                             args = ImplementationManager.emptyArgs(impl);
                         }
 
-                        macroTask.add(impl, args);
+                        MacroTaskCommand macroTaskCommand = entityFactory.newEntity(MacroTaskCommand.class);
+                        macroTaskCommand.setCommand(impl);
+                        macroTaskCommand.setArgs(args);
+
+                        macroTaskCommand.setMacroTask(macroTask);
+                        macroTask.add(macroTaskCommand);
                     } catch (Exception e) {
                         LOG.error("While adding Command {} to Macro", impl.getKey(), e);
 
@@ -248,6 +266,30 @@
 
         macroTask.setContinueOnError(macroTaskTO.isContinueOnError());
         macroTask.setSaveExecs(macroTaskTO.isSaveExecs());
+
+        macroTask.getFormPropertyDefs().clear();
+        macroTaskTO.getFormPropertyDefs().forEach(fpdTO -> {
+            FormPropertyDef fpd = entityFactory.newEntity(FormPropertyDef.class);
+            fpd.setKey(fpdTO.getKey());
+            fpd.setName(fpdTO.getName());
+            fpd.setType(fpdTO.getType());
+            fpd.setReadable(fpdTO.isReadable());
+            fpd.setWritable(fpdTO.isWritable());
+            fpd.setRequired(fpdTO.isRequired());
+            fpd.setDatePattern(fpdTO.getDatePattern());
+            fpd.setEnumValues(fpdTO.getEnumValues());
+
+            fpd.setMacroTask(macroTask);
+            macroTask.add(fpd);
+        });
+
+        if (macroTaskTO.getMacroActions() == null) {
+            macroTask.setMacroAction(null);
+        } else {
+            implementationDAO.findById(macroTaskTO.getMacroActions()).ifPresentOrElse(
+                    macroTask::setMacroAction,
+                    () -> LOG.debug("Invalid Implementation {}, ignoring...", macroTaskTO.getMacroActions()));
+        }
     }
 
     @Override
@@ -272,16 +314,16 @@
 
             Implementation jobDelegate = (macroTaskTO.getJobDelegate() == null
                     ? implementationDAO.findByType(IdRepoImplementationType.TASKJOB_DELEGATE).stream().
-                            filter(impl -> MACRO_RUN_JOB_DELEGATE.equals(impl.getBody())).
+                            filter(impl -> MacroJobDelegate.class.getName().equals(impl.getBody())).
                             findFirst()
                     : implementationDAO.findById(macroTaskTO.getJobDelegate())).
                     orElse(null);
             if (jobDelegate == null) {
                 jobDelegate = entityFactory.newEntity(Implementation.class);
-                jobDelegate.setKey(StringUtils.substringAfterLast(MACRO_RUN_JOB_DELEGATE, "."));
+                jobDelegate.setKey(MacroJobDelegate.class.getSimpleName());
                 jobDelegate.setEngine(ImplementationEngine.JAVA);
                 jobDelegate.setType(IdRepoImplementationType.TASKJOB_DELEGATE);
-                jobDelegate.setBody(MACRO_RUN_JOB_DELEGATE);
+                jobDelegate.setBody(MacroJobDelegate.class.getName());
                 jobDelegate = implementationDAO.save(jobDelegate);
             }
             macroTask.setJobDelegate(jobDelegate);
@@ -320,21 +362,10 @@
         task.setCronExpression(taskTO.getCronExpression());
         task.setActive(taskTO.isActive());
 
-        switch (task) {
-            case MacroTask macroTask -> {
-                MacroTaskTO macroTaskTO = (MacroTaskTO) taskTO;
-
-                macroTask.getCommands().clear();
-                macroTask.getCommandArgs().clear();
-
-                fill(macroTask, macroTaskTO);
-            }
-
-            case ProvisioningTask<?> provisioningTask ->
-                fill(provisioningTask, (ProvisioningTaskTO) taskTO);
-
-            default -> {
-            }
+        if (task instanceof MacroTask) {
+            fill((MacroTask) task, (MacroTaskTO) taskTO);
+        } else if (task instanceof ProvisioningTask) {
+            fill((ProvisioningTask) task, (ProvisioningTaskTO) taskTO);
         }
     }
 
@@ -450,17 +481,33 @@
                 MacroTask macroTask = (MacroTask) task;
                 MacroTaskTO macroTaskTO = (MacroTaskTO) taskTO;
 
+                fill(macroTaskTO, macroTask);
+
                 macroTaskTO.setJobDelegate(macroTask.getJobDelegate().getKey());
                 macroTaskTO.setRealm(macroTask.getRealm().getFullPath());
-                for (int i = 0; i < macroTask.getCommands().size(); i++) {
-                    macroTaskTO.getCommands().add(
-                            new CommandTO.Builder(macroTask.getCommands().get(i).getKey()).
-                                    args(macroTask.getCommandArgs().get(i)).build());
-                }
+
+                macroTask.getCommands().forEach(mct -> macroTaskTO.getCommands().add(
+                        new CommandTO.Builder(mct.getCommand().getKey()).args(mct.getArgs()).build()));
+
                 macroTaskTO.setContinueOnError(macroTask.isContinueOnError());
                 macroTaskTO.setSaveExecs(macroTask.isSaveExecs());
 
-                fill(macroTaskTO, macroTask);
+                macroTask.getFormPropertyDefs().forEach(fpd -> {
+                    FormPropertyDefTO fpdTO = new FormPropertyDefTO();
+                    fpdTO.setKey(fpd.getKey());
+                    fpdTO.setName(fpd.getName());
+                    fpdTO.setType(fpd.getType());
+                    fpdTO.setReadable(fpd.isReadable());
+                    fpdTO.setWritable(fpd.isWritable());
+                    fpdTO.setRequired(fpd.isRequired());
+                    fpdTO.setDatePattern(fpd.getDatePattern());
+                    fpdTO.getEnumValues().putAll(fpd.getEnumValues());
+
+                    macroTaskTO.getFormPropertyDefs().add(fpdTO);
+                });
+
+                Optional.ofNullable(macroTask.getMacroActions()).
+                        ifPresent(fv -> macroTaskTO.setMacroActions(fv.getKey()));
             }
 
             case PULL -> {
@@ -476,9 +523,8 @@
                         ? UnmatchingRule.PROVISION : pullTask.getUnmatchingRule());
                 pullTaskTO.setPullMode(pullTask.getPullMode());
 
-                if (pullTask.getReconFilterBuilder() != null) {
-                    pullTaskTO.setReconFilterBuilder(pullTask.getReconFilterBuilder().getKey());
-                }
+                Optional.ofNullable(pullTask.getReconFilterBuilder()).
+                        ifPresent(rfb -> pullTaskTO.setReconFilterBuilder(rfb.getKey()));
 
                 pullTask.getTemplates().
                         forEach(template -> pullTaskTO.getTemplates().
@@ -527,4 +573,59 @@
 
         return taskTO;
     }
+
+    @Override
+    public SyncopeForm getMacroTaskForm(final MacroTask task) {
+        if (task.getFormPropertyDefs().isEmpty()) {
+            throw new NotFoundException("No form properties defined for MacroTask " + task.getKey());
+        }
+
+        Optional<MacroActions> actions;
+        if (task.getMacroActions() == null) {
+            actions = Optional.empty();
+        } else {
+            try {
+                actions = Optional.of(ImplementationManager.build(
+                        task.getMacroActions(),
+                        () -> perContextMacroActions.get(task.getMacroActions().getKey()),
+                        instance -> perContextMacroActions.put(task.getMacroActions().getKey(), instance)));
+            } catch (Exception e) {
+                LOG.error("Could not build {}", task.getMacroActions().getKey(), e);
+
+                SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.InvalidImplementation);
+                sce.getElements().add("Could not build " + task.getMacroActions().getKey());
+                throw sce;
+            }
+        }
+
+        SyncopeForm form = new SyncopeForm();
+
+        form.getProperties().addAll(task.getFormPropertyDefs().stream().map(fpd -> {
+            FormProperty prop = new FormProperty();
+            prop.setId(fpd.getKey());
+            prop.setName(fpd.getName());
+            prop.setReadable(fpd.isReadable());
+            prop.setRequired(fpd.isRequired());
+            prop.setWritable(fpd.isWritable());
+            prop.setType(fpd.getType());
+            switch (prop.getType()) {
+                case Date ->
+                    prop.setDatePattern(fpd.getDatePattern());
+
+                case Enum ->
+                    fpd.getEnumValues().
+                            forEach((key, value) -> prop.getEnumValues().add(new FormPropertyValue(key, value)));
+
+                case Dropdown ->
+                    actions.ifPresent(a -> a.getDropdownValues(fpd.getKey()).
+                            forEach((key, value) -> prop.getDropdownValues().add(new FormPropertyValue(key, value))));
+
+                default -> {
+                }
+            }
+            return prop;
+        }).collect(Collectors.toList()));
+
+        return form;
+    }
 }
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/job/MacroJobDelegate.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/job/MacroJobDelegate.java
new file mode 100644
index 0000000..e563026
--- /dev/null
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/job/MacroJobDelegate.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.core.provisioning.java.job;
+
+import jakarta.annotation.Resource;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ValidationException;
+import jakarta.validation.Validator;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import org.apache.commons.jexl3.JexlContext;
+import org.apache.commons.jexl3.MapContext;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.syncope.common.lib.command.CommandArgs;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+import org.apache.syncope.core.persistence.api.dao.ImplementationDAO;
+import org.apache.syncope.core.persistence.api.entity.task.FormPropertyDef;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTask;
+import org.apache.syncope.core.persistence.api.entity.task.MacroTaskCommand;
+import org.apache.syncope.core.persistence.api.entity.task.TaskExec;
+import org.apache.syncope.core.persistence.api.utils.FormatUtils;
+import org.apache.syncope.core.provisioning.api.jexl.JexlUtils;
+import org.apache.syncope.core.provisioning.api.job.JobExecutionContext;
+import org.apache.syncope.core.provisioning.api.job.JobExecutionException;
+import org.apache.syncope.core.provisioning.api.macro.Command;
+import org.apache.syncope.core.provisioning.api.macro.MacroActions;
+import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
+import org.apache.syncope.core.spring.implementation.ImplementationManager;
+import org.apache.syncope.core.spring.task.VirtualThreadPoolTaskExecutor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.concurrent.DelegatingSecurityContextCallable;
+import org.springframework.util.ReflectionUtils;
+
+public class MacroJobDelegate extends AbstractSchedTaskJobDelegate<MacroTask> {
+
+    public static final String MACRO_TASK_FORM_JOBDETAIL_KEY = "macroTaskForm";
+
+    @Autowired
+    protected ImplementationDAO implementationDAO;
+
+    @Autowired
+    protected Validator validator;
+
+    @Resource(name = "batchExecutor")
+    protected VirtualThreadPoolTaskExecutor executor;
+
+    protected final Map<String, MacroActions> perContextActions = new ConcurrentHashMap<>();
+
+    protected final Map<String, Command<?>> perContextCommands = new ConcurrentHashMap<>();
+
+    protected boolean validate(final FormPropertyDef fpd, final String value, final Optional<MacroActions> actions) {
+        if (!fpd.isWritable()) {
+            return false;
+        }
+
+        return switch (fpd.getType()) {
+            case Enum ->
+                fpd.getEnumValues().containsKey(value);
+            case Dropdown ->
+                actions.map(a -> a.getDropdownValues(fpd.getKey()).containsKey(value)).orElse(false);
+            default ->
+                value != null;
+        };
+    }
+
+    protected Optional<JexlContext> check(
+            final SyncopeForm macroTaskForm,
+            final Optional<MacroActions> actions,
+            final StringBuilder output) throws JobExecutionException {
+
+        if (macroTaskForm == null) {
+            return Optional.empty();
+        }
+
+        // check if there is any required property with no value provided
+        Set<String> missingFormProperties = task.getFormPropertyDefs().stream().
+                filter(FormPropertyDef::isRequired).
+                map(fpd -> Pair.of(
+                fpd.getKey(),
+                macroTaskForm.getProperty(fpd.getKey()).map(p -> p.getValue() != null))).
+                filter(pair -> pair.getRight().isEmpty()).
+                map(Pair::getLeft).
+                collect(Collectors.toSet());
+        if (!missingFormProperties.isEmpty()) {
+            throw new JobExecutionException("Required form properties missing: " + missingFormProperties);
+        }
+
+        // if validator is defined, validate the provided form
+        try {
+            actions.ifPresent(a -> a.validate(macroTaskForm));
+        } catch (ValidationException e) {
+            throw new JobExecutionException("Invalid form submitted for task " + task.getKey(), e);
+        }
+
+        // build the JEXL context where variables are mapped to property values, built according to the defined type
+        Map<String, Object> vars = macroTaskForm.getProperties().stream().
+                map(p -> task.getFormPropertyDefs().stream().
+                filter(fpd -> fpd.getKey().equals(p.getId()) && validate(fpd, p.getValue(), actions)).findFirst().
+                map(fpd -> Pair.of(fpd, p.getValue()))).
+                filter(Optional::isPresent).map(Optional::get).
+                map(pair -> {
+                    Object value;
+                    switch (pair.getLeft().getType()) {
+                        case Boolean:
+                            value = BooleanUtils.toBoolean(pair.getRight());
+                            break;
+
+                        case Date:
+                            value = StringUtils.isBlank(pair.getLeft().getDatePattern())
+                                    ? FormatUtils.parseDate(pair.getRight())
+                                    : FormatUtils.parseDate(pair.getRight(), pair.getLeft().getDatePattern());
+                            break;
+
+                        case Long:
+                            value = NumberUtils.toLong(pair.getRight());
+                            break;
+
+                        case Enum:
+                        case Dropdown:
+                        case String:
+                        case Password:
+                        default:
+                            value = pair.getRight();
+                    }
+
+                    return Pair.of(pair.getLeft().getKey(), value);
+                }).collect(Collectors.toMap(Pair::getLeft, Pair::getRight));
+
+        output.append("Form parameter values: ").append(vars).append("\n\n");
+
+        return vars.isEmpty() ? Optional.empty() : Optional.of(new MapContext(vars));
+    }
+
+    protected String run(
+            final List<Pair<Command<CommandArgs>, CommandArgs>> commands,
+            final Optional<MacroActions> actions,
+            final StringBuilder output,
+            final boolean dryRun)
+            throws JobExecutionException {
+
+        Future<AtomicReference<Pair<String, Exception>>> future = executor.submit(
+                new DelegatingSecurityContextCallable<>(() -> {
+
+                    AtomicReference<Pair<String, Exception>> error = new AtomicReference<>();
+
+                    for (int i = 0; i < commands.size() && error.get() == null; i++) {
+                        Pair<Command<CommandArgs>, CommandArgs> command = commands.get(i);
+
+                        try {
+                            String args = POJOHelper.serialize(command.getRight());
+                            output.append("Command[").append(command.getLeft().getClass().getName()).append("]: ").
+                                    append(args).append("\n");
+
+                            if (!dryRun) {
+                                actions.ifPresent(a -> a.beforeCommand(command.getLeft(), command.getRight()));
+
+                                String cmdOut = command.getLeft().run(command.getRight());
+
+                                actions.ifPresent(a -> a.afterCommand(command.getLeft(), command.getRight(), cmdOut));
+
+                                output.append(cmdOut);
+                            }
+                        } catch (Exception e) {
+                            if (task.isContinueOnError()) {
+                                output.append("Continuing on error: <").append(e.getMessage()).append('>');
+
+                                LOG.error("While running {} with args {}, continuing on error",
+                                        command.getLeft().getClass().getName(), command.getRight(), e);
+                            } else {
+                                error.set(Pair.of(command.getLeft().getClass().getName(), e));
+                            }
+                        }
+                        output.append("\n\n");
+                    }
+
+                    return error;
+                }));
+
+        try {
+            AtomicReference<Pair<String, Exception>> error = future.get();
+            if (error.get() != null) {
+                throw new JobExecutionException("While running " + error.get().getLeft(), error.get().getRight());
+            }
+        } catch (ExecutionException | InterruptedException e) {
+            throw new JobExecutionException("While waiting for macro commands completion", e);
+        }
+
+        output.append("COMPLETED");
+
+        return actions.filter(a -> !dryRun).map(a -> a.afterAll(output)).orElse(output).toString();
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    protected String doExecute(final boolean dryRun, final String executor, final JobExecutionContext context)
+            throws JobExecutionException {
+
+        Optional<MacroActions> actions;
+        if (task.getMacroActions() == null) {
+            actions = Optional.empty();
+        } else {
+            try {
+                actions = Optional.of(ImplementationManager.build(
+                        task.getMacroActions(),
+                        () -> perContextActions.get(task.getMacroActions().getKey()),
+                        instance -> perContextActions.put(task.getMacroActions().getKey(), instance)));
+            } catch (Exception e) {
+                throw new JobExecutionException("Could not build " + task.getMacroActions().getKey(), e);
+            }
+        }
+
+        StringBuilder output = new StringBuilder();
+
+        SyncopeForm macroTaskForm = (SyncopeForm) context.getData().get(MACRO_TASK_FORM_JOBDETAIL_KEY);
+        Optional<JexlContext> jexlContext = check(macroTaskForm, actions, output);
+
+        if (!dryRun) {
+            actions.ifPresent(MacroActions::beforeAll);
+        }
+
+        List<Pair<Command<CommandArgs>, CommandArgs>> commands = new ArrayList<>();
+        for (MacroTaskCommand command : task.getCommands()) {
+            Command<CommandArgs> runnable;
+            try {
+                runnable = (Command<CommandArgs>) ImplementationManager.build(
+                        command.getCommand(),
+                        () -> perContextCommands.get(command.getCommand().getKey()),
+                        instance -> perContextCommands.put(command.getCommand().getKey(), instance));
+            } catch (Exception e) {
+                throw new JobExecutionException("Could not build " + command.getCommand().getKey(), e);
+            }
+
+            CommandArgs args;
+            if (command.getArgs() == null) {
+                try {
+                    args = ImplementationManager.emptyArgs(command.getCommand());
+                } catch (Exception e) {
+                    throw new JobExecutionException("While getting empty args from " + command.getKey(), e);
+                }
+            } else {
+                args = command.getArgs();
+
+                jexlContext.ifPresent(ctx -> ReflectionUtils.doWithFields(
+                        args.getClass(),
+                        field -> {
+                            if (String.class.equals(field.getType())) {
+                                field.setAccessible(true);
+                                Object value = field.get(args);
+                                if (value instanceof String) {
+                                    field.set(args, JexlUtils.evaluateTemplate((String) value, ctx));
+                                }
+                            }
+                        },
+                        field -> !field.isSynthetic()));
+
+                Set<ConstraintViolation<Object>> violations = validator.validate(args);
+                if (!violations.isEmpty()) {
+                    LOG.error("While validating {}: {}", args, violations);
+
+                    throw new JobExecutionException(
+                            "While running " + command.getKey(),
+                            new IllegalArgumentException(args.getClass().getName()));
+                }
+            }
+
+            commands.add(Pair.of(runnable, args));
+        }
+
+        return run(commands, actions, output, dryRun);
+    }
+
+    @Override
+    protected boolean hasToBeRegistered(final TaskExec<?> execution) {
+        return task.isSaveExecs();
+    }
+}
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java
index 575b812..d6b88d1 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java
@@ -18,7 +18,6 @@
  */
 package org.apache.syncope.core.provisioning.java.notification;
 
-import java.io.StringWriter;
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -27,6 +26,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.apache.commons.jexl3.JexlContext;
 import org.apache.commons.jexl3.MapContext;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
@@ -231,6 +231,7 @@
         jexlVars.put("recipients", recipientTOs);
         jexlVars.put("syncopeConf", confParamOps.list(SyncopeConstants.MASTER_DOMAIN));
         jexlVars.put("events", notification.getEvents());
+        JexlContext ctx = new MapContext(jexlVars);
 
         NotificationTask task = entityFactory.newEntity(NotificationTask.class);
         task.setNotification(notification);
@@ -244,23 +245,15 @@
         task.setSubject(notification.getSubject());
 
         if (StringUtils.isNotBlank(notification.getTemplate().getTextTemplate())) {
-            task.setTextBody(evaluate(notification.getTemplate().getTextTemplate(), jexlVars));
+            task.setTextBody(JexlUtils.evaluateTemplate(notification.getTemplate().getTextTemplate(), ctx));
         }
         if (StringUtils.isNotBlank(notification.getTemplate().getHTMLTemplate())) {
-            task.setHtmlBody(evaluate(notification.getTemplate().getHTMLTemplate(), jexlVars));
+            task.setHtmlBody(JexlUtils.evaluateTemplate(notification.getTemplate().getHTMLTemplate(), ctx));
         }
 
         return task;
     }
 
-    protected static String evaluate(final String template, final Map<String, Object> jexlVars) {
-        StringWriter writer = new StringWriter();
-        JexlUtils.newJxltEngine().
-                createTemplate(template).
-                evaluate(new MapContext(jexlVars), writer);
-        return writer.toString();
-    }
-
     @Override
     public boolean notificationsAvailable(
             final String domain,
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/propagation/LDAPMembershipPropagationActions.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/propagation/LDAPMembershipPropagationActions.java
index d071bf0..fda71b0 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/propagation/LDAPMembershipPropagationActions.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/propagation/LDAPMembershipPropagationActions.java
@@ -92,7 +92,7 @@
         JexlUtils.addPlainAttrsToContext(group.getPlainAttrs(), jexlContext);
         JexlUtils.addDerAttrsToContext(group, derAttrHandler, jexlContext);
 
-        return JexlUtils.evaluate(connObjectLinkTemplate, jexlContext).toString();
+        return JexlUtils.evaluateExpr(connObjectLinkTemplate, jexlContext).toString();
     }
 
     protected void buildManagedGroupConnObjectLinks(
diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/utils/TemplateUtils.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/utils/TemplateUtils.java
index 6834520..b9260f4 100644
--- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/utils/TemplateUtils.java
+++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/utils/TemplateUtils.java
@@ -92,7 +92,7 @@
 
         if (template.getValues() != null && !template.getValues().isEmpty()) {
             template.getValues().forEach(value -> {
-                String evaluated = JexlUtils.evaluate(value, jexlContext).toString();
+                String evaluated = JexlUtils.evaluateExpr(value, jexlContext).toString();
                 if (StringUtils.isNotBlank(evaluated)) {
                     result.getValues().add(evaluated);
                 }
@@ -110,7 +110,7 @@
         JexlUtils.addAttrsToContext(realmMember.getVirAttrs(), jexlContext);
 
         if (template.getRealm() != null) {
-            String evaluated = JexlUtils.evaluate(template.getRealm(), jexlContext).toString();
+            String evaluated = JexlUtils.evaluateExpr(template.getRealm(), jexlContext).toString();
             if (StringUtils.isNotBlank(evaluated)) {
                 realmMember.setRealm(evaluated);
             }
@@ -196,7 +196,7 @@
 
             case UserTO userTO -> {
                 if (StringUtils.isNotBlank(userTO.getUsername())) {
-                    String evaluated = JexlUtils.evaluate(userTO.getUsername(), jexlContext).toString();
+                    String evaluated = JexlUtils.evaluateExpr(userTO.getUsername(), jexlContext).toString();
                     if (StringUtils.isNotBlank(evaluated)) {
                         switch (realmMember) {
                             case UserTO urm ->
@@ -210,7 +210,7 @@
                 }
 
                 if (StringUtils.isNotBlank(userTO.getPassword())) {
-                    String evaluated = JexlUtils.evaluate(userTO.getPassword(), jexlContext).toString();
+                    String evaluated = JexlUtils.evaluateExpr(userTO.getPassword(), jexlContext).toString();
                     if (StringUtils.isNotBlank(evaluated)) {
                         switch (realmMember) {
                             case UserTO urm ->
@@ -262,7 +262,7 @@
 
             case GroupTO groupTO -> {
                 if (StringUtils.isNotBlank(groupTO.getName())) {
-                    String evaluated = JexlUtils.evaluate(groupTO.getName(), jexlContext).toString();
+                    String evaluated = JexlUtils.evaluateExpr(groupTO.getName(), jexlContext).toString();
                     if (StringUtils.isNotBlank(evaluated)) {
                         switch (realmMember) {
                             case GroupTO grm ->
diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityContext.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityContext.java
index 16dc35e..01be169 100644
--- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityContext.java
+++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityContext.java
@@ -34,10 +34,12 @@
 import org.apache.syncope.core.spring.security.jws.AccessTokenJWSVerifier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Role;
 import org.springframework.security.config.core.GrantedAuthorityDefaults;
 
 @EnableConfigurationProperties(SecurityProperties.class)
@@ -46,17 +48,13 @@
 
     private static final Logger LOG = LoggerFactory.getLogger(SecurityContext.class);
 
+    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
     @Bean
-    public CipherAlgorithm adminPasswordAlgorithm(final SecurityProperties props) {
-        return props.getAdminPasswordAlgorithm();
+    public static GrantedAuthorityDefaults grantedAuthorityDefaults() {
+        return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
     }
 
-    @Bean
-    public JWSAlgorithm jwsAlgorithm(final SecurityProperties props) {
-        return JWSAlgorithm.parse(props.getJwsAlgorithm().toUpperCase());
-    }
-
-    private static String jwsKey(final JWSAlgorithm jwsAlgorithm, final SecurityProperties props) {
+    protected static String jwsKey(final JWSAlgorithm jwsAlgorithm, final SecurityProperties props) {
         String jwsKey = props.getJwsKey();
         if (jwsKey == null) {
             throw new IllegalArgumentException("No JWS key provided");
@@ -79,6 +77,16 @@
         return jwsKey;
     }
 
+    @Bean
+    public CipherAlgorithm adminPasswordAlgorithm(final SecurityProperties props) {
+        return props.getAdminPasswordAlgorithm();
+    }
+
+    @Bean
+    public JWSAlgorithm jwsAlgorithm(final SecurityProperties props) {
+        return JWSAlgorithm.parse(props.getJwsAlgorithm().toUpperCase());
+    }
+
     @ConditionalOnMissingBean
     @Bean
     public DefaultCredentialChecker credentialChecker(
@@ -135,11 +143,6 @@
     }
 
     @Bean
-    public GrantedAuthorityDefaults grantedAuthorityDefaults() {
-        return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
-    }
-
-    @Bean
     public ApplicationContextProvider applicationContextProvider() {
         return new ApplicationContextProvider();
     }
diff --git a/ext/flowable/client-common-ui/pom.xml b/ext/flowable/client-common-ui/pom.xml
deleted file mode 100644
index 7acb692..0000000
--- a/ext/flowable/client-common-ui/pom.xml
+++ /dev/null
@@ -1,66 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Licensed to the Apache Software Foundation (ASF) under one
-or more contributor license agreements.  See the NOTICE file
-distributed with this work for additional information
-regarding copyright ownership.  The ASF 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.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-
-  <modelVersion>4.0.0</modelVersion>
-  
-  <parent>
-    <groupId>org.apache.syncope.ext</groupId>
-    <artifactId>syncope-ext-flowable</artifactId>
-    <version>4.0.0-SNAPSHOT</version>
-  </parent>
-    
-  <name>Apache Syncope Ext: Flowable Client Common UI</name>
-  <description>Apache Syncope Ext: Flowable Client Common UI</description>
-  <groupId>org.apache.syncope.ext.flowable</groupId>
-  <artifactId>syncope-ext-flowable-client-common-ui</artifactId>
-  <packaging>jar</packaging>
-  
-  <properties>
-    <rootpom.basedir>${basedir}/../../..</rootpom.basedir>
-  </properties>
-  
-  <dependencies>
-    <dependency>
-      <groupId>org.apache.syncope.client.idrepo</groupId>
-      <artifactId>syncope-client-idrepo-common-ui</artifactId>      
-      <version>${project.version}</version>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.syncope.ext.flowable</groupId>
-      <artifactId>syncope-ext-flowable-common-lib</artifactId>
-      <version>${project.version}</version>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.syncope.ext.flowable</groupId>
-      <artifactId>syncope-ext-flowable-rest-api</artifactId>
-      <version>${project.version}</version>
-    </dependency>
-  </dependencies>
-  
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-checkstyle-plugin</artifactId>
-      </plugin>
-    </plugins>
-  </build>
-</project>
diff --git a/ext/flowable/client-console/pom.xml b/ext/flowable/client-console/pom.xml
index 17ec624..2a0fe86 100644
--- a/ext/flowable/client-console/pom.xml
+++ b/ext/flowable/client-console/pom.xml
@@ -47,11 +47,6 @@
 
     <dependency>
       <groupId>org.apache.syncope.ext.flowable</groupId>
-      <artifactId>syncope-ext-flowable-client-common-ui</artifactId>
-      <version>${project.version}</version>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.syncope.ext.flowable</groupId>
       <artifactId>syncope-ext-flowable-rest-api</artifactId>
       <version>${project.version}</version>
     </dependency>
diff --git a/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormDirectoryPanel.java b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormDirectoryPanel.java
index 1db22cf..ff5264f 100644
--- a/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormDirectoryPanel.java
+++ b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormDirectoryPanel.java
@@ -214,8 +214,8 @@
             public void onClick(final AjaxRequestTarget target, final UserRequestForm ignore) {
                 manageFormModal.setFormModel(new CompoundPropertyModel<>(model.getObject()));
 
-                target.add(manageFormModal.setContent(new UserRequestFormModal(manageFormModal, pageRef, model.
-                        getObject()) {
+                target.add(manageFormModal.setContent(
+                        new UserRequestFormModal(manageFormModal, pageRef, model.getObject()) {
 
                     private static final long serialVersionUID = 5546519445061007248L;
 
diff --git a/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormModal.java b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormModal.java
index 622b2c2..177fdf1 100644
--- a/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormModal.java
+++ b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormModal.java
@@ -26,7 +26,6 @@
 import org.apache.syncope.client.ui.commons.panels.SubmitableModalPanel;
 import org.apache.syncope.client.ui.commons.panels.WizardModalPanel;
 import org.apache.syncope.common.lib.to.UserRequestForm;
-import org.apache.syncope.ext.client.common.ui.panels.UserRequestFormPanel;
 import org.apache.wicket.PageReference;
 import org.apache.wicket.ajax.AjaxRequestTarget;
 import org.apache.wicket.markup.html.panel.Panel;
diff --git a/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormPanel.java b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormPanel.java
new file mode 100644
index 0000000..78f64fa
--- /dev/null
+++ b/ext/flowable/client-console/src/main/java/org/apache/syncope/client/console/panels/UserRequestFormPanel.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.client.console.panels;
+
+import org.apache.syncope.client.ui.commons.panels.SyncopeFormPanel;
+import org.apache.syncope.common.lib.to.UserRequestForm;
+import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.authroles.authorization.strategies.role.metadata.MetaDataRoleAuthorizationStrategy;
+
+public abstract class UserRequestFormPanel extends SyncopeFormPanel<UserRequestForm> {
+
+    private static final long serialVersionUID = 6064351260702815499L;
+
+    public UserRequestFormPanel(final String id, final UserRequestForm form) {
+        super(id, form);
+
+        AjaxLink<String> userDetails = new AjaxLink<>("userDetails") {
+
+            private static final long serialVersionUID = -4804368561204623354L;
+
+            @Override
+            public void onClick(final AjaxRequestTarget target) {
+                viewDetails(target);
+            }
+        };
+        MetaDataRoleAuthorizationStrategy.authorize(userDetails, ENABLE, IdRepoEntitlement.USER_READ);
+
+        boolean enabled = form.getUserTO() != null;
+        userDetails.setVisible(enabled).setEnabled(enabled);
+
+        add(userDetails);
+    }
+
+    protected abstract void viewDetails(AjaxRequestTarget target);
+}
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.html
similarity index 89%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.html
index aef81e5..c5a8c98 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.html
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.html
@@ -17,15 +17,11 @@
 under the License.
 -->
 <html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
-  <wicket:panel>
-    <div wicket:id="propView">
-      <span wicket:id="value">[value]</span>
-    </div>
-
+  <wicket:extend>
     <div style="margin: 20px 0">
       <a href="#" alt="user details" class="btn btn-success btn-circle btn-lg" wicket:id="userDetails" wicket:message="title:userDetails">
         <i class="fas fa-eye"></i>
       </a>
     </div>
-  </wicket:panel>
+  </wicket:extend>
 </html>
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.properties
similarity index 97%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.properties
index 450ff50..5d29cc4 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel.properties
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel.properties
@@ -15,4 +15,3 @@
 # specific language governing permissions and limitations
 # under the License.
 userDetails=User details
-userForm=Edit User
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_it.properties b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_it.properties
similarity index 96%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_it.properties
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_it.properties
index 92c475d..b96f57c 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_it.properties
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_it.properties
@@ -15,4 +15,3 @@
 # specific language governing permissions and limitations
 # under the License.
 userDetails=Dettagli utente
-userForm=Modifica utente
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ja.properties
similarity index 93%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ja.properties
index 5a9cc2d..93119db 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ja.properties
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ja.properties
@@ -15,4 +15,3 @@
 # specific language governing permissions and limitations
 # under the License.
 userDetails=\u30e6\u30fc\u30b6\u30fc\u8a73\u7d30
-userForm=\u30e6\u30fc\u30b6\u30fc\u3092\u7de8\u96c6
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_pt_BR.properties b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_pt_BR.properties
similarity index 95%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_pt_BR.properties
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_pt_BR.properties
index 00a8971..b2b000a 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_pt_BR.properties
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_pt_BR.properties
@@ -15,4 +15,3 @@
 # specific language governing permissions and limitations
 # under the License.
 userDetails=Detalhes do Usu\u00e1rio
-userForm=Detalhes do Usu\u00e1rio
diff --git a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ru.properties b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ru.properties
similarity index 88%
rename from ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ru.properties
rename to ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ru.properties
index 02c159a..c82ab29 100644
--- a/ext/flowable/client-common-ui/src/main/resources/org/apache/syncope/ext/client/common/ui/panels/UserRequestFormPanel_ru.properties
+++ b/ext/flowable/client-console/src/main/resources/org/apache/syncope/client/console/panels/UserRequestFormPanel_ru.properties
@@ -17,4 +17,3 @@
 #
 # userDetails=\u00d0\u0098\u00d0\u00bd\u00d1\u0084\u00d0\u00be\u00d1\u0080\u00d0\u00bc\u00d0\u00b0\u00d1\u0086\u00d0\u00b8\u00d1\u008f \u00d0\u00be \u00d0\u00bf\u00d0\u00be\u00d0\u00bb\u00d1\u008c\u00d0\u00b7\u00d0\u00be\u00d0\u00b2\u00d0\u00b0\u00d1\u0082\u00d0\u00b5\u00d0\u00bb\u00d0\u00b5
 userDetails=\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435
-userForm=\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435
diff --git a/ext/flowable/client-enduser/pom.xml b/ext/flowable/client-enduser/pom.xml
index 9f57116..a2ed5b1 100644
--- a/ext/flowable/client-enduser/pom.xml
+++ b/ext/flowable/client-enduser/pom.xml
@@ -38,11 +38,6 @@
   </properties>
   
   <dependencies>
-     <dependency>
-      <groupId>org.apache.syncope.ext.flowable</groupId>
-      <artifactId>syncope-ext-flowable-client-common-ui</artifactId>
-      <version>${project.version}</version>
-    </dependency>
     <dependency>
       <groupId>org.apache.syncope.ext.flowable</groupId>
       <artifactId>syncope-ext-flowable-rest-api</artifactId>
diff --git a/ext/flowable/client-enduser/src/main/java/org/apache/syncope/client/enduser/panels/UserRequestDetails.java b/ext/flowable/client-enduser/src/main/java/org/apache/syncope/client/enduser/panels/UserRequestDetails.java
index c55907b..ae03ab3 100644
--- a/ext/flowable/client-enduser/src/main/java/org/apache/syncope/client/enduser/panels/UserRequestDetails.java
+++ b/ext/flowable/client-enduser/src/main/java/org/apache/syncope/client/enduser/panels/UserRequestDetails.java
@@ -21,13 +21,13 @@
 import org.apache.syncope.client.enduser.SyncopeEnduserSession;
 import org.apache.syncope.client.enduser.rest.UserRequestRestClient;
 import org.apache.syncope.client.ui.commons.panels.NotificationPanel;
+import org.apache.syncope.client.ui.commons.panels.SyncopeFormPanel;
 import org.apache.syncope.common.lib.SyncopeClientException;
 import org.apache.syncope.common.lib.to.ProvisioningResult;
 import org.apache.syncope.common.lib.to.UserRequest;
 import org.apache.syncope.common.lib.to.UserRequestForm;
 import org.apache.syncope.common.lib.to.UserTO;
 import org.apache.syncope.common.lib.types.ExecStatus;
-import org.apache.syncope.ext.client.common.ui.panels.UserRequestFormPanel;
 import org.apache.wicket.ajax.AjaxRequestTarget;
 import org.apache.wicket.ajax.markup.html.AjaxLink;
 import org.apache.wicket.ajax.markup.html.form.AjaxButton;
@@ -72,15 +72,7 @@
         } else {
             Form<Void> form = new Form<>("userRequestWrapForm");
 
-            form.add(new UserRequestFormPanel("userRequestFormPanel", formTO, false) {
-
-                private static final long serialVersionUID = 3617895525072546591L;
-
-                @Override
-                protected void viewDetails(final AjaxRequestTarget target) {
-                    // do nothing
-                }
-            });
+            form.add(new SyncopeFormPanel<>("userRequestFormPanel", formTO));
 
             form.add(new AjaxButton("submit") {
 
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequest.java b/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequest.java
index 229859c..cb0212b 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequest.java
+++ b/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequest.java
@@ -18,26 +18,16 @@
  */
 package org.apache.syncope.common.lib.to;
 
-import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.annotation.JsonPropertyOrder;
-import com.fasterxml.jackson.annotation.JsonTypeInfo;
-import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
 import java.util.Date;
 import java.util.Optional;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
 import org.apache.syncope.common.lib.BaseBean;
 
-@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "_class")
-@JsonPropertyOrder(value = { "_class", "bpmnProcess" })
 public class UserRequest implements BaseBean {
 
     private static final long serialVersionUID = -8430826310789942133L;
 
-    @JacksonXmlProperty(localName = "_class", isAttribute = true)
-    @JsonProperty("_class")
-    private final String clazz = "org.apache.syncope.common.lib.to.UserRequest";
-
     private String bpmnProcess;
 
     private Date startTime;
diff --git a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestForm.java b/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestForm.java
index 7a6d769..5f51fe5 100644
--- a/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestForm.java
+++ b/ext/flowable/common-lib/src/main/java/org/apache/syncope/common/lib/to/UserRequestForm.java
@@ -18,31 +18,17 @@
  */
 package org.apache.syncope.common.lib.to;
 
-import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.annotation.JsonPropertyOrder;
-import com.fasterxml.jackson.annotation.JsonTypeInfo;
-import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
-import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
-import java.util.ArrayList;
 import java.util.Date;
-import java.util.List;
 import java.util.Optional;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
-import org.apache.syncope.common.lib.BaseBean;
+import org.apache.syncope.common.lib.form.SyncopeForm;
 import org.apache.syncope.common.lib.request.UserUR;
 
-@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "_class")
-@JsonPropertyOrder(value = { "_class", "bpmnProcess" })
-public class UserRequestForm implements BaseBean {
+public class UserRequestForm extends SyncopeForm {
 
     private static final long serialVersionUID = -7044543391316529128L;
 
-    @JacksonXmlProperty(localName = "_class", isAttribute = true)
-    @JsonProperty("_class")
-    private final String clazz = "org.apache.syncope.common.lib.to.UserRequestForm";
-
     private String bpmnProcess;
 
     private String username;
@@ -63,8 +49,6 @@
 
     private UserUR userUR;
 
-    private final List<UserRequestFormProperty> properties = new ArrayList<>();
-
     public String getBpmnProcess() {
         return bpmnProcess;
     }
@@ -145,20 +129,10 @@
         this.userUR = userUR;
     }
 
-    @JsonIgnore
-    public Optional<UserRequestFormProperty> getProperty(final String id) {
-        return properties.stream().filter(property -> id.equals(property.getId())).findFirst();
-    }
-
-    @JacksonXmlElementWrapper(localName = "properties")
-    @JacksonXmlProperty(localName = "property")
-    public List<UserRequestFormProperty> getProperties() {
-        return properties;
-    }
-
     @Override
     public int hashCode() {
         return new HashCodeBuilder().
+                appendSuper(super.hashCode()).
                 append(bpmnProcess).
                 append(username).
                 append(executionId).
@@ -169,7 +143,6 @@
                 append(assignee).
                 append(userTO).
                 append(userUR).
-                append(properties).
                 build();
     }
 
@@ -186,6 +159,7 @@
         }
         UserRequestForm other = (UserRequestForm) obj;
         return new EqualsBuilder().
+                appendSuper(super.equals(obj)).
                 append(bpmnProcess, other.bpmnProcess).
                 append(username, other.username).
                 append(executionId, other.executionId).
@@ -196,7 +170,6 @@
                 append(assignee, other.assignee).
                 append(userTO, other.userTO).
                 append(userUR, other.userUR).
-                append(properties, other.properties).
                 build();
     }
 }
diff --git a/ext/flowable/common-lib/src/test/java/org/apache/syncope/common/lib/to/SerializationTest.java b/ext/flowable/common-lib/src/test/java/org/apache/syncope/common/lib/to/SerializationTest.java
index eb7e2f0..d4f1fc8 100644
--- a/ext/flowable/common-lib/src/test/java/org/apache/syncope/common/lib/to/SerializationTest.java
+++ b/ext/flowable/common-lib/src/test/java/org/apache/syncope/common/lib/to/SerializationTest.java
@@ -27,9 +27,11 @@
 import java.util.Date;
 import java.util.UUID;
 import org.apache.syncope.common.lib.Attr;
+import org.apache.syncope.common.lib.form.FormProperty;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.common.lib.form.FormPropertyValue;
 import org.apache.syncope.common.lib.request.AttrPatch;
 import org.apache.syncope.common.lib.request.UserUR;
-import org.apache.syncope.common.lib.types.UserRequestFormPropertyType;
 import org.junit.jupiter.api.Test;
 
 public abstract class SerializationTest {
@@ -54,14 +56,14 @@
         userUR.getPlainAttrs().add(new AttrPatch.Builder(new Attr.Builder("schema1").value("value1").build()).build());
         form.setUserUR(userUR);
 
-        UserRequestFormProperty property = new UserRequestFormProperty();
+        FormProperty property = new FormProperty();
         property.setId("printMode");
         property.setName("Preferred print mode");
-        property.setType(UserRequestFormPropertyType.Dropdown);
+        property.setType(FormPropertyType.Dropdown);
         property.getDropdownValues().add(
-                new UserRequestFormPropertyValue("8559d14d-58c2-46eb-a2d4-a7d35161e8f8", "value1"));
+                new FormPropertyValue("8559d14d-58c2-46eb-a2d4-a7d35161e8f8", "value1"));
         property.getDropdownValues().add(
-                new UserRequestFormPropertyValue(UUID.randomUUID().toString(), "value2 / value3"));
+                new FormPropertyValue(UUID.randomUUID().toString(), "value2 / value3"));
         form.getProperties().add(property);
 
         PagedResult<UserRequestForm> original = new PagedResult<>();
@@ -72,9 +74,8 @@
         StringWriter writer = new StringWriter();
         objectMapper().writeValue(writer, original);
 
-        PagedResult<UserRequestForm> actual = objectMapper().readValue(writer.toString(),
-            new TypeReference<>() {
-            });
+        PagedResult<UserRequestForm> actual = objectMapper().readValue(writer.toString(), new TypeReference<>() {
+        });
         assertEquals(original, actual);
     }
 }
diff --git a/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/impl/FlowableUserRequestHandler.java b/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/impl/FlowableUserRequestHandler.java
index ae56ebb..5134570 100644
--- a/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/impl/FlowableUserRequestHandler.java
+++ b/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/impl/FlowableUserRequestHandler.java
@@ -28,16 +28,16 @@
 import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.syncope.common.lib.form.FormProperty;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.common.lib.form.FormPropertyValue;
 import org.apache.syncope.common.lib.request.PasswordPatch;
 import org.apache.syncope.common.lib.request.UserUR;
 import org.apache.syncope.common.lib.to.UserRequest;
 import org.apache.syncope.common.lib.to.UserRequestForm;
-import org.apache.syncope.common.lib.to.UserRequestFormProperty;
-import org.apache.syncope.common.lib.to.UserRequestFormPropertyValue;
 import org.apache.syncope.common.lib.to.UserTO;
 import org.apache.syncope.common.lib.to.WorkflowTaskExecInput;
 import org.apache.syncope.common.lib.types.ResourceOperation;
-import org.apache.syncope.common.lib.types.UserRequestFormPropertyType;
 import org.apache.syncope.core.flowable.api.DropdownValueProvider;
 import org.apache.syncope.core.flowable.api.UserRequestHandler;
 import org.apache.syncope.core.flowable.support.DomainProcessEngine;
@@ -56,7 +56,6 @@
 import org.apache.syncope.core.workflow.api.WorkflowException;
 import org.flowable.common.engine.api.FlowableException;
 import org.flowable.common.engine.api.FlowableIllegalArgumentException;
-import org.flowable.engine.form.FormProperty;
 import org.flowable.engine.form.FormType;
 import org.flowable.engine.form.TaskFormData;
 import org.flowable.engine.history.HistoricActivityInstance;
@@ -275,33 +274,33 @@
         }
     }
 
-    protected static UserRequestFormPropertyType fromFlowableFormType(final FormType flowableFormType) {
-        UserRequestFormPropertyType result = UserRequestFormPropertyType.String;
+    protected static FormPropertyType fromFlowableFormType(final FormType flowableFormType) {
+        FormPropertyType result = FormPropertyType.String;
 
         if (null != flowableFormType.getName()) {
             switch (flowableFormType.getName()) {
                 case "long":
-                    result = UserRequestFormPropertyType.Long;
+                    result = FormPropertyType.Long;
                     break;
 
                 case "enum":
-                    result = UserRequestFormPropertyType.Enum;
+                    result = FormPropertyType.Enum;
                     break;
 
                 case "date":
-                    result = UserRequestFormPropertyType.Date;
+                    result = FormPropertyType.Date;
                     break;
 
                 case "boolean":
-                    result = UserRequestFormPropertyType.Boolean;
+                    result = FormPropertyType.Boolean;
                     break;
 
                 case "dropdown":
-                    result = UserRequestFormPropertyType.Dropdown;
+                    result = FormPropertyType.Dropdown;
                     break;
 
                 case "password":
-                    result = UserRequestFormPropertyType.Password;
+                    result = FormPropertyType.Password;
                     break;
 
                 case "string":
@@ -389,7 +388,7 @@
                 getVariable(procInstId, FlowableRuntimeUtils.USER_UR, UserUR.class));
 
         formTO.getProperties().addAll(props.stream().map(prop -> {
-            UserRequestFormProperty propertyTO = new UserRequestFormProperty();
+            FormProperty propertyTO = new FormProperty();
             propertyTO.setId(prop.getPropertyId());
             propertyTO.setName(prop.getPropertyId());
             propertyTO.setValue(prop.getPropertyValue());
@@ -404,7 +403,7 @@
             final String procInstId,
             final String taskId,
             final String formKey,
-            final List<FormProperty> props) {
+            final List<org.flowable.engine.form.FormProperty> props) {
 
         UserRequestForm formTO = new UserRequestForm();
 
@@ -424,7 +423,7 @@
                 getVariable(procInstId, FlowableRuntimeUtils.USER_UR, UserUR.class));
 
         formTO.getProperties().addAll(props.stream().map(fProp -> {
-            UserRequestFormProperty propertyTO = new UserRequestFormProperty();
+            FormProperty propertyTO = new FormProperty();
             propertyTO.setId(fProp.getId());
             propertyTO.setName(fProp.getName());
             propertyTO.setReadable(fProp.isReadable());
@@ -438,8 +437,7 @@
 
                 case Enum ->
                     ((Map<String, String>) fProp.getType().getInformation("values")).
-                            forEach((key, value) -> propertyTO.getEnumValues().add(
-                            new UserRequestFormPropertyValue(key, value)));
+                            forEach((key, value) -> propertyTO.getEnumValues().add(new FormPropertyValue(key, value)));
 
                 case Dropdown -> {
                     String valueProviderBean = (String) fProp.getType().getInformation(DropdownValueProvider.NAME);
@@ -447,7 +445,7 @@
                         DropdownValueProvider valueProvider = ApplicationContextProvider.getApplicationContext().
                                 getBean(valueProviderBean, DropdownValueProvider.class);
                         valueProvider.getValues().forEach((key, value) -> propertyTO.getDropdownValues().add(
-                                new UserRequestFormPropertyValue(key, value)));
+                                new FormPropertyValue(key, value)));
                     } catch (Exception e) {
                         LOG.error("Could not find bean {} of type {} for form property {}",
                                 valueProviderBean, DropdownValueProvider.class.getName(), propertyTO.getId(), e);
@@ -623,7 +621,7 @@
     protected Map<String, String> getPropertiesForSubmit(final UserRequestForm form) {
         Map<String, String> props = new HashMap<>();
         form.getProperties().stream().
-                filter(UserRequestFormProperty::isWritable).
+                filter(FormProperty::isWritable).
                 forEach(prop -> props.put(prop.getId(), prop.getValue()));
         return Collections.unmodifiableMap(props);
     }
diff --git a/ext/flowable/pom.xml b/ext/flowable/pom.xml
index d12afd3..dabc6cf 100644
--- a/ext/flowable/pom.xml
+++ b/ext/flowable/pom.xml
@@ -45,7 +45,6 @@
     <module>flowable-bpmn</module>
     <module>client-console</module>
     <module>client-enduser</module>
-    <module>client-common-ui</module>
   </modules>
 
 </project>
diff --git a/ext/scimv2/logic/src/main/java/org/apache/syncope/core/logic/SCIMDataBinder.java b/ext/scimv2/logic/src/main/java/org/apache/syncope/core/logic/SCIMDataBinder.java
index 41feaaa..f39f839 100644
--- a/ext/scimv2/logic/src/main/java/org/apache/syncope/core/logic/SCIMDataBinder.java
+++ b/ext/scimv2/logic/src/main/java/org/apache/syncope/core/logic/SCIMDataBinder.java
@@ -714,7 +714,7 @@
             final SCIMPatchOperation op) {
 
         confs.stream().
-                filter(conf -> BooleanUtils.toBoolean(JexlUtils.evaluate(
+                filter(conf -> BooleanUtils.toBoolean(JexlUtils.evaluateExpr(
                 filter2JexlExpression(op.getPath().getFilter()),
                 new MapContext(Map.of("type", conf.getType().name()))).toString())).findFirst().
                 ifPresent(conf -> {
@@ -936,7 +936,7 @@
                             ifPresent(addressConf -> setAttribute(userUR.getPlainAttrs(), addressConf, op)));
                 } else if (op.getPath().getFilter() != null) {
                     conf.getUserConf().getAddresses().stream().
-                            filter(addressConf -> BooleanUtils.toBoolean(JexlUtils.evaluate(
+                            filter(addressConf -> BooleanUtils.toBoolean(JexlUtils.evaluateExpr(
                             filter2JexlExpression(op.getPath().getFilter()),
                             new MapContext(Map.of("type", addressConf.getType().name()))).toString())).findFirst().
                             ifPresent(addressConf -> setAttribute(userUR.getPlainAttrs(), addressConf, op));
diff --git a/ext/scimv2/scim-rest-cxf/src/main/java/org/apache/syncope/ext/scimv2/cxf/service/SCIMGroupServiceImpl.java b/ext/scimv2/scim-rest-cxf/src/main/java/org/apache/syncope/ext/scimv2/cxf/service/SCIMGroupServiceImpl.java
index d26defa..9aabf25 100644
--- a/ext/scimv2/scim-rest-cxf/src/main/java/org/apache/syncope/ext/scimv2/cxf/service/SCIMGroupServiceImpl.java
+++ b/ext/scimv2/scim-rest-cxf/src/main/java/org/apache/syncope/ext/scimv2/cxf/service/SCIMGroupServiceImpl.java
@@ -151,7 +151,7 @@
                 if (CollectionUtils.isEmpty(op.getValue())) {
                     members(id).stream().filter(member -> op.getPath().getFilter() == null
                             ? true
-                            : BooleanUtils.toBoolean(JexlUtils.evaluate(
+                            : BooleanUtils.toBoolean(JexlUtils.evaluateExpr(
                                     SCIMDataBinder.filter2JexlExpression(op.getPath().getFilter()),
                                     new MapContext(Map.of("value", member))).toString())).
                             forEach(member -> changeMembership(member, id, op.getOp()));
diff --git a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/ITImplementationLookup.java b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/ITImplementationLookup.java
index aec8c1e..c851145 100644
--- a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/ITImplementationLookup.java
+++ b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/ITImplementationLookup.java
@@ -36,7 +36,6 @@
 import org.apache.syncope.common.lib.report.ReportConf;
 import org.apache.syncope.common.lib.types.IdMImplementationType;
 import org.apache.syncope.common.lib.types.IdRepoImplementationType;
-import org.apache.syncope.core.logic.job.MacroRunJobDelegate;
 import org.apache.syncope.core.persistence.api.DomainHolder;
 import org.apache.syncope.core.persistence.common.attrvalue.AlwaysTrueValidator;
 import org.apache.syncope.core.persistence.common.attrvalue.BasicValidator;
@@ -50,6 +49,7 @@
 import org.apache.syncope.core.provisioning.api.rules.PushCorrelationRule;
 import org.apache.syncope.core.provisioning.java.job.ExpiredAccessTokenCleanup;
 import org.apache.syncope.core.provisioning.java.job.ExpiredBatchCleanup;
+import org.apache.syncope.core.provisioning.java.job.MacroJobDelegate;
 import org.apache.syncope.core.provisioning.java.propagation.AzurePropagationActions;
 import org.apache.syncope.core.provisioning.java.propagation.DBPasswordPropagationActions;
 import org.apache.syncope.core.provisioning.java.propagation.GoogleAppsPropagationActions;
@@ -134,7 +134,7 @@
             put(IdRepoImplementationType.ITEM_TRANSFORMER, classNames);
 
             classNames = new HashSet<>();
-            classNames.add(MacroRunJobDelegate.class.getName());
+            classNames.add(MacroJobDelegate.class.getName());
             classNames.add(PullJobDelegate.class.getName());
             classNames.add(PushJobDelegate.class.getName());
             classNames.add(ExpiredAccessTokenCleanup.class.getName());
@@ -147,6 +147,9 @@
 
             classNames = new HashSet<>();
             put(IdRepoImplementationType.LOGIC_ACTIONS, classNames);
+            classNames = new HashSet<>();
+            classNames.add(TestMacroActions.class.getName());
+            put(IdRepoImplementationType.MACRO_ACTIONS, classNames);
 
             classNames = new HashSet<>();
             classNames.add(LDAPMembershipPropagationActions.class.getName());
@@ -179,7 +182,7 @@
             classNames.add(EmailAddressValidator.class.getName());
             classNames.add(AlwaysTrueValidator.class.getName());
             classNames.add(BinaryValidator.class.getName());
-            put(IdRepoImplementationType.VALIDATOR, classNames);
+            put(IdRepoImplementationType.ATTR_VALUE_VALIDATOR, classNames);
 
             classNames = new HashSet<>();
             classNames.add(TestNotificationRecipientsProvider.class.getName());
diff --git a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommand.java b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommand.java
index 2289bd5..462570a 100644
--- a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommand.java
+++ b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommand.java
@@ -25,7 +25,7 @@
 import org.apache.syncope.common.lib.to.RealmTO;
 import org.apache.syncope.core.logic.AnyObjectLogic;
 import org.apache.syncope.core.logic.RealmLogic;
-import org.apache.syncope.core.logic.api.Command;
+import org.apache.syncope.core.provisioning.api.macro.Command;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
diff --git a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommandArgs.java b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommandArgs.java
index 871e409..936b933 100644
--- a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommandArgs.java
+++ b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestCommandArgs.java
@@ -20,6 +20,7 @@
 
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotEmpty;
+import java.util.Objects;
 import org.apache.syncope.common.lib.command.CommandArgs;
 
 public class TestCommandArgs extends CommandArgs {
@@ -64,6 +65,36 @@
     }
 
     @Override
+    public int hashCode() {
+        int hash = 3;
+        hash = 89 * hash + Objects.hashCode(this.parentRealm);
+        hash = 89 * hash + Objects.hashCode(this.realmName);
+        hash = 89 * hash + Objects.hashCode(this.printerName);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final TestCommandArgs other = (TestCommandArgs) obj;
+        if (!Objects.equals(this.parentRealm, other.parentRealm)) {
+            return false;
+        }
+        if (!Objects.equals(this.realmName, other.realmName)) {
+            return false;
+        }
+        return Objects.equals(this.printerName, other.printerName);
+    }
+
+    @Override
     public String toString() {
         return "TestCommandArgs{"
                 + "parentRealm=" + parentRealm
diff --git a/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestMacroActions.java b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestMacroActions.java
new file mode 100644
index 0000000..ebaeada
--- /dev/null
+++ b/fit/core-reference/src/main/java/org/apache/syncope/fit/core/reference/TestMacroActions.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.fit.core.reference;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.syncope.core.persistence.api.dao.RealmDAO;
+import org.apache.syncope.core.persistence.api.dao.RealmSearchDAO;
+import org.apache.syncope.core.persistence.api.entity.Realm;
+import org.apache.syncope.core.provisioning.api.macro.MacroActions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+
+public class TestMacroActions implements MacroActions {
+
+    @Autowired
+    private RealmDAO realmDAO;
+
+    @Autowired
+    private RealmSearchDAO realmSearchDAO;
+
+    @Transactional(readOnly = true)
+    @Override
+    public Map<String, String> getDropdownValues(final String formProperty) {
+        return realmSearchDAO.findChildren(realmDAO.getRoot()).stream().
+                collect(Collectors.toMap(Realm::getFullPath, Realm::getName));
+    }
+}
diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroITCase.java
deleted file mode 100644
index 250d5d4..0000000
--- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroITCase.java
+++ /dev/null
@@ -1,174 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF 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.apache.syncope.fit.core;
-
-import static org.awaitility.Awaitility.await;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.fail;
-
-import jakarta.ws.rs.core.Response;
-import java.nio.charset.StandardCharsets;
-import java.util.concurrent.TimeUnit;
-import org.apache.commons.io.IOUtils;
-import org.apache.syncope.common.lib.SyncopeClientException;
-import org.apache.syncope.common.lib.command.CommandTO;
-import org.apache.syncope.common.lib.request.UserCR;
-import org.apache.syncope.common.lib.to.AnyObjectTO;
-import org.apache.syncope.common.lib.to.ExecTO;
-import org.apache.syncope.common.lib.to.ImplementationTO;
-import org.apache.syncope.common.lib.to.MacroTaskTO;
-import org.apache.syncope.common.lib.to.RoleTO;
-import org.apache.syncope.common.lib.to.UserTO;
-import org.apache.syncope.common.lib.types.ClientExceptionType;
-import org.apache.syncope.common.lib.types.ExecStatus;
-import org.apache.syncope.common.lib.types.IdRepoEntitlement;
-import org.apache.syncope.common.lib.types.IdRepoImplementationType;
-import org.apache.syncope.common.lib.types.ImplementationEngine;
-import org.apache.syncope.common.lib.types.TaskType;
-import org.apache.syncope.common.rest.api.RESTHeaders;
-import org.apache.syncope.common.rest.api.beans.ExecSpecs;
-import org.apache.syncope.common.rest.api.beans.RealmQuery;
-import org.apache.syncope.common.rest.api.beans.TaskQuery;
-import org.apache.syncope.common.rest.api.service.TaskService;
-import org.apache.syncope.fit.AbstractITCase;
-import org.apache.syncope.fit.core.reference.TestCommand;
-import org.apache.syncope.fit.core.reference.TestCommandArgs;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-
-public class MacroITCase extends AbstractITCase {
-
-    private static String MACRO_TASK_KEY;
-
-    private static final TestCommandArgs TCA = new TestCommandArgs();
-
-    static {
-        TCA.setParentRealm("/odd");
-        TCA.setRealmName("macro");
-        TCA.setPrinterName("aprinter112");
-    }
-
-    @BeforeAll
-    public static void testCommandsSetup() throws Exception {
-        CommandITCase.testCommandSetup();
-
-        ImplementationTO command = null;
-        try {
-            command = IMPLEMENTATION_SERVICE.read(
-                    IdRepoImplementationType.COMMAND, "GroovyCommand");
-        } catch (SyncopeClientException e) {
-            if (e.getType().getResponseStatus() == Response.Status.NOT_FOUND) {
-                command = new ImplementationTO();
-                command.setKey("GroovyCommand");
-                command.setEngine(ImplementationEngine.GROOVY);
-                command.setType(IdRepoImplementationType.COMMAND);
-                command.setBody(IOUtils.toString(
-                        MacroITCase.class.getResourceAsStream("/GroovyCommand.groovy"), StandardCharsets.UTF_8));
-                Response response = IMPLEMENTATION_SERVICE.create(command);
-                command = IMPLEMENTATION_SERVICE.read(
-                        command.getType(), response.getHeaderString(RESTHeaders.RESOURCE_KEY));
-                assertNotNull(command.getKey());
-            }
-        }
-        assertNotNull(command);
-
-        if (MACRO_TASK_KEY == null) {
-            MACRO_TASK_KEY = TASK_SERVICE.<MacroTaskTO>search(
-                    new TaskQuery.Builder(TaskType.MACRO).build()).getResult().
-                    stream().filter(t -> "Test Macro".equals(t.getName())).findFirst().map(MacroTaskTO::getKey).
-                    orElseGet(() -> {
-                        MacroTaskTO task = new MacroTaskTO();
-                        task.setName("Test Macro");
-                        task.setActive(true);
-                        task.setRealm("/odd");
-                        task.getCommands().add(new CommandTO.Builder("GroovyCommand").build());
-                        task.getCommands().add(
-                                new CommandTO.Builder(TestCommand.class.getSimpleName()).args(TCA).build());
-
-                        Response response = TASK_SERVICE.create(TaskType.MACRO, task);
-                        return response.getHeaderString(RESTHeaders.RESOURCE_KEY);
-                    });
-        }
-    }
-
-    @AfterAll
-    public static void cleanup() {
-        TestCommandArgs args = new TestCommandArgs();
-        try {
-            ANY_OBJECT_SERVICE.delete(args.getPrinterName());
-            REALM_SERVICE.delete(args.getParentRealm() + "/" + args.getRealmName());
-        } catch (Exception e) {
-            // ignore
-        }
-    }
-
-    @Test
-    public void execute() {
-        int preExecs = TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().size();
-        ExecTO execution = TASK_SERVICE.execute(new ExecSpecs.Builder().key(MACRO_TASK_KEY).build());
-        assertNotNull(execution.getExecutor());
-
-        await().atMost(MAX_WAIT_SECONDS, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).until(() -> {
-            try {
-                return preExecs < TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().size();
-            } catch (Exception e) {
-                return false;
-            }
-        });
-
-        ExecTO exec = TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().get(preExecs);
-        assertEquals(ExecStatus.SUCCESS.name(), exec.getStatus());
-
-        AnyObjectTO printer = ANY_OBJECT_SERVICE.read(PRINTER, TCA.getPrinterName());
-        assertNotNull(printer);
-        assertEquals(TCA.getParentRealm() + "/" + TCA.getRealmName(), printer.getRealm());
-        assertFalse(REALM_SERVICE.search(
-                new RealmQuery.Builder().base(printer.getRealm()).build()).getResult().isEmpty());
-    }
-
-    @Test
-    public void cantExecute() {
-        // 1. create Role for task execution
-        RoleTO role = new RoleTO();
-        role.setKey("new" + getUUIDString());
-        role.getRealms().add("/even");
-        role.getEntitlements().add(IdRepoEntitlement.TASK_EXECUTE);
-        role = createRole(role);
-        assertNotNull(role);
-
-        // 2. create User with such a Role granted
-        UserCR userCR = UserITCase.getUniqueSample("cantrunncommand@test.org");
-        userCR.getRoles().add(role.getKey());
-        UserTO userTO = createUser(userCR).getEntity();
-        assertNotNull(userTO);
-
-        // 3. attempt to run the macro task -> fail
-        TaskService taskService = CLIENT_FACTORY.create(
-                userTO.getUsername(), "password123").getService(TaskService.class);
-        try {
-            taskService.execute(new ExecSpecs.Builder().key(MACRO_TASK_KEY).build());
-            fail();
-        } catch (SyncopeClientException e) {
-            assertEquals(ClientExceptionType.DelegatedAdministration, e.getType());
-        }
-    }
-}
diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroTaskITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroTaskITCase.java
new file mode 100644
index 0000000..ef83017
--- /dev/null
+++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MacroTaskITCase.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.apache.syncope.fit.core;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import jakarta.ws.rs.core.Response;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.IOUtils;
+import org.apache.syncope.common.lib.SyncopeClientException;
+import org.apache.syncope.common.lib.command.CommandTO;
+import org.apache.syncope.common.lib.form.FormProperty;
+import org.apache.syncope.common.lib.form.FormPropertyType;
+import org.apache.syncope.common.lib.form.SyncopeForm;
+import org.apache.syncope.common.lib.request.UserCR;
+import org.apache.syncope.common.lib.to.AnyObjectTO;
+import org.apache.syncope.common.lib.to.ExecTO;
+import org.apache.syncope.common.lib.to.FormPropertyDefTO;
+import org.apache.syncope.common.lib.to.ImplementationTO;
+import org.apache.syncope.common.lib.to.MacroTaskTO;
+import org.apache.syncope.common.lib.to.RoleTO;
+import org.apache.syncope.common.lib.to.UserTO;
+import org.apache.syncope.common.lib.types.ClientExceptionType;
+import org.apache.syncope.common.lib.types.ExecStatus;
+import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.common.lib.types.IdRepoImplementationType;
+import org.apache.syncope.common.lib.types.ImplementationEngine;
+import org.apache.syncope.common.lib.types.TaskType;
+import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.apache.syncope.common.rest.api.beans.ExecSpecs;
+import org.apache.syncope.common.rest.api.beans.RealmQuery;
+import org.apache.syncope.common.rest.api.beans.TaskQuery;
+import org.apache.syncope.common.rest.api.service.TaskService;
+import org.apache.syncope.fit.AbstractITCase;
+import org.apache.syncope.fit.core.reference.TestCommand;
+import org.apache.syncope.fit.core.reference.TestCommandArgs;
+import org.apache.syncope.fit.core.reference.TestMacroActions;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+public class MacroTaskITCase extends AbstractITCase {
+
+    private static String MACRO_TASK_KEY;
+
+    private static final TestCommandArgs TCA = new TestCommandArgs();
+
+    static {
+        TCA.setParentRealm("${parent}");
+        TCA.setRealmName("${realm}");
+        TCA.setPrinterName("aprinter112");
+    }
+
+    @BeforeAll
+    public static void testCommandsSetup() throws Exception {
+        CommandITCase.testCommandSetup();
+
+        ImplementationTO command = null;
+        try {
+            command = IMPLEMENTATION_SERVICE.read(
+                    IdRepoImplementationType.COMMAND, "GroovyCommand");
+        } catch (SyncopeClientException e) {
+            if (e.getType().getResponseStatus() == Response.Status.NOT_FOUND) {
+                command = new ImplementationTO();
+                command.setKey("GroovyCommand");
+                command.setEngine(ImplementationEngine.GROOVY);
+                command.setType(IdRepoImplementationType.COMMAND);
+                command.setBody(IOUtils.toString(
+                        MacroTaskITCase.class.getResourceAsStream("/GroovyCommand.groovy"), StandardCharsets.UTF_8));
+                Response response = IMPLEMENTATION_SERVICE.create(command);
+                command = IMPLEMENTATION_SERVICE.read(
+                        command.getType(), response.getHeaderString(RESTHeaders.RESOURCE_KEY));
+                assertNotNull(command.getKey());
+            }
+        }
+        assertNotNull(command);
+
+        ImplementationTO macroActions = null;
+        try {
+            macroActions = IMPLEMENTATION_SERVICE.read(IdRepoImplementationType.MACRO_ACTIONS,
+                    TestMacroActions.class.getSimpleName());
+        } catch (SyncopeClientException e) {
+            if (e.getType().getResponseStatus() == Response.Status.NOT_FOUND) {
+                macroActions = new ImplementationTO();
+                macroActions.setKey(TestMacroActions.class.getSimpleName());
+                macroActions.setEngine(ImplementationEngine.JAVA);
+                macroActions.setType(IdRepoImplementationType.MACRO_ACTIONS);
+                macroActions.setBody(TestMacroActions.class.getName());
+                Response response = IMPLEMENTATION_SERVICE.create(macroActions);
+                macroActions = IMPLEMENTATION_SERVICE.read(
+                        macroActions.getType(), response.getHeaderString(RESTHeaders.RESOURCE_KEY));
+                assertNotNull(macroActions.getKey());
+            }
+        }
+        assertNotNull(macroActions);
+
+        if (MACRO_TASK_KEY == null) {
+            MACRO_TASK_KEY = TASK_SERVICE.<MacroTaskTO>search(
+                    new TaskQuery.Builder(TaskType.MACRO).build()).getResult().
+                    stream().filter(t -> "Test Macro".equals(t.getName())).findFirst().map(MacroTaskTO::getKey).
+                    orElseGet(() -> {
+                        MacroTaskTO task = new MacroTaskTO();
+                        task.setName("Test Macro");
+                        task.setActive(true);
+                        task.setRealm("/odd");
+                        task.getCommands().add(new CommandTO.Builder("GroovyCommand").build());
+                        task.getCommands().add(
+                                new CommandTO.Builder(TestCommand.class.getSimpleName()).args(TCA).build());
+
+                        FormPropertyDefTO realm = new FormPropertyDefTO();
+                        realm.setKey("realm");
+                        realm.setName("Realm");
+                        realm.setWritable(true);
+                        realm.setRequired(true);
+                        realm.setType(FormPropertyType.String);
+                        task.getFormPropertyDefs().add(realm);
+
+                        FormPropertyDefTO parent = new FormPropertyDefTO();
+                        parent.setKey("parent");
+                        parent.setName("Parent Realm");
+                        parent.setWritable(true);
+                        parent.setRequired(true);
+                        parent.setType(FormPropertyType.Dropdown);
+                        task.getFormPropertyDefs().add(parent);
+
+                        task.setMacroActions(TestMacroActions.class.getSimpleName());
+
+                        Response response = TASK_SERVICE.create(TaskType.MACRO, task);
+                        return response.getHeaderString(RESTHeaders.RESOURCE_KEY);
+                    });
+        }
+    }
+
+    @AfterAll
+    public static void cleanup() {
+        TestCommandArgs args = new TestCommandArgs();
+        try {
+            ANY_OBJECT_SERVICE.delete(args.getPrinterName());
+            REALM_SERVICE.delete(args.getParentRealm() + "/" + args.getRealmName());
+        } catch (Exception e) {
+            // ignore
+        }
+    }
+
+    @Test
+    public void execute() {
+        SyncopeForm form = TASK_SERVICE.getMacroTaskForm(MACRO_TASK_KEY);
+        form.getProperty("realm").orElseThrow().setValue("macro");
+        FormProperty parent = form.getProperty("parent").orElseThrow();
+        assertTrue(parent.getDropdownValues().stream().anyMatch(v -> "/odd".equals(v.getKey())));
+        parent.setValue("/odd");
+
+        int preExecs = TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().size();
+        ExecTO execution = TASK_SERVICE.execute(new ExecSpecs.Builder().key(MACRO_TASK_KEY).build(), form);
+        assertNotNull(execution.getExecutor());
+
+        await().atMost(MAX_WAIT_SECONDS, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).until(() -> {
+            try {
+                return preExecs < TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().size();
+            } catch (Exception e) {
+                return false;
+            }
+        });
+
+        ExecTO exec = TASK_SERVICE.read(TaskType.MACRO, MACRO_TASK_KEY, true).getExecutions().get(preExecs);
+        assertEquals(ExecStatus.SUCCESS.name(), exec.getStatus());
+
+        AnyObjectTO printer = ANY_OBJECT_SERVICE.read(PRINTER, TCA.getPrinterName());
+        assertNotNull(printer);
+        assertEquals("/odd/macro", printer.getRealm());
+        assertFalse(REALM_SERVICE.search(
+                new RealmQuery.Builder().base(printer.getRealm()).build()).getResult().isEmpty());
+    }
+
+    @Test
+    public void saveSameCommandMultipleOccurrencies() {
+        TestCommandArgs tca1 = new TestCommandArgs();
+        tca1.setParentRealm("parent1");
+        tca1.setRealmName("realm1");
+        tca1.setPrinterName("printer1");
+
+        MacroTaskTO task = new MacroTaskTO();
+        task.setName("saveSameCommandMultipleOccurrencies");
+        task.setActive(true);
+        task.setRealm("/");
+        task.getCommands().add(new CommandTO.Builder("GroovyCommand").build());
+        task.getCommands().add(new CommandTO.Builder(TestCommand.class.getSimpleName()).args(tca1).build());
+        task.getCommands().add(new CommandTO.Builder("GroovyCommand").build());
+
+        Response response = TASK_SERVICE.create(TaskType.MACRO, task);
+        String newTaskKey = response.getHeaderString(RESTHeaders.RESOURCE_KEY);
+
+        task = TASK_SERVICE.<MacroTaskTO>read(TaskType.MACRO, newTaskKey, false);
+        assertEquals(3, task.getCommands().size());
+        assertEquals("GroovyCommand", task.getCommands().get(0).getKey());
+        assertEquals(TestCommand.class.getSimpleName(), task.getCommands().get(1).getKey());
+        assertEquals(tca1, task.getCommands().get(1).getArgs());
+        assertEquals("GroovyCommand", task.getCommands().get(2).getKey());
+
+        TestCommandArgs tca2 = new TestCommandArgs();
+        tca2.setParentRealm("parent2");
+        tca2.setRealmName("realm2");
+        tca2.setPrinterName("printer2");
+        task.getCommands().add(new CommandTO.Builder(TestCommand.class.getSimpleName()).args(tca2).build());
+
+        TASK_SERVICE.update(TaskType.MACRO, task);
+
+        task = TASK_SERVICE.<MacroTaskTO>read(TaskType.MACRO, newTaskKey, false);
+        assertEquals(4, task.getCommands().size());
+        assertEquals("GroovyCommand", task.getCommands().get(0).getKey());
+        assertEquals(TestCommand.class.getSimpleName(), task.getCommands().get(1).getKey());
+        assertEquals(tca1, task.getCommands().get(1).getArgs());
+        assertEquals("GroovyCommand", task.getCommands().get(2).getKey());
+        assertEquals(TestCommand.class.getSimpleName(), task.getCommands().get(3).getKey());
+        assertEquals(tca2, task.getCommands().get(3).getArgs());
+    }
+
+    @Test
+    public void cantExecute() {
+        // 1. create Role for task execution
+        RoleTO role = new RoleTO();
+        role.setKey("new" + getUUIDString());
+        role.getRealms().add("/even");
+        role.getEntitlements().add(IdRepoEntitlement.TASK_EXECUTE);
+        role = createRole(role);
+        assertNotNull(role);
+
+        // 2. create User with such a Role granted
+        UserCR userCR = UserITCase.getUniqueSample("cantrunncommand@test.org");
+        userCR.getRoles().add(role.getKey());
+        UserTO userTO = createUser(userCR).getEntity();
+        assertNotNull(userTO);
+
+        // 3. attempt to run the macro task -> fail
+        TaskService taskService = CLIENT_FACTORY.create(
+                userTO.getUsername(), "password123").getService(TaskService.class);
+        try {
+            taskService.execute(new ExecSpecs.Builder().key(MACRO_TASK_KEY).build());
+            fail();
+        } catch (SyncopeClientException e) {
+            assertEquals(ClientExceptionType.DelegatedAdministration, e.getType());
+        }
+    }
+}
diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/PropagationTaskITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/PropagationTaskITCase.java
index 3f06b23..e22f385 100644
--- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/PropagationTaskITCase.java
+++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/PropagationTaskITCase.java
@@ -581,10 +581,8 @@
     @Test
     public void issueSYNCOPE741() {
         for (int i = 0; i < 3; i++) {
-            TASK_SERVICE.execute(new ExecSpecs.Builder().
-                    key("1e697572-b896-484c-ae7f-0c8f63fcbc6c").build());
-            TASK_SERVICE.execute(new ExecSpecs.Builder().
-                    key("316285cc-ae52-4ea2-a33b-7355e189ac3f").build());
+            TASK_SERVICE.execute(new ExecSpecs.Builder().key("1e697572-b896-484c-ae7f-0c8f63fcbc6c").build());
+            TASK_SERVICE.execute(new ExecSpecs.Builder().key("316285cc-ae52-4ea2-a33b-7355e189ac3f").build());
         }
         try {
             Thread.sleep(3000);
diff --git a/fit/core-reference/src/test/resources/GroovyCommand.groovy b/fit/core-reference/src/test/resources/GroovyCommand.groovy
index 92a216a..7dcfd5b 100644
--- a/fit/core-reference/src/test/resources/GroovyCommand.groovy
+++ b/fit/core-reference/src/test/resources/GroovyCommand.groovy
@@ -19,7 +19,7 @@
 
 import org.apache.syncope.common.lib.command.CommandArgs
 import org.apache.syncope.core.logic.SyncopeLogic
-import org.apache.syncope.core.logic.api.Command
+import org.apache.syncope.core.provisioning.api.macro.Command
 import org.springframework.beans.factory.annotation.Autowired
 
 class GroovyCommand implements Command<CommandArgs> {
diff --git a/pom.xml b/pom.xml
index e0adf77..9fa89cc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -406,7 +406,7 @@
     <connid.azure.version>2.0.2</connid.azure.version>
     <connid.scim.version>1.0.4</connid.scim.version>
     <connid.servicenow.version>1.0.3</connid.servicenow.version>
-    <connid.okta.version>3.0.3</connid.okta.version>
+    <connid.okta.version>3.0.4</connid.okta.version>
     <connid.cmd.version>0.5</connid.cmd.version>
 
     <cxf.version>4.0.4</cxf.version>
@@ -495,7 +495,7 @@
 
     <tomcat.version>10.1.23</tomcat.version>
     <wildfly.version>32.0.0.Final</wildfly.version>
-    <payara.version>6.2024.4</payara.version>
+    <payara.version>6.2024.5</payara.version>
     <jakarta.faces.version>4.0.7</jakarta.faces.version>
 
     <docker.postgresql.version>16</docker.postgresql.version>
diff --git a/src/main/asciidoc/reference-guide/architecture/core.adoc b/src/main/asciidoc/reference-guide/architecture/core.adoc
index e490ec3..e671146 100644
--- a/src/main/asciidoc/reference-guide/architecture/core.adoc
+++ b/src/main/asciidoc/reference-guide/architecture/core.adoc
@@ -68,9 +68,8 @@
 The Workflow layer is responsible for managing the internal lifecycle of Users, Groups and Any Objects.
 
 Besides the default engine, another engine is available based on https://www.flowable.org/[Flowable^], the 
-reference open source http://www.bpmn.org/[BPMN 2.0^] implementation. It enables advanced features such
-as approval management and new statuses definitions; a web-based GUI editor, the
-https://www.flowable.org/docs/userguide/index.html#flowableModelerApp[Flowable Modeler^], is also available.
+reference open source http://www.bpmn.org/[BPMN 2.0^] implementation. It enables advanced features such as approval
+management and new statuses definitions; a web-based GUI editor to model workflows and user requests is also available.
 
 [.text-center]
 image::userWorkflow.png[title="Default Flowable user workflow",alt="Default Flowable user workflow"] 
diff --git a/src/main/asciidoc/reference-guide/concepts/authenticationmodules.adoc b/src/main/asciidoc/reference-guide/concepts/authenticationmodules.adoc
index 319a2ac..f99e87a 100644
--- a/src/main/asciidoc/reference-guide/concepts/authenticationmodules.adoc
+++ b/src/main/asciidoc/reference-guide/concepts/authenticationmodules.adoc
@@ -27,10 +27,11 @@
     ** https://apereo.github.io/cas/6.6.x/authentication/Database-Authentication.html[Database^]
     ** https://apereo.github.io/cas/6.6.x/authentication/JAAS-Authentication.html[JAAS^]
     ** https://apereo.github.io/cas/6.6.x/authentication/LDAP-Authentication.html[LDAP^]
-    ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-Generic-OpenID-Connect.html[OpenID Connect^]
-    ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-OAuth20.html[OAuth2^]
+    ** https://apereo.github.io/cas/6.6.x/authentication/SPNEGO-Authentication.html[SPNEGO^]
     ** https://apereo.github.io/cas/6.6.x/authentication/Syncope-Authentication.html[Syncope^]
     ** https://apereo.github.io/cas/6.6.x/authentication/X509-Authentication.html[X509^]
+    ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-Generic-OpenID-Connect.html[OpenID Connect^]
+    ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-OAuth20.html[OAuth2^]
     ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-SAML.htmll[SAML^]
     ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-Apple.html[Apple Signin^]
     ** https://apereo.github.io/cas/6.6.x/integration/Delegate-Authentication-Azure-AD.html[Azure Active Directory^]
diff --git a/src/main/asciidoc/reference-guide/concepts/tasks.adoc b/src/main/asciidoc/reference-guide/concepts/tasks.adoc
index 044a863..d6f0887 100644
--- a/src/main/asciidoc/reference-guide/concepts/tasks.adoc
+++ b/src/main/asciidoc/reference-guide/concepts/tasks.adoc
@@ -221,6 +221,18 @@
 ** when to start
 ** https://docs.spring.io/spring-framework/reference/integration/scheduling.html#scheduling-cron-expression[cron expression^]
 
+===== MacroActions
+
+Macro task execution can be decorated with custom logic to be invoked around task execution, by associating
+macro tasks to one or more <<implementations,implementations>> of the
+ifeval::["{snapshotOrRelease}" == "release"]
+https://github.com/apache/syncope/blob/syncope-{docVersion}/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/MacroActions.java[MacroActions^]
+endif::[]
+ifeval::["{snapshotOrRelease}" == "snapshot"]
+https://github.com/apache/syncope/blob/3_0_X/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/api/MacroActions.java[MacroActions^]
+endif::[]
+interface.
+
 [[tasks-scheduled]]
 ==== Scheduled
 
diff --git a/src/main/asciidoc/reference-guide/concepts/workflow.adoc b/src/main/asciidoc/reference-guide/concepts/workflow.adoc
index 69e7ec5..19a7558 100644
--- a/src/main/asciidoc/reference-guide/concepts/workflow.adoc
+++ b/src/main/asciidoc/reference-guide/concepts/workflow.adoc
@@ -120,12 +120,11 @@
 
 . Besides mandatory statuses, which are modeled as BPMN `userTask` instances, more can be freely added
 at runtime, provided that adequate transitions and conditions are also inserted; more details about available BPMN
-constructs are available in the https://www.flowable.org/docs/userguide/index.html#bpmnConstructs[Flowable User Guide^]. +
+constructs are available in the https://www.flowable.com/open-source/docs/bpmn/ch07b-BPMN-Constructs[Flowable User Guide^]. +
 Additional statuses and transitions allow the internal processes of Apache Syncope to better adapt to suit organizational flows.
 . Custom logic can be injected into the workflow process by providing BPMN `serviceTask` instances.
-. https://www.flowable.org/docs/userguide/index.html#forms[Flowable forms^] are used for implementing <<approval,approval>>.
-. The https://www.flowable.org/docs/userguide/index.html#flowableModelerApp[Flowable Modeler^] is available with the
-<<admin-console,admin console>>, thus allowing web-based graphical modeling of the workflow definition.
+. Flowable forms are used for implementing <<approval,approval>>.
+. <<admin-console,admin console>> supports web-based graphical modeling of the workflow definition.
 
 [.text-center]
 image::userWorkflow.png[title="Default Flowable user workflow",alt="Default Flowable user workflow"] 
@@ -139,7 +138,7 @@
 Managers could also be asked to complete the information provided before the requested operation is finished.
 
 In order to define an approval form, a dedicated BPMN `userTask` needs to be defined, following the rules established
-for https://www.flowable.org/docs/userguide/index.html#forms[Flowable forms^].
+for Flowable forms.
 
 [NOTE]
 .What is required for administrators to manage approval?
@@ -153,7 +152,7 @@
 .. `USER_READ`
 . The BPMN `userTask` must either indicate `U` among `candidateUsers` or at least one of the groups assigned to `U`
 among `candidateGroups`, as required by
-https://www.flowable.org/docs/userguide/index.html#bpmnUserTaskUserAssignmentExtension[Flowable's task assignment rules^]
+https://www.flowable.com/open-source/docs/bpmn/ch07b-BPMN-Constructs#flowable-extensions-for-task-assignment[Flowable's task assignment rules^]
 
 The special super-user `admin` is entitled to manage all approvals, even those not specifying any
 `candidateUsers` or `candidateGroups`.
@@ -162,8 +161,8 @@
 [[sample-selfreg-approval]]
 .Approving self-registration
 ====
-The snippet below shows how to define an approval form in XML; the same operation can be performed via the
-https://www.flowable.org/docs/userguide/index.html#flowableModelerApp[Flowable Modeler^].
+The snippet below shows how to define an approval form in XML; the same operation can be performed via the GUI editor
+provided by <<admin-console,admin console>>.
 
 [source,xml]
 ----
@@ -209,8 +208,8 @@
 [[sample-user-request]]
 .Assigning printer to user
 ====
-The BPMN process below shows how to define an user request in XML; the same operation can be performed via the
-https://www.flowable.org/docs/userguide/index.html#flowableModelerApp[Flowable Modeler^].
+The BPMN process below shows how to define an user request in XML; the same operation can be performed via the GUI
+editor provided by <<admin-console,admin console>>.
 
 In this user request definition:
 
@@ -258,8 +257,7 @@
 </process>
 ----
 <1> the first form defined is self-assigned to the user which has started this request
-<2> the `dropdown` type is a Syncope extension of the
-https://www.flowable.org/docs/userguide/index.html#formProperties[form property types supported by Flowable^]
+<2> the `dropdown` type is a Syncope extension of the form property types supported by Flowable
 and allows to inject a list of elements via the `dropdownValueProvider` value (with name `printersValueProvider` in this
 sample), which must be a Spring bean implementing the
 ifeval::["{snapshotOrRelease}" == "release"]
diff --git a/src/main/asciidoc/reference-guide/usage/customization.adoc b/src/main/asciidoc/reference-guide/usage/customization.adoc
index af42264..85d6e86 100644
--- a/src/main/asciidoc/reference-guide/usage/customization.adoc
+++ b/src/main/asciidoc/reference-guide/usage/customization.adoc
@@ -255,7 +255,7 @@
 be provided - in the source tree under `core/src/main/java` when Java or via REST services if Groovy - for the following
 components:
 
-* <<propagationactions,propagation>>, <<pushactions,push>>, <<pullactions,pull>> and <<logicactions,logic>> actions
+* <<propagationactions,propagation>>, <<pushactions,push>>, <<pullactions,pull>>,  <<macroactions,macro>> and <<logicactions,logic>> actions
 * <<push-correlation-rules,push>> / <<pull-correlation-rules,pull>> correlation rules
 * <<pull-mode,reconciliation filter builders>>
 * <<commands,commands>>
diff --git a/src/site/xdoc/building.xml b/src/site/xdoc/building.xml
index 5272b8f..b4b8386 100644
--- a/src/site/xdoc/building.xml
+++ b/src/site/xdoc/building.xml
@@ -35,7 +35,7 @@
     <section name="Prerequisites">
       <p>
         <ul>
-          <li>JDK 8 or higher for &le; 2.1; JDK 11 or higher for later versions</li>
+          <li>JDK 8 for &le; 2.1; JDK 11 for 3.0; JDK 21 for later versions</li>
           <li>
             Latest <a href="https://maven.apache.org/download.html">Apache Maven</a>
           </li>
@@ -65,7 +65,7 @@
           On Windows you can find it in several distributions including
           <a href="http://gnuwin32.sourceforge.net/packages/patch.htm">GNUWin32</a>
         </p>        
-      </subsection>      
+      </subsection>
     </section>   
 
     <section name="Building Syncope">
@@ -73,11 +73,11 @@
         Before building Syncope, you need to setup an environment variable to give Maven more memory.
       </p>
       <p>
-        On Unix with JDK 8 or later
+        On Unix
         <source>export MAVEN_OPTS="-Xms512m -Xmx1024m"</source>
       </p>
       <p>
-        On Windows with JDK 8 or later
+        On Windows
         <source>set MAVEN_OPTS=-Xms512m -Xmx1024m</source>
       </p>
       <p>
@@ -144,9 +144,7 @@
         </p>
 
         <h4>HotSwapAgent</h4>
-        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled (requires
-        <a href="https://github.com/dcevm/dcevm">DCEVM Java</a> installed as "alternative JVM" and IDE of choice
-        <a href="http://hotswapagent.org/mydoc_setup_netbeans.html">set up properly</a>).
+        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled.
         <source>$ mvn -Photswap,all</source>
 
         <h4>DBMSes</h4>
@@ -155,20 +153,32 @@
         </div>
 
         <h5>PostgreSQL</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
         Perform the full test suite against a real <a href="https://www.postgresql.org/">PostgreSQL</a> database via
         <source>$ mvn -Ppostgres-it</source> or <source>$ mvn -Ppgjsonb-it</source> (for JSONB support)
 
         <h5>MySQL</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
         Perform the full test suite against a real <a href="https://www.mysql.com/">MySQL</a> database via
         <source>$ mvn -Pmysql-it</source> or <source>$ mvn -Pmyjson-it</source> (for JSON support)
 
         <h5>MariaDB</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
         Perform the full test suite against a real <a href="https://mariadb.org/">MariaDB</a> database via
         <source>$ mvn -Pmariadb-it</source>
 
         <h5>Oracle database</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
         Perform the full test suite against a real <a href="https://www.oracle.com/products/database/">Oracle</a> database via
-        <source>$ mvn -Poracle-it</source>
+        <source>$ mvn -Poracle-it</source> or <source>$ mvn -Pojson-it</source> (for JSON support)
 
         <h5>MS SQL Server</h5>
         Prform the full test suite against a real <a href="https://www.microsoft.com/en-us/sql-server/">MS SQL Server</a> database via
@@ -186,13 +196,21 @@
         <a href="http://www.wildfly.org">Wildfly</a> via
         <source>$ mvn -Pwildfly-it</source>
         
-        <h4>Elasticsearch</h4>
-        <div class="alert alert-warning">
-          <p>This build profile require <a href="https://www.docker.com/">Docker</a> to work.</p>
-        </div>
+        <h4>External search engines</h4>
 
+        <h5>Elasticsearch</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
         Perform the full test suite relying on a real <a href="https://www.elastic.co/">Elasticsearch</a> instance via
         <source>$ mvn -Pelasticsearch-it</source>
+
+        <h5>OpenSearch</h5>
+        <div class="alert alert-warning">
+          <p>This build profile requires <a href="https://www.docker.com/">Docker</a> to work.</p>
+        </div>
+        Perform the full test suite relying on a real <a href="https://opensearch.org/">OpenSearch</a> instance via
+        <source>$ mvn -Popensearch-it</source>
       </subsection>
 
       <subsection name="fit/console-reference">
@@ -203,9 +221,7 @@
         <source>$ mvn -Pdebug</source>
 
         <h4>HotSwapAgent</h4>
-        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled (requires
-        <a href="https://github.com/dcevm/dcevm">DCEVM Java</a> installed as "alternative JVM" and IDE of choice
-        <a href="http://hotswapagent.org/mydoc_setup_netbeans.html">set up properly</a>).
+        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled.
         <source>$ mvn -Photswap</source>
       </subsection>
       
@@ -217,9 +233,7 @@
         <source>$ mvn -Pdebug</source>
 
         <h4>HotSwapAgent</h4>
-        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled (requires
-        <a href="https://github.com/dcevm/dcevm">DCEVM Java</a> installed as "alternative JVM" and IDE of choice
-        <a href="http://hotswapagent.org/mydoc_setup_netbeans.html">set up properly</a>).
+        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled.
         <source>$ mvn -Photswap</source>
       </subsection>
 
@@ -231,9 +245,7 @@
         <source>$ mvn -Pdebug</source>
 
         <h4>HotSwapAgent</h4>
-        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled (requires
-        <a href="https://github.com/dcevm/dcevm">DCEVM Java</a> installed as "alternative JVM" and IDE of choice
-        <a href="http://hotswapagent.org/mydoc_setup_netbeans.html">set up properly</a>).
+        Similar to Debug, but with <a href="http://hotswapagent.org/">HotSwapAgent</a> features enabled.
         <source>$ mvn -Photswap</source>
       </subsection>
     </section>
diff --git a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/mapping/AuthModulePropertySourceMapper.java b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/mapping/AuthModulePropertySourceMapper.java
index 7d44f46..17dd7dd 100644
--- a/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/mapping/AuthModulePropertySourceMapper.java
+++ b/wa/bootstrap/src/main/java/org/apache/syncope/wa/bootstrap/mapping/AuthModulePropertySourceMapper.java
@@ -22,6 +22,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.BooleanUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.client.lib.SyncopeClient;
 import org.apache.syncope.common.lib.auth.AbstractOIDCAuthModuleConf;
@@ -477,7 +478,7 @@
         props.getSystem().setKerberosKdc(conf.getKerberosKdc());
         props.getSystem().setKerberosRealm(conf.getKerberosRealm());
         props.getSystem().setKerberosConf(conf.getKerberosConf());
-        props.getSystem().setKerberosDebug(conf.isKerberosDebug() ? Boolean.TRUE.toString() : Boolean.FALSE.toString());
+        props.getSystem().setKerberosDebug(BooleanUtils.toStringTrueFalse(conf.isKerberosDebug()));
 
         if (conf.getLdap() != null) {
             SpnegoLdapProperties ldapProps = new SpnegoLdapProperties();