Repository: arenadata/adcm (language: python)

Issue:
ADCM-2986 Add adcm image for cloud hostprovider

Bump and cleanup



Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
index af8ef2c2f2..9304e61b96 100644
--- a/Makefile
+++ b/Makefile
@@ -59,19 +59,23 @@ unittests: basetests test_image ## Run unittests
 
 pytest: ## Run functional tests
 	docker pull hub.adsw.io/library/functest:3.8.6.slim.buster-x64
-	docker run -i --rm --shm-size=4g -v /var/run/docker.sock:/var/run/docker.sock --network=host -v $(CURDIR)/:/adcm -w /adcm/ \
+	docker run -i --rm --shm-size=4g -v /var/run/docker.sock:/var/run/docker.sock --network=host \
+	-v $(CURDIR)/:/adcm -v ${LDAP_CONF_FILE}:${LDAP_CONF_FILE} -w /adcm/ \
 	-e BUILD_TAG=${BUILD_TAG} -e ADCMPATH=/adcm/ -e PYTHONPATH=${PYTHONPATH}:python/ \
 	-e SELENOID_HOST="${SELENOID_HOST}" -e SELENOID_PORT="${SELENOID_PORT}" \
 	hub.adsw.io/library/functest:3.8.6.slim.buster-x64 /bin/sh -e \
-	./pytest.sh -m "not full and not extra_rbac" --adcm-image='hub.adsw.io/adcm/adcm:$(subst /,_,$(BRANCH_NAME))'
+	./pytest.sh -m "not full and not extra_rbac" --adcm-image='hub.adsw.io/adcm/adcm:$(subst /,_,$(BRANCH_NAME))' \
+	--ldap-conf ${LDAP_CONF_FILE}
 
 pytest_release: ## Run functional tests on release
 	docker pull hub.adsw.io/library/functest:3.8.6.slim.buster.firefox-x64
-	docker run -i --rm --shm-size=4g -v /var/run/docker.sock:/var/run/docker.sock --network=host -v $(CURDIR)/:/adcm -w /adcm/ \
+	docker run -i --rm --shm-size=4g -v /var/run/docker.sock:/var/run/docker.sock --network=host \
+	-v $(CURDIR)/:/adcm -v ${LDAP_CONF_FILE}:${LDAP_CONF_FILE} -w /adcm/ \
 	-e BUILD_TAG=${BUILD_TAG} -e ADCMPATH=/adcm/ -e PYTHONPATH=${PYTHONPATH}:python/ \
 	-e SELENOID_HOST="${SELENOID_HOST}" -e SELENOID_PORT="${SELENOID_PORT}" \
 	hub.adsw.io/library/functest:3.8.6.slim.buster.firefox-x64 /bin/sh -e \
-	./pytest.sh --adcm-image='hub.adsw.io/adcm/adcm:$(subst /,_,$(BRANCH_NAME))'
+	./pytest.sh --adcm-image='hub.adsw.io/adcm/adcm:$(subst /,_,$(BRANCH_NAME))' \
+	--ldap-conf ${LDAP_CONF_FILE}
 
 ng_tests: ## Run Angular tests
 	docker pull hub.adsw.io/library/functest:3.8.6.slim.buster_node16-x64
diff --git a/README.md b/README.md
index 57d3661496..559baa766c 100644
--- a/README.md
+++ b/README.md
@@ -83,3 +83,23 @@ pre-commit install
 ```
 
 After this you will see invocation of black and pylint on every commit.
+
+## Link ADWP_UI packages
+
+If you need to debug packages from ADWP_UI, you should do next:
+
+In ADWP_UI repository:
+```sh
+delete dist folder
+yarn run watch:widgets
+cd dist/widgets
+yarn link
+```
+
+In ADCM repository:
+```sh
+cd web
+sudo rm -rf ./node_modules OR rmdir -force ./node_modules(WIN)
+yarn link "@adwp-ui/widgets"
+yarn install
+```
\ No newline at end of file
diff --git a/assemble/cloud/Dockerfile b/assemble/cloud/Dockerfile
new file mode 100644
index 0000000000..296b036e96
--- /dev/null
+++ b/assemble/cloud/Dockerfile
@@ -0,0 +1,9 @@
+ARG ADCM_IMAGE
+ARG ADCM_TAG
+FROM $ADCM_IMAGE:$ADCM_TAG
+
+RUN apk add git && pip install adcm-client
+
+EXPOSE 8000
+
+CMD ["/etc/startup.sh"]
diff --git a/conf/adcm/config.yaml b/conf/adcm/config.yaml
index 7a5075cb5b..43f3b1a33b 100644
--- a/conf/adcm/config.yaml
+++ b/conf/adcm/config.yaml
@@ -4,6 +4,25 @@
   name: ADCM
   version: 1.8
 
+  actions:
+    run_ldap_sync:
+      type: job
+      script_type: python
+      script: python_scripts/run_ldap_sync.py
+      display_name: Run LDAP sync
+      allow_in_maintenance_mode: true
+      states:
+        available: any
+    test_ldap_connection:
+      type: job
+      script_type: python
+      script: python_scripts/testing_ldap_connection.py
+      display_name: Test LDAP connection
+      allow_to_terminate: true
+      allow_in_maintenance_mode: true
+      states:
+        available: any
+
   config:
     - name: "global"
       display_name: "Global Options"
@@ -108,3 +127,88 @@
           min: 0
           description: |
             You can set the time (number of days) after which the Objects configuration will be deleted from the database. 0 is infinite storing.
+    - name: "ldap_integration"
+      display_name: "LDAP integration"
+      type: "group"
+      activatable: true
+      active: false
+      subs:
+        - name: "ldap_uri"
+          display_name: "LDAP URI"
+          description: |
+            The URI of the LDAP server.
+          type: string
+        - name: "ldap_user"
+          display_name: "Bind DN"
+          description: |
+            The distinguished name (DN) of the user that is used to connect to the LDAP server. For example: "cn=admin,dc=ad,dc=ranger-test".
+          type: string
+        - name: "ldap_password"
+          display_name: "Bind Password"
+          description: |
+            Password for access to the LDAP server of the user specified in the "LDAP User" field.
+          type: password
+        - name: "user_search_base"
+          display_name: "User search base"
+          description: |
+            The distinguished name (DN) of the directory object from which to search for entries. For example: "ou=Peoples,dc=ad,dc=ranger-test".
+          type: string
+        - name: "user_search_filter"
+          display_name: "User search filter"
+          description: |
+            Additional filter constraining the users selected for syncing.
+          type: string
+          required: false
+        - name: "user_object_class"
+          display_name: "User object class"
+          description: |
+            Object class to identify user entries.
+          type: string
+          default: "user"
+        - name: "user_name_attribute"
+          display_name: "User name attribute"
+          description: |
+            Attribute from user entry that would be treated as user name.
+          type: string
+          default: "sAMAccountName"
+        - name: "group_search_base"
+          display_name: "Group search base"
+          description: |
+            The distinguished name (DN) of the directory object from which to search for entries. For example: "ou=Groups,dc=ad,dc=ranger-test".
+          type: string
+        - name: "group_search_filter"
+          display_name: "Group search filter"
+          description: |
+            Additional filter constraining the groups selected for syncing.
+          type: string
+          required: false
+        - name: "group_object_class"
+          display_name: "Group object class"
+          description: |
+            Object class to identify group entries.
+          type: string
+          default: "group"
+        - name: "group_name_attribute"
+          display_name: "Group name attribute"
+          description: |
+            Attribute from group entry that would be treated as group name.
+          type: string
+          default: "cn"
+        - name: "group_member_attribute_name"
+          display_name: "Group member attribute name"
+          description: |
+            Attribute from group entry that is list of members.
+          type: string
+          default: "member"
+        - name: "sync_interval"
+          display_name: "Sync interval"
+          description: |
+            Amount of time in minutes that group memberships and user distinguished names are synchronized between AD and ADCM.
+          type: integer
+          default: 60
+        - name: "tls_ca_cert_file"
+          display_name: "TLS CA certificate file path"
+          description: |
+            CA certificate path in ADCM container volume.
+          type: string
+          required: false
diff --git a/conf/adcm/python_scripts/run_ldap_sync.py b/conf/adcm/python_scripts/run_ldap_sync.py
new file mode 100644
index 0000000000..290ab173d9
--- /dev/null
+++ b/conf/adcm/python_scripts/run_ldap_sync.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# Licensed 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 os
+import sys
+import ldap
+
+os.environ["PYTHONPATH"] = "/adcm/python/"
+sys.path.append("/adcm/python/")
+
+import adcm.init_django  # pylint: disable=unused-import
+from rbac.models import User, Group, OriginType
+from rbac.ldap import _get_ldap_default_settings, configure_tls, is_tls
+from cm.errors import AdcmEx
+from cm.logger import log
+from django.db import DataError, IntegrityError
+
+CERT_ENV_KEY = 'LDAPTLS_CACERT'
+
+
+class SyncLDAP:
+    _settings = None
+    _conn = None
+
+    @property
+    def conn(self):
+        if self._conn is None:
+            self._conn = self._bind()
+        return self._conn
+
+    def _bind(self):
+        try:
+            ldap.set_option(ldap.OPT_REFERRALS, 0)
+            conn = ldap.initialize(self.settings["SERVER_URI"])
+            conn.protocol_version = ldap.VERSION3
+            configure_tls(is_tls(self.settings["SERVER_URI"]), os.environ.get(CERT_ENV_KEY, ''), conn)
+            conn.simple_bind_s(self.settings["BIND_DN"], self.settings["BIND_PASSWORD"])
+        except ldap.LDAPError as e:
+            sys.stdout.write(f"Can't connect to {self.settings['SERVER_URI']} with user: {self.settings['BIND_DN']}. Error: {e}\n")
+            raise
+        return conn
+
+    def unbind(self):
+        if self._conn is not None:
+            self.conn.unbind_s()
+            self._conn = None
+
+    @property
+    def settings(self):
+        if self._settings is None:
+            self._settings, error_code = _get_ldap_default_settings()
+            if error_code is not None:
+                error = AdcmEx(error_code)
+                sys.stdout.write(error.msg)
+                raise error
+        return self._settings
+
+    def sync(self):
+        self.sync_groups()
+        self.sync_users()
+
+    def sync_groups(self):
+        """Synchronize LDAP groups with group model and delete groups which is not found in LDAP"""
+        self.settings['GROUP_SEARCH'].filterstr = f'(&' \
+                                                  f'(objectClass={self.settings["GROUP_OBJECT_CLASS"]})' \
+                                                  f'{self.settings["GROUP_FILTER"]})'
+        ldap_groups = self.settings['GROUP_SEARCH'].execute(self.conn, {})
+        self._sync_ldap_groups(ldap_groups)
+        sys.stdout.write("Groups were synchronized\n")
+
+    def sync_users(self):
+        """Synchronize LDAP users with user model and delete users which is not found in LDAP"""
+        groups = Group.objects.filter(type=OriginType.LDAP).values_list('display_name', flat=True)
+        group_filter = '' if groups.count() <= 1 else '(|'
+        for group_name in groups:
+            group_filter += f'(memberOf=CN={group_name},{self.settings["GROUP_SEARCH"].base_dn})'
+        group_filter = group_filter if groups.count() <= 1 else group_filter+')'
+        self.settings['USER_SEARCH'].filterstr = f'(&' \
+                                                 f'(objectClass={self.settings["USER_OBJECT_CLASS"]})' \
+                                                 f'{self.settings["USER_FILTER"]}' \
+                                                 f'{group_filter})'
+        ldap_users = self.settings['USER_SEARCH'].execute(self.conn, {'user': '*'}, True)
+        self._sync_ldap_users(ldap_users)
+        sys.stdout.write("Users were synchronized\n")
+
+    def _sync_ldap_groups(self, ldap_groups):
+        new_groups = set()
+        error_names = []
+        for cname, ldap_attributes in ldap_groups:
+            try:
+                name = ldap_attributes[self.settings["GROUP_TYPE"].name_attr][0]
+            except KeyError:
+                name = ''
+
+            try:
+                group, created = Group.objects.get_or_create(
+                    name=f'{name} [ldap]', built_in=False, type=OriginType.LDAP
+                )
+                group.user_set.clear()
+                new_groups.add(name)
+            except (IntegrityError, DataError) as e:
+                error_names.append(name)
+                sys.stdout.write("Error creating group %s: %s\n" % (name, e))
+                continue
+            else:
+                if created:
+                    sys.stdout.write("Create new group: %s\n" % name)
+        django_groups = set(Group.objects.filter(type=OriginType.LDAP).values_list('display_name', flat=True))
+        for groupname in django_groups - new_groups:
+            group = Group.objects.get(name__iexact=f'{groupname} [ldap]')
+            sys.stdout.write(f"Delete this group: {group}\n")
+            group.delete()
+        msg = "Sync of groups ended successfully."
+        msg += f"Couldn\'t synchronize groups: {error_names}\n" if error_names else ""
+        log.debug(msg)
+
+    def _sync_ldap_users(self, ldap_users):
+        ldap_usernames = set()
+        error_names = []
+        for cname, ldap_attributes in ldap_users:
+            defaults = {}
+            for field, ldap_name in self.settings['USER_ATTR_MAP'].items():
+                try:
+                    defaults[field] = ldap_attributes[ldap_name][0]
+                except KeyError:
+                    defaults[field] = ''
+
+            username = defaults["username"]
+            kwargs = {
+                'username__iexact': username,
+                'type': OriginType.LDAP,
+                'defaults': defaults,
+            }
+
+            try:
+                user, created = User.objects.get_or_create(**kwargs)
+            except (IntegrityError, DataError) as e:
+                error_names.append(username)
+                sys.stdout.write("Error creating user %s: %s\n" % (username, e))
+                continue
+            else:
+                updated = False
+                user.is_active = False
+                if not hex(int(ldap_attributes['useraccountcontrol'][0])).endswith('2'):
+                    user.is_active = True
+                if created:
+                    sys.stdout.write("Create user: %s\n" % username)
+                    user.set_unusable_password()
+                else:
+                    for name, attr in defaults.items():
+                        current_attr = getattr(user, name, None)
+                        if current_attr != attr:
+                            setattr(user, name, attr)
+                            updated = True
+                    if updated:
+                        sys.stdout.write("Updated user: %s\n" % username)
+
+                # Remove condition after ADCM-2944
+                if not user.is_active:
+                    sys.stdout.write(f"Delete this user and deactivate his session: {user}\n")
+                    user.delete()
+                else:
+                  user.save()
+                  ldap_usernames.add(username)
+                  for group in ldap_attributes.get('memberof', []):
+                      name = group.split(',')[0][3:]
+                      try:
+                          group = Group.objects.get(name=f'{name} [ldap]', built_in=False,
+                                                                       type=OriginType.LDAP)
+                          group.user_set.add(user)
+                          sys.stdout.write(f"Add user {user} to group {group}\n")
+                      except (IntegrityError, DataError) as e:
+                          sys.stdout.write("Error getting group %s: %s\n" % (name, e))
+
+        django_usernames = set(User.objects.filter(type=OriginType.LDAP).values_list('username', flat=True))
+        for username in django_usernames - ldap_usernames:
+            user = User.objects.get(username__iexact=username)
+            sys.stdout.write(f"Delete this user and deactivate his session: {user}\n")
+            user.delete()
+            # Uncomment after ADCM-2944
+            # user.is_active = False
+            # user.save()
+        msg = "Sync of users ended successfully."
+        msg += f"Couldn\'t synchronize users: {error_names}\n" if error_names else ""
+        log.debug(msg)
+
+
+if __name__ == '__main__':
+    sync_ldap = SyncLDAP()
+    sync_ldap.sync()
+    sync_ldap.unbind()
diff --git a/os/etc/crontabs/root b/os/etc/crontabs/root
index 2043bedcc0..8c2f8ef5d8 100644
--- a/os/etc/crontabs/root
+++ b/os/etc/crontabs/root
@@ -18,3 +18,4 @@
 0       3       *       *       6       run-parts /etc/periodic/weekly
 0       5       1       *       *       run-parts /etc/periodic/monthly
 0       8       */1     *       *       python /adcm/python/manage.py logrotate --target all
+*/1     *       *       *       *       python /adcm/python/manage.py run_ldap_sync
diff --git a/python/adcm/settings.py b/python/adcm/settings.py
index 40416274ac..7bff91c5a7 100644
--- a/python/adcm/settings.py
+++ b/python/adcm/settings.py
@@ -174,6 +174,7 @@
 AUTHENTICATION_BACKENDS = (
     'django.contrib.auth.backends.ModelBackend',
     'guardian.backends.ObjectPermissionBackend',
+    'rbac.ldap.CustomLDAPBackend',
     'social_core.backends.google.GoogleOAuth2',
 )
 
@@ -270,6 +271,11 @@
             'level': 'DEBUG',
             'propagate': True,
         },
+        'django_auth_ldap': {
+            'handlers': ['file'],
+            'level': 'DEBUG',
+            'propagate': True,
+        },
     },
 }
 
diff --git a/python/api/action/views.py b/python/api/action/views.py
index 9f718333bf..4c71d3c67b 100644
--- a/python/api/action/views.py
+++ b/python/api/action/views.py
@@ -186,7 +186,7 @@ def check_action_perm(self, action, obj):
             permission_denied()
 
     @staticmethod
-    def check_maintenance_mode(action, obj):
+    def check_disabling_cause(action, obj):
         if isinstance(obj, Host) and obj.maintenance_mode == MaintenanceModeType.On:
             raise AdcmEx(
                 'ACTION_ERROR',
@@ -198,6 +198,11 @@ def check_maintenance_mode(action, obj):
                 'ACTION_ERROR',
                 msg='you cannot start the action because at least one host is in maintenance mode',
             )
+        if action.disabling_cause == 'no_ldap_settings':
+            raise AdcmEx(
+                'ACTION_ERROR',
+                msg='you cannot start the action because ldap settings not configured completely',
+            )
 
     def post(self, request, *args, **kwargs):
         """
@@ -211,6 +216,6 @@ def post(self, request, *args, **kwargs):
         )
         action = get_object_for_user(request.user, 'cm.view_action', Action, id=action_id)
         self.check_action_perm(action, obj)
-        self.check_maintenance_mode(action, obj)
+        self.check_disabling_cause(action, obj)
         serializer = self.get_serializer(data=request.data)
         return create(serializer, action=action, task_object=obj)
diff --git a/python/api/cluster/serializers.py b/python/api/cluster/serializers.py
index f026c3bd1b..b33cb32e39 100644
--- a/python/api/cluster/serializers.py
+++ b/python/api/cluster/serializers.py
@@ -20,7 +20,7 @@
 from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer
 from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField
 from api.host.serializers import HostSerializer
-from api.serializers import StringListSerializer
+from api.serializers import StringListSerializer, DoUpgradeSerializer
 from api.utils import (
     CommonAPIURL,
     ObjectURL,
@@ -38,8 +38,10 @@
     Host,
     Prototype,
     ServiceComponent,
+    Upgrade,
 )
 from cm.status_api import get_cluster_status, get_hc_status
+from cm.upgrade import do_upgrade
 
 
 def get_cluster_id(obj):
@@ -356,3 +358,14 @@ def create(self, validated_data):
         cluster = self.context.get('cluster')
         service = self.context.get('service')
         return cm.api.multi_bind(cluster, service, bind)
+
+
+class DoClusterUpgradeSerializer(DoUpgradeSerializer):
+    hc = serializers.JSONField(required=False, default=list)
+
+    def create(self, validated_data):
+        upgrade = check_obj(Upgrade, validated_data.get('upgrade_id'), 'UPGRADE_NOT_FOUND')
+        config = validated_data.get('config', {})
+        attr = validated_data.get('attr', {})
+        hc = validated_data.get('hc', [])
+        return do_upgrade(validated_data.get('obj'), upgrade, config, attr, hc)
diff --git a/python/api/cluster/views.py b/python/api/cluster/views.py
index 855cada6c4..8853f86008 100644
--- a/python/api/cluster/views.py
+++ b/python/api/cluster/views.py
@@ -223,7 +223,7 @@ def delete(self, request, *args, **kwargs):
 
 class ClusterUpgrade(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = api.serializers.UpgradeLinkSerializer
+    serializer_class = api.serializers.ClusterUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
 
     def get_ordering(self):
@@ -247,7 +247,7 @@ def get(self, request, *args, **kwargs):
 
 class ClusterUpgradeDetail(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = api.serializers.UpgradeLinkSerializer
+    serializer_class = api.serializers.ClusterUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
 
     def get(self, request, *args, **kwargs):
@@ -269,7 +269,7 @@ def get(self, request, *args, **kwargs):
 
 class DoClusterUpgrade(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = api.serializers.DoUpgradeSerializer
+    serializer_class = serializers.DoClusterUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
 
     def post(self, request, *args, **kwargs):
@@ -321,7 +321,11 @@ def get(self, request, *args, **kwargs):
         check_custom_perm(
             request.user, 'view_host_components_of', 'cluster', cluster, 'view_hostcomponent'
         )
-        hc = self.get_queryset().filter(cluster=cluster)
+        hc = (
+            self.get_queryset()
+            .prefetch_related('service', 'component', 'host')
+            .filter(cluster=cluster)
+        )
         if self._is_for_ui():
             ui_hc = HostComponent()
             ui_hc.hc = hc
diff --git a/python/api/job/views.py b/python/api/job/views.py
index 66b5ba58b1..6208f4f16c 100644
--- a/python/api/job/views.py
+++ b/python/api/job/views.py
@@ -43,6 +43,14 @@ class JobList(PermissionListMixin, PaginatedView):
     permission_classes = (permissions.DjangoModelPermissions,)
     permission_required = ['cm.view_joblog']
 
+    def get_queryset(self, *args, **kwargs):
+        if self.request.user.is_superuser:
+            exclude_pks = []
+        else:
+            exclude_pks = JobLog.get_adcm_jobs_qs().values_list('pk', flat=True)
+
+        return super().get_queryset(*args, **kwargs).exclude(pk__in=exclude_pks)
+
 
 class JobDetail(PermissionListMixin, GenericUIView):
     queryset = JobLog.objects.all()
@@ -166,6 +174,14 @@ class Task(PermissionListMixin, PaginatedView):
     filterset_fields = ('action_id', 'pid', 'status', 'start_date', 'finish_date')
     ordering_fields = ('status', 'start_date', 'finish_date')
 
+    def get_queryset(self, *args, **kwargs):
+        if self.request.user.is_superuser:
+            exclude_pks = []
+        else:
+            exclude_pks = TaskLog.get_adcm_tasks_qs().values_list('pk', flat=True)
+
+        return super().get_queryset(*args, **kwargs).exclude(pk__in=exclude_pks)
+
 
 class TaskDetail(PermissionListMixin, DetailView):
     """
diff --git a/python/api/provider/serializers.py b/python/api/provider/serializers.py
index ddecb1dc40..2a051d20a0 100644
--- a/python/api/provider/serializers.py
+++ b/python/api/provider/serializers.py
@@ -15,6 +15,9 @@
 
 import cm
 from api.action.serializers import ActionShort
+from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer
+from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField
+from api.serializers import StringListSerializer, DoUpgradeSerializer
 from api.utils import (
     hlink,
     check_obj,
@@ -23,13 +26,10 @@
     CommonAPIURL,
     ObjectURL,
 )
-from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer
-from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField
-from api.serializers import StringListSerializer
-from api.serializers import UpgradeSerializer, UrlField
 from cm.adcm_config import get_main_info
 from cm.errors import AdcmEx
-from cm.models import Action, Prototype
+from cm.models import Action, Prototype, Upgrade
+from cm.upgrade import do_upgrade
 
 
 class ProviderSerializer(serializers.Serializer):
@@ -103,10 +103,9 @@ def get_main_info(self, obj):
         return get_main_info(obj)
 
 
-class UpgradeProviderSerializer(UpgradeSerializer):
-    class MyUrlField(UrlField):
-        def get_kwargs(self, obj):
-            return {'provider_id': self.context['provider_id'], 'upgrade_id': obj.id}
-
-    url = MyUrlField(read_only=True, view_name='provider-upgrade-details')
-    do = MyUrlField(read_only=True, view_name='do-provider-upgrade')
+class DoProviderUpgradeSerializer(DoUpgradeSerializer):
+    def create(self, validated_data):
+        upgrade = check_obj(Upgrade, validated_data.get('upgrade_id'), 'UPGRADE_NOT_FOUND')
+        config = validated_data.get('config', {})
+        attr = validated_data.get('attr', {})
+        return do_upgrade(validated_data.get('obj'), upgrade, config, attr, [])
diff --git a/python/api/provider/views.py b/python/api/provider/views.py
index 74b869817f..2b876ccba1 100644
--- a/python/api/provider/views.py
+++ b/python/api/provider/views.py
@@ -27,8 +27,8 @@
 from cm.api import delete_host_provider
 from cm.models import HostProvider, Upgrade
 from cm.upgrade import get_upgrade
-from . import serializers
 from rbac.viewsets import DjangoOnlyObjectPermissions
+from . import serializers
 
 
 class ProviderList(PermissionListMixin, PaginatedView):
@@ -79,7 +79,7 @@ def delete(self, request, *args, **kwargs):
 
 class ProviderUpgrade(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = serializers.UpgradeProviderSerializer
+    serializer_class = api.serializers.ProviderUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
     filter_backends = (AdcmFilterBackend, AdcmOrderingFilter)
 
@@ -104,7 +104,7 @@ def get(self, request, *args, **kwargs):
 
 class ProviderUpgradeDetail(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = serializers.UpgradeProviderSerializer
+    serializer_class = api.serializers.ProviderUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
 
     def get(self, request, *args, **kwargs):
@@ -126,7 +126,7 @@ def get(self, request, *args, **kwargs):
 
 class DoProviderUpgrade(GenericUIView):
     queryset = Upgrade.objects.all()
-    serializer_class = api.serializers.DoUpgradeSerializer
+    serializer_class = serializers.DoProviderUpgradeSerializer
     permission_classes = (permissions.IsAuthenticated,)
 
     def post(self, request, *args, **kwargs):
diff --git a/python/api/serializers.py b/python/api/serializers.py
index 99dfe5c839..b6af9f16e9 100644
--- a/python/api/serializers.py
+++ b/python/api/serializers.py
@@ -18,8 +18,7 @@
 from api.utils import check_obj, hlink, UrlField
 from cm.adcm_config import ui_config, get_prototype_config, get_action_variant
 from cm.errors import raise_AdcmEx
-from cm.models import Upgrade, GroupConfig, Cluster, HostProvider, PrototypeConfig
-from cm.upgrade import do_upgrade
+from cm.models import GroupConfig, Cluster, HostProvider, PrototypeConfig
 
 
 class UpgradeSerializer(serializers.Serializer):
@@ -51,43 +50,51 @@ def get_config(self, instance):
 
         if 'cluster_id' in self.context:
             obj = check_obj(Cluster, self.context['cluster_id'])
-            proto = obj.prototype
         elif 'provider_id' in self.context:
             obj = check_obj(HostProvider, self.context['provider_id'])
-            proto = obj.prototype
         else:
             obj = None
-            proto = self.context['prototype']
 
         action_conf = PrototypeConfig.objects.filter(
             prototype=instance.action.prototype, action=instance.action
         ).order_by('id')
-        _, _, _, attr = get_prototype_config(proto, instance.action)
+        *_, attr = get_prototype_config(instance.action.prototype, instance.action)
         if obj:
             get_action_variant(obj, action_conf)
         conf = ConfigSerializerUI(action_conf, many=True, context=self.context, read_only=True)
         return {'attr': attr, 'config': conf.data}
 
 
-class UpgradeLinkSerializer(UpgradeSerializer):
+class ClusterUpgradeSerializer(UpgradeSerializer):
     class MyUrlField(UrlField):
         def get_kwargs(self, obj):
             return {'cluster_id': self.context['cluster_id'], 'upgrade_id': obj.id}
 
+    hostcomponentmap = serializers.SerializerMethodField()
     url = MyUrlField(read_only=True, view_name='cluster-upgrade-details')
     do = MyUrlField(read_only=True, view_name='do-cluster-upgrade')
 
+    def get_hostcomponentmap(self, instance):
+        if instance.action:
+            return instance.action.hostcomponentmap
+        return []
+
+
+class ProviderUpgradeSerializer(UpgradeSerializer):
+    class MyUrlField(UrlField):
+        def get_kwargs(self, obj):
+            return {'provider_id': self.context['provider_id'], 'upgrade_id': obj.id}
+
+    url = MyUrlField(read_only=True, view_name='provider-upgrade-details')
+    do = MyUrlField(read_only=True, view_name='do-provider-upgrade')
+
 
 class DoUpgradeSerializer(serializers.Serializer):
     id = serializers.IntegerField(read_only=True)
     upgradable = serializers.BooleanField(read_only=True)
     config = serializers.JSONField(required=False, default=dict)
     task_id = serializers.IntegerField(read_only=True)
-
-    def create(self, validated_data):
-        upgrade = check_obj(Upgrade, validated_data.get('upgrade_id'), 'UPGRADE_NOT_FOUND')
-        config = validated_data.get('config')
-        return do_upgrade(validated_data.get('obj'), upgrade, config)
+    attr = serializers.JSONField(required=False, default=dict)
 
 
 class StringListSerializer(serializers.ListField):
diff --git a/python/api/stack/filters.py b/python/api/stack/filters.py
new file mode 100644
index 0000000000..07c3603b1b
--- /dev/null
+++ b/python/api/stack/filters.py
@@ -0,0 +1,28 @@
+# Licensed 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.
+
+
+from django_filters import rest_framework as drf_filters
+from cm.models import Prototype
+
+
+class StringInFilter(drf_filters.BaseInFilter, drf_filters.CharFilter):
+    pass
+
+
+class PrototypeListFilter(drf_filters.FilterSet):
+    name = StringInFilter(label='name', field_name='name', lookup_expr='in')
+    parent_name = StringInFilter(label='parent_name', field_name='parent', lookup_expr='name__in')
+
+    class Meta:
+        model = Prototype
+        fields = ['bundle_id', 'type']
diff --git a/python/api/stack/serializers.py b/python/api/stack/serializers.py
index 4728c7fe36..758288c629 100644
--- a/python/api/stack/serializers.py
+++ b/python/api/stack/serializers.py
@@ -82,6 +82,52 @@ def get_bundle_edition(self, obj):
         return obj.bundle.edition
 
 
+def get_constraint(self, obj):
+    if obj.type == 'component':
+        return obj.constraint
+    return []
+
+
+def get_service_name(self, obj):
+    if obj.type == 'component':
+        return obj.parent.name
+    return ''
+
+
+def get_service_display_name(self, obj):
+    if obj.type == 'component':
+        return obj.parent.display_name
+    return ''
+
+
+def get_service_id(self, obj):
+    if obj.type == 'component':
+        return obj.parent.id
+    return None
+
+
+class PrototypeUISerializer(PrototypeSerializer):
+    parent_id = serializers.IntegerField(read_only=True)
+    version_order = serializers.IntegerField(read_only=True)
+    shared = serializers.BooleanField(read_only=True)
+    constraint = serializers.SerializerMethodField(read_only=True)
+    requires = serializers.JSONField(read_only=True)
+    bound_to = serializers.JSONField(read_only=True)
+    adcm_min_version = serializers.CharField(read_only=True)
+    monitoring = serializers.CharField(read_only=True)
+    config_group_customization = serializers.BooleanField(read_only=True)
+    venv = serializers.CharField(read_only=True)
+    allow_maintenance_mode = serializers.BooleanField(read_only=True)
+    service_name = serializers.SerializerMethodField(read_only=True)
+    service_display_name = serializers.SerializerMethodField(read_only=True)
+    service_id = serializers.SerializerMethodField(read_only=True)
+
+    get_constraint = get_constraint
+    get_service_name = get_service_name
+    get_service_display_name = get_service_display_name
+    get_service_id = get_service_id
+
+
 class PrototypeShort(serializers.ModelSerializer):
     class Meta:
         model = Prototype
@@ -164,8 +210,17 @@ def get_license(self, obj):
 
 
 class PrototypeDetailSerializer(PrototypeSerializer):
+    constraint = serializers.SerializerMethodField()
     actions = StackActionDetailSerializer(many=True, read_only=True)
     config = ConfigSerializer(many=True, read_only=True)
+    service_name = serializers.SerializerMethodField(read_only=True)
+    service_display_name = serializers.SerializerMethodField(read_only=True)
+    service_id = serializers.SerializerMethodField(read_only=True)
+
+    get_constraint = get_constraint
+    get_service_name = get_service_name
+    get_service_display_name = get_service_display_name
+    get_service_id = get_service_id
 
 
 class ProviderTypeDetailSerializer(ProviderTypeSerializer):
diff --git a/python/api/stack/views.py b/python/api/stack/views.py
index 76f644525e..319da507ec 100644
--- a/python/api/stack/views.py
+++ b/python/api/stack/views.py
@@ -33,7 +33,7 @@
 from cm.models import Bundle, Prototype, Action
 from cm.models import PrototypeConfig, Upgrade, PrototypeExport
 from cm.models import PrototypeImport
-from . import serializers
+from . import serializers, filters
 
 
 class CsrfOffSessionAuthentication(SessionAuthentication):
@@ -163,7 +163,8 @@ class PrototypeList(PaginatedView):
 
     queryset = Prototype.objects.all()
     serializer_class = serializers.PrototypeSerializer
-    filterset_fields = ('name', 'bundle_id', 'type')
+    serializer_class_ui = serializers.PrototypeUISerializer
+    filterset_class = filters.PrototypeListFilter
     ordering_fields = ('display_name', 'version_order')
 
 
diff --git a/python/api/utils.py b/python/api/utils.py
index fad8965836..57d4de3198 100644
--- a/python/api/utils.py
+++ b/python/api/utils.py
@@ -33,6 +33,7 @@
     HostComponent,
     MaintenanceModeType,
     Host,
+    ConfigLog,
 )
 
 
@@ -100,6 +101,10 @@ def update(serializer, **kwargs):
 
 def set_disabling_cause(obj: ADCMEntity, action: Action) -> None:
     action.disabling_cause = None
+    if obj.prototype.type == 'adcm':
+        current_configlog = ConfigLog.objects.get(obj_ref=obj.config, id=obj.config.current)
+        if not current_configlog.attr['ldap_integration']['active']:
+            action.disabling_cause = 'no_ldap_settings'
     if obj.prototype.type == 'cluster':
         mm = Host.objects.filter(cluster=obj, maintenance_mode=MaintenanceModeType.On).exists()
         if not action.allow_in_maintenance_mode and mm:
diff --git a/python/cm/adcm_config.py b/python/cm/adcm_config.py
index 3ae1c0ea98..daef4eacc3 100644
--- a/python/cm/adcm_config.py
+++ b/python/cm/adcm_config.py
@@ -40,6 +40,9 @@
 )
 
 
+SECURE_PARAM_TYPES = ('password', 'secrettext')
+
+
 def proto_ref(proto):
     return f'{proto.type} "{proto.name}" {proto.version}'
 
@@ -113,7 +116,7 @@ def get_default(c, proto=None):  # pylint: disable=too-many-branches
         value = c.default
     elif c.type == 'text':
         value = c.default
-    elif c.type in ('password', 'secrettext'):
+    elif c.type in SECURE_PARAM_TYPES:
         if c.default:
             value = ansible_encrypt_and_format(c.default)
     elif type_is_complex(c.type):
@@ -231,7 +234,7 @@ def get_prototype_config(proto: Prototype, action: Action = None) -> Tuple[dict,
         else:
             spec[c.name][c.subname] = obj_to_dict(c, flist)
             conf[c.name][c.subname] = get_default(c, proto)
-    return (spec, flat_spec, conf, attr)
+    return spec, flat_spec, conf, attr
 
 
 def make_object_config(obj: ADCMEntity, prototype: Prototype) -> None:
@@ -394,13 +397,13 @@ def process_file_type(obj: Any, spec: dict, conf: dict):
 
 def ansible_encrypt(msg):
     vault = VaultAES256()
-    secret = VaultSecret(bytes(config.ANSIBLE_SECRET, 'utf-8'))
-    return vault.encrypt(bytes(msg, 'utf-8'), secret)
+    secret = VaultSecret(bytes(config.ANSIBLE_SECRET, config.ENCODING))
+    return vault.encrypt(bytes(msg, config.ENCODING), secret)
 
 
 def ansible_encrypt_and_format(msg):
     ciphertext = ansible_encrypt(msg)
-    return f'{config.ANSIBLE_VAULT_HEADER}\n{str(ciphertext, "utf-8")}'
+    return f'{config.ANSIBLE_VAULT_HEADER}\n{str(ciphertext, config.ENCODING)}'
 
 
 def ansible_decrypt(msg):
@@ -408,8 +411,16 @@ def ansible_decrypt(msg):
         return msg
     _, ciphertext = msg.split("\n")
     vault = VaultAES256()
-    secret = VaultSecret(bytes(config.ANSIBLE_SECRET, 'utf-8'))
-    return str(vault.decrypt(ciphertext, secret), 'utf-8')
+    secret = VaultSecret(bytes(config.ANSIBLE_SECRET, config.ENCODING))
+    return str(vault.decrypt(ciphertext, secret), config.ENCODING)
+
+
+def is_ansible_encrypted(msg):
+    if not isinstance(msg, str):
+        msg = str(msg, config.ENCODING)
+    if config.ANSIBLE_VAULT_HEADER in msg:
+        return True
+    return False
 
 
 def process_password(spec, conf):
@@ -420,11 +431,11 @@ def update_password(passwd):
 
     for key in conf:
         if 'type' in spec[key]:
-            if spec[key]['type'] in ('password', 'secrettext') and conf[key]:
+            if spec[key]['type'] in SECURE_PARAM_TYPES and conf[key]:
                 conf[key] = update_password(conf[key])
         else:
             for subkey in conf[key]:
-                if spec[key][subkey]['type'] in ('password', 'secrettext') and conf[key][subkey]:
+                if spec[key][subkey]['type'] in SECURE_PARAM_TYPES and conf[key][subkey]:
                     conf[key][subkey] = update_password(conf[key][subkey])
     return conf
 
@@ -438,7 +449,7 @@ def process_config(obj, spec, old_conf):  # pylint: disable=too-many-branches
             if conf[key] is not None:
                 if spec[key]['type'] == 'file':
                     conf[key] = cook_file_type_name(obj, key, '')
-                elif spec[key]['type'] in ('password', 'secrettext'):
+                elif spec[key]['type'] in SECURE_PARAM_TYPES:
                     if config.ANSIBLE_VAULT_HEADER in conf[key]:
                         conf[key] = {'__ansible_vault': conf[key]}
         elif conf[key]:
@@ -446,7 +457,7 @@ def process_config(obj, spec, old_conf):  # pylint: disable=too-many-branches
                 if conf[key][subkey] is not None:
                     if spec[key][subkey]['type'] == 'file':
                         conf[key][subkey] = cook_file_type_name(obj, key, subkey)
-                    elif spec[key][subkey]['type'] in ('password', 'secrettext'):
+                    elif spec[key][subkey]['type'] in SECURE_PARAM_TYPES:
                         if config.ANSIBLE_VAULT_HEADER in conf[key][subkey]:
                             conf[key][subkey] = {'__ansible_vault': conf[key][subkey]}
     return conf
@@ -650,6 +661,17 @@ def check_value_unselected_field(
     :param spec: Config specification
     :param obj: Parent object (Cluster, Service, Component Provider or Host)
     """
+
+    # pylint: disable=too-many-boolean-expressions
+    def check_empty_values(key, current, new):
+        key_in_config = key in current and key in new
+        if key_in_config and (
+            (bool(current[key]) is False and new[key] is None)
+            or (current[key] is None and bool(new[key]) is False)
+        ):
+            return True
+        return False
+
     for k, v in group_keys.items():
         if isinstance(v, Mapping):
             if (
@@ -673,12 +695,9 @@ def check_value_unselected_field(
                 obj,
             )
         else:
-            if spec[k]['type'] in ['list', 'map', 'string']:
-                if config_is_ro(obj, k, spec[k]['limits']) or (
-                    k in current_config
-                    and k in new_config
-                    and bool(current_config[k]) is False
-                    and new_config[k] is None
+            if spec[k]['type'] in ['list', 'map', 'string', 'structure']:
+                if config_is_ro(obj, k, spec[k]['limits']) or check_empty_values(
+                    k, current_config, new_config
                 ):
                     continue
 
@@ -967,13 +986,18 @@ def check_str(idx, v):
                         err('CONFIG_VALUE_ERROR', tmpl2.format(msg))
 
 
-def replace_object_config(obj, key, subkey, value):
+def replace_object_config(obj, key, subkey, value, proto_conf):
     cl = ConfigLog.objects.get(obj_ref=obj.config, id=obj.config.current)
     conf = cl.config
+
+    if proto_conf.type in SECURE_PARAM_TYPES and not is_ansible_encrypted(value):
+        value = ansible_encrypt_and_format(value)
+
     if subkey:
         conf[key][subkey] = value
     else:
         conf[key] = value
+
     save_obj_config(obj.config, conf, cl.attr, 'ansible update')
 
 
@@ -995,10 +1019,13 @@ def set_object_config(obj, keys, value):
     # if config_is_ro(obj, keys, pconf.limits):
     #    msg = 'config key {} of {} is read only'
     #    err('CONFIG_VALUE_ERROR', msg.format(key, ref))
-    replace_object_config(obj, key, subkey, value)
+    replace_object_config(obj, key, subkey, value, pconf)
     if pconf.type == 'file':
         save_file_type(obj, key, subkey, value)
-    log.info('update %s config %s/%s to "%s"', obj_ref(obj), key, subkey, value)
+    log_value = value
+    if pconf.type in SECURE_PARAM_TYPES:
+        log_value = '****'
+    log.info('update %s config %s/%s to "%s"', obj_ref(obj), key, subkey, log_value)
     return value
 
 
diff --git a/python/cm/adcm_schema.yaml b/python/cm/adcm_schema.yaml
index 5e0322314a..75187715a8 100644
--- a/python/cm/adcm_schema.yaml
+++ b/python/cm/adcm_schema.yaml
@@ -8,7 +8,7 @@ root:
 #                             O B J E C T S
 ###############################################################################
 # There are a number of object types: cluster, provider, host and service
-# All of them has the same struture but with small difference
+# All of them has the same structure but with small difference
 
 object:
   match: dict_key_selection
@@ -55,7 +55,7 @@ cluster_object:
   <<: *common_object
   items:
     <<: *common_object_items
-    upgrade: upgrade_list
+    upgrade: cluster_upgrade_list
     import: import_dict
     export: export
     config_group_customization: boolean
@@ -70,14 +70,14 @@ provider_object:
   <<: *common_object
   items:
     <<: *common_object_items
-    upgrade: upgrade_list
+    upgrade: provider_upgrade_list
     config_group_customization: boolean
 
 adcm_object:
   <<: *common_object
   items:
     <<: *common_object_items
-    upgrade: upgrade_list
+    upgrade: provider_upgrade_list
 
 export:
   match: one_of
@@ -162,20 +162,26 @@ version_rule:
     - float
 
 # Upgrade Block
-upgrade_list:
+cluster_upgrade_list:
   match: list
-  item: upgrade_obj
+  item: cluster_upgrade_obj
 
-upgrade_obj:
+provider_upgrade_list:
+  match: list
+  item: provider_upgrade_obj
+
+upgrade_obj: &common_upgrade_object
   match: dict
-  items:
+  items: &common_upgrade_object_items
     name: version_rule
     description: string
     versions: version_dict
     states: states_dict
     from_edition: any_or_list
     scripts: upgrade_task_list
-    hc_acl: action_hc_acl_list
+    masking: action_masking_or_none
+    on_fail: post_action
+    on_success: post_action
     venv: string
     ui_options: json
     config: config_obj
@@ -184,6 +190,17 @@ upgrade_obj:
     - name
     - versions
 
+provider_upgrade_obj:
+  <<: *common_upgrade_object
+  items:
+    <<: *common_upgrade_object_items
+
+cluster_upgrade_obj:
+  <<: *common_upgrade_object
+  items:
+    <<: *common_upgrade_object_items
+    hc_acl: action_hc_acl_list
+
 upgrade_task_list:
   match: list
   item: upgrade_task_action
@@ -755,6 +772,7 @@ action_hc_acl_dict:
   required_items:
     - component
     - action
+    - service
 
 hc_acl_action:
   match: set
@@ -766,6 +784,7 @@ action_script_type:
   match: set
   variants:
     - ansible
+    - python
 
 action_states_dict:
   match: dict
diff --git a/python/cm/api.py b/python/cm/api.py
index 5a5ef03911..ccbf10f8c7 100644
--- a/python/cm/api.py
+++ b/python/cm/api.py
@@ -202,11 +202,11 @@ def add_host_provider(proto, name, desc=''):
     return provider
 
 
-def _cancel_locking_tasks(obj: ADCMEntity):
+def _cancel_locking_tasks(obj: ADCMEntity, obj_deletion=False):
     """Cancel all tasks that have locks on object"""
     for lock in obj.concerns.filter(type=ConcernType.Lock):
         for task in TaskLog.objects.filter(lock=lock):
-            task.cancel()
+            task.cancel(obj_deletion=obj_deletion)
 
 
 def delete_host_provider(provider, cancel_tasks=True):
@@ -215,7 +215,7 @@ def delete_host_provider(provider, cancel_tasks=True):
         msg = 'There is host #{} "{}" of host {}'
         err('PROVIDER_CONFLICT', msg.format(hosts[0].id, hosts[0].fqdn, obj_ref(provider)))
     if cancel_tasks:
-        _cancel_locking_tasks(provider)
+        _cancel_locking_tasks(provider, obj_deletion=True)
     provider_id = provider.id
     provider.delete()
     cm.status_api.post_event('delete', 'provider', provider_id)
@@ -285,7 +285,7 @@ def delete_host(host, cancel_tasks=True):
         msg = 'Host #{} "{}" belong to {}'
         err('HOST_CONFLICT', msg.format(host.id, host.fqdn, obj_ref(cluster)))
     if cancel_tasks:
-        _cancel_locking_tasks(host)
+        _cancel_locking_tasks(host, obj_deletion=True)
     host_id = host.id
     host.delete()
     cm.status_api.post_event('delete', 'host', host_id)
@@ -356,7 +356,7 @@ def delete_service(service: ClusterObject, cancel_tasks=True) -> None:
     if ClusterBind.objects.filter(source_service=service).exists():
         err('SERVICE_CONFLICT', f'Service #{service.id} has exports(s)')
     if cancel_tasks:
-        _cancel_locking_tasks(service)
+        _cancel_locking_tasks(service, obj_deletion=True)
     service_id = service.id
     cluster = service.cluster
     service.delete()
@@ -370,7 +370,7 @@ def delete_service(service: ClusterObject, cancel_tasks=True) -> None:
 
 def delete_cluster(cluster, cancel_tasks=True):
     if cancel_tasks:
-        _cancel_locking_tasks(cluster)
+        _cancel_locking_tasks(cluster, obj_deletion=True)
     cluster_id = cluster.id
     hosts = cluster.host_set.all()
     host_ids = [str(host.id) for host in hosts]
@@ -536,7 +536,7 @@ def get_hc(cluster):
     return hc_map
 
 
-def check_hc(cluster, hc_in):  # pylint: disable=too-many-branches
+def check_sub_key(hc_in):
     def check_sub(sub_key, sub_type, item):
         if sub_key not in item:
             msg = '"{}" sub-field of hostcomponent is required'
@@ -558,6 +558,8 @@ def check_sub(sub_key, sub_type, item):
             msg = 'duplicate ({}) in host service list'
             raise AdcmEx('INVALID_INPUT', msg.format(item))
 
+
+def make_host_comp_list(cluster, hc_in):
     host_comp_list = []
     for item in hc_in:
         host = Host.obj.get(id=item['host_id'])
@@ -570,9 +572,16 @@ def check_sub(sub_key, sub_type, item):
             msg = 'host {} (cluster #{}) does not belong to cluster #{}'
             raise AdcmEx("FOREIGN_HOST", msg.format(host.fqdn, host.cluster.id, cluster.id))
         host_comp_list.append((service, host, comp))
+    return host_comp_list
 
+
+def check_hc(cluster, hc_in):
+    check_sub_key(hc_in)
+    host_comp_list = make_host_comp_list(cluster, hc_in)
     for service in ClusterObject.objects.filter(cluster=cluster):
-        cm.issue.check_component_constraint(service, [i for i in host_comp_list if i[0] == service])
+        cm.issue.check_component_constraint(
+            cluster, service.prototype, [i for i in host_comp_list if i[0] == service]
+        )
 
     cm.issue.check_component_requires(host_comp_list)
     cm.issue.check_bound_components(host_comp_list)
diff --git a/python/cm/bundle.py b/python/cm/bundle.py
index 1bf87255e1..8bd09987a5 100644
--- a/python/cm/bundle.py
+++ b/python/cm/bundle.py
@@ -278,15 +278,15 @@ def re_check_actions():
         hc = act.hostcomponentmap
         ref = f'in hc_acl of action "{act.name}" of {proto_ref(act.prototype)}'
         for item in hc:
-            sp = StagePrototype.objects.filter(type='service', name=item['service'])
+            sp = StagePrototype.objects.filter(type='service', name=item['service']).first()
             if not sp:
                 msg = 'Unknown service "{}" {}'
                 err('INVALID_ACTION_DEFINITION', msg.format(item['service'], ref))
             if not StagePrototype.objects.filter(
-                parent=sp[0], type='component', name=item['component']
+                parent=sp, type='component', name=item['component']
             ):
                 msg = 'Unknown component "{}" of service "{}" {}'
-                err('INVALID_ACTION_DEFINITION', msg.format(item['component'], sp[0].name, ref))
+                err('INVALID_ACTION_DEFINITION', msg.format(item['component'], sp.name, ref))
 
 
 def check_component_requires(comp):
diff --git a/python/cm/checker.py b/python/cm/checker.py
index 7eba6b2d31..bad32fe909 100644
--- a/python/cm/checker.py
+++ b/python/cm/checker.py
@@ -83,7 +83,7 @@ def check_match_type(match, data, data_type, path, rule, parent=None):
         raise FormatError(path, msg, data, rule, parent)
 
 
-def match_none(data, rules, rule, path, parent=None):
+def match_none(data, rules, rule, path, parent=None, is_service=False):
     if data is not None:
         msg = 'Object should be empty'
         if path:
@@ -92,35 +92,46 @@ def match_none(data, rules, rule, path, parent=None):
         raise FormatError(path, msg, data, rule, parent)
 
 
-def match_any(data, rules, rule, path, parent=None):
+def match_any(data, rules, rule, path, parent=None, is_service=False):
     pass
 
 
-def match_list(data, rules, rule, path, parent=None):
+def match_list(data, rules, rule, path, parent=None, is_service=False):
     check_match_type('match_list', data, list, path, rule, parent)
     for i, v in enumerate(data):
-        process_rule(v, rules, rules[rule]['item'], path + [('Value of list index', i)], parent)
+        process_rule(
+            v, rules, rules[rule]['item'], path + [('Value of list index', i)], parent, is_service
+        )
     return True
 
 
-def match_dict(data, rules, rule, path, parent=None):
+def match_dict(data, rules, rule, path, parent=None, is_service=False):
     check_match_type('match_dict', data, dict, path, rule, parent)
+
+    if is_service is False:
+        is_service = data.get("type") == "service"
+
     if 'required_items' in rules[rule]:
         for i in rules[rule]['required_items']:
             if i not in data:
+                if is_service and i == "service":
+                    continue
+
                 raise FormatError(path, f'There is no required key "{i}" in map.', data, rule)
+
     for k in data:
         new_path = path + [('Value of map key', k)]
         if 'items' in rules[rule] and k in rules[rule]['items']:
-            process_rule(data[k], rules, rules[rule]['items'][k], new_path, data)
+            process_rule(data[k], rules, rules[rule]['items'][k], new_path, data, is_service)
         elif 'default_item' in rules[rule]:
-            process_rule(data[k], rules, rules[rule]['default_item'], new_path, data)
+            process_rule(data[k], rules, rules[rule]['default_item'], new_path, data, is_service)
         else:
             msg = f'Map key "{k}" is not allowed here (rule "{rule}")'
+
             raise FormatError(path, msg, data, rule)
 
 
-def match_dict_key_selection(data, rules, rule, path, parent=None):
+def match_dict_key_selection(data, rules, rule, path, parent=None, is_service=False):
     check_match_type('dict_key_selection', data, dict, path, rule, parent)
     key = rules[rule]['selector']
     if key not in data:
@@ -128,20 +139,20 @@ def match_dict_key_selection(data, rules, rule, path, parent=None):
         raise FormatError(path, msg, data, rule, parent)
     value = data[key]
     if value in rules[rule]['variants']:
-        process_rule(data, rules, rules[rule]['variants'][value], path, parent)
+        process_rule(data, rules, rules[rule]['variants'][value], path, parent, is_service)
     elif 'default_variant' in rule:
-        process_rule(data, rules, rules[rule]['default_variant'], path, parent)
+        process_rule(data, rules, rules[rule]['default_variant'], path, parent, is_service)
     else:
         msg = f'Value "{value}" is not allowed for map key "{key}".'
         raise FormatError(path, msg, data, rule, parent)
 
 
-def match_one_of(data, rules, rule, path, parent=None):
+def match_one_of(data, rules, rule, path, parent=None, is_service=False):
     errors = []
     sub_errors = []
     for obj in rules[rule]['variants']:
         try:
-            process_rule(data, rules, obj, path, parent)
+            process_rule(data, rules, obj, path, parent, is_service)
         except FormatError as e:
             if e.errors:
                 sub_errors += e.errors
@@ -152,14 +163,14 @@ def match_one_of(data, rules, rule, path, parent=None):
         raise FormatError(path, msg, data, rule, parent, caused_by=errors)
 
 
-def match_set(data, rules, rule, path, parent=None):
+def match_set(data, rules, rule, path, parent=None, is_service=False):
     if data not in rules[rule]['variants']:
         msg = f'Value "{data}" not in set {rules[rule]["variants"]}'
         raise FormatError(path, msg, data, rule, parent=parent)
 
 
 def match_simple_type(obj_type):
-    def match(data, rules, rule, path, parent=None):
+    def match(data, rules, rule, path, parent=None, is_service=False):
         check_type(data, obj_type, path, rule, parent=parent)
 
     return match
@@ -182,15 +193,15 @@ def match(data, rules, rule, path, parent=None):
 
 def check_rule(rules):
     if not isinstance(rules, dict):
-        return (False, 'YSpec should be a map')
+        return False, 'YSpec should be a map'
     if 'root' not in rules:
-        return (False, 'YSpec should has "root" key')
+        return False, 'YSpec should has "root" key'
     if 'match' not in rules['root']:
-        return (False, 'YSpec should has "match" subkey of "root" key')
-    return (True, '')
+        return False, 'YSpec should has "match" subkey of "root" key'
+    return True, ''
 
 
-def process_rule(data, rules, name, path=None, parent=None):
+def process_rule(data, rules, name, path=None, parent=None, is_service=False):
     if path is None:
         path = []
     if name not in rules:
@@ -202,8 +213,7 @@ def process_rule(data, rules, name, path=None, parent=None):
     if match not in MATCH:
         raise SchemaError(f"Unknown match {match} from schema. Impossible to handle that.")
 
-    # print(f'process_rule: {MATCH[match].__name__} "{name}" data: {data}')
-    MATCH[match](data, rules, name, path=path, parent=parent)
+    MATCH[match](data, rules, name, path=path, parent=parent, is_service=is_service)
 
 
 def check(data, rules):
diff --git a/python/cm/config.py b/python/cm/config.py
index 6808f040b6..5e85a68630 100644
--- a/python/cm/config.py
+++ b/python/cm/config.py
@@ -15,6 +15,8 @@
 import sys
 from os.path import dirname
 
+ENCODING = 'utf-8'
+
 PYTHON_DIR = sys.exec_prefix
 PYTHON_EXECUTABLE = sys.executable
 PYTHON_VERSION = f'{sys.version_info.major}.{sys.version_info.minor}'
diff --git a/python/cm/errors.py b/python/cm/errors.py
index 2ec1e72dbb..8c9cf36017 100644
--- a/python/cm/errors.py
+++ b/python/cm/errors.py
@@ -167,6 +167,16 @@
         rfs.HTTP_409_CONFLICT,
         ERR,
     ),
+    'NO_CERT_FILE': (
+        'missing cert file for `ldaps://` connection',
+        rfs.HTTP_400_BAD_REQUEST,
+        ERR,
+    ),
+    'NO_LDAP_SETTINGS': (
+        'disabled ldap settings',
+        rfs.HTTP_400_BAD_REQUEST,
+        ERR,
+    ),
 }
 
 
diff --git a/python/cm/issue.py b/python/cm/issue.py
index 6a6ab21191..318829ce7c 100644
--- a/python/cm/issue.py
+++ b/python/cm/issue.py
@@ -127,14 +127,16 @@ def check_hc(cluster):
         for co in ClusterObject.objects.filter(cluster=cluster):
             for comp in Prototype.objects.filter(parent=co.prototype, type='component'):
                 const = comp.constraint
-                if len(const) == 2 and const[0] == 0 and (const[1] == '+' or const[1] == 'odd'):
+                if len(const) == 2 and const[0] == 0:
                     continue
                 log.debug('void host components for %s', proto_ref(co.prototype))
                 return False
 
     for service in ClusterObject.objects.filter(cluster=cluster):
         try:
-            check_component_constraint(service, [i for i in shc_list if i[0] == service])
+            check_component_constraint(
+                cluster, service.prototype, [i for i in shc_list if i[0] == service]
+            )
         except AdcmEx:
             return False
     try:
@@ -202,9 +204,9 @@ def get_obj_config(obj):
     return (cl.config, attr)
 
 
-def check_component_constraint(service, hc_in):
-    ref = f'in host component list for {obj_ref(service)}'
-    all_host = Host.objects.filter(cluster=service.cluster)
+def check_component_constraint(cluster, service_prototype, hc_in, old_bundle=None):
+    ref = f'in host component list for {service_prototype.type} {service_prototype.name}'
+    all_host = Host.objects.filter(cluster=cluster)
 
     def cc_err(msg):
         raise AdcmEx('COMPONENT_CONSTRAINT_ERROR', msg)
@@ -246,7 +248,17 @@ def check(comp, const):
         elif const[0] == 'odd':
             check_odd(count, const[0], comp)
 
-    for c in Prototype.objects.filter(parent=service.prototype, type='component'):
+    for c in Prototype.objects.filter(parent=service_prototype, type='component'):
+        if old_bundle:
+            try:
+                old_service_proto = Prototype.objects.get(
+                    name=service_prototype.name, type='service', bundle=old_bundle
+                )
+                Prototype.objects.get(
+                    parent=old_service_proto, bundle=old_bundle, type='component', name=c.name
+                )
+            except Prototype.DoesNotExist:
+                continue
         check(c, c.constraint)
 
 
diff --git a/python/cm/job.py b/python/cm/job.py
index ffc95442df..0c234abe03 100644
--- a/python/cm/job.py
+++ b/python/cm/job.py
@@ -15,6 +15,7 @@
 import json
 import os
 import subprocess
+import copy
 from configparser import ConfigParser
 from typing import List, Tuple, Optional, Hashable, Any, Union
 
@@ -24,7 +25,7 @@
 from cm import api, inventory, adcm_config, variant, config, issue
 from cm.adcm_config import process_file_type
 from cm.api_context import ctx
-from cm.errors import raise_AdcmEx as err
+from cm.errors import raise_AdcmEx as err, AdcmEx
 from cm.hierarchy import Tree
 from cm.inventory import get_obj_config, process_config_and_attr
 from cm.logger import log
@@ -45,6 +46,7 @@
     ServiceComponent,
     SubAction,
     TaskLog,
+    Prototype,
     get_object_cluster,
 )
 from cm.status_api import post_event
@@ -106,9 +108,12 @@ def prepare_task(
     _, spec = check_action_config(action, obj, conf, attr)
     if conf and not spec:
         err("CONFIG_VALUE_ERROR", "Absent config in action prototype")
-    host_map = check_hostcomponentmap(cluster, action, hc)
     check_action_hosts(action, obj, cluster, hosts)
     old_hc = api.get_hc(cluster)
+    host_map, post_upgrade_hc = check_hostcomponentmap(cluster, action, hc)
+
+    if hasattr(action, 'upgrade') and not action.hostcomponentmap:
+        check_constraints_for_upgrade(cluster, action.upgrade, get_actual_hc(cluster))
 
     if not attr:
         attr = {}
@@ -116,9 +121,8 @@ def prepare_task(
     with transaction.atomic():  # pylint: disable=too-many-locals
         DummyData.objects.filter(id=1).update(date=timezone.now())
 
-        task = create_task(action, obj, conf, attr, old_hc, hosts, verbose)
-
-        if host_map:
+        task = create_task(action, obj, conf, attr, old_hc, hosts, verbose, post_upgrade_hc)
+        if host_map or (hasattr(action, 'upgrade') and host_map is not None):
             api.save_hc(cluster, host_map)
 
         if conf:
@@ -232,7 +236,7 @@ def add_delta(delta, action, key, fqdn, host):
         key = cook_comp_key(service.prototype.name, comp.prototype.name)
         add_to_dict(new, key, host.fqdn, host)
 
-    if not old:
+    if old is None:
         old = {}
         for hc in HostComponent.objects.filter(cluster=cluster):
             key = cook_comp_key(hc.service.prototype.name, hc.component.prototype.name)
@@ -262,23 +266,91 @@ def add_delta(delta, action, key, fqdn, host):
     return delta
 
 
-def check_hostcomponentmap(
-    cluster: Cluster, action: Action, hc: List[dict]
-) -> List[Tuple[ClusterObject, Host, ServiceComponent]]:
+def check_hostcomponentmap(cluster: Cluster, action: Action, new_hc: List[dict]):
+
     if not action.hostcomponentmap:
-        return []
+        return None, []
 
-    if not hc:
+    if not new_hc:
         err('TASK_ERROR', 'hc is required')
 
     if not cluster:
         err('TASK_ERROR', 'Only cluster objects can have action with hostcomponentmap')
 
-    for host_comp in hc:
-        host = Host.obj.get(id=host_comp.get('host_id', 0))
-        issue.check_object_concern(host)
+    for host_comp in new_hc:
+        if not hasattr(action, 'upgrade'):
+            host = Host.obj.get(id=host_comp.get('host_id', 0))
+            issue.check_object_concern(host)
+    post_upgrade_hc, clear_hc = check_upgrade_hc(action, new_hc)
 
-    return api.check_hc(cluster, hc)
+    old_hc = get_old_hc(api.get_hc(cluster))
+    if not hasattr(action, 'upgrade'):
+        prepared_hc_list = api.check_hc(cluster, clear_hc)
+    else:
+        api.check_sub_key(clear_hc)
+        prepared_hc_list = api.make_host_comp_list(cluster, clear_hc)
+        check_constraints_for_upgrade(cluster, action.upgrade, prepared_hc_list)
+    cook_delta(cluster, prepared_hc_list, action.hostcomponentmap, old_hc)
+    return prepared_hc_list, post_upgrade_hc
+
+
+def check_constraints_for_upgrade(cluster, upgrade, host_comp_list):
+    try:
+
+        for service in ClusterObject.objects.filter(cluster=cluster):
+            try:
+                prototype = Prototype.objects.get(
+                    name=service.name, type='service', bundle=upgrade.bundle
+                )
+                issue.check_component_constraint(
+                    cluster,
+                    prototype,
+                    [i for i in host_comp_list if i[0] == service],
+                    cluster.prototype.bundle,
+                )
+            except Prototype.DoesNotExist:
+                pass
+        issue.check_component_requires(host_comp_list)
+        issue.check_bound_components(host_comp_list)
+        api.check_maintenance_mode(cluster, host_comp_list)
+    except AdcmEx as e:
+        if e.code == 'COMPONENT_CONSTRAINT_ERROR':
+            e.msg = (
+                f"Host-component map of upgraded cluster should satisfy "
+                f"constraints of new bundle. Now error is: {e.msg}"
+            )
+        err(e.code, e.msg)
+
+
+def check_upgrade_hc(action, new_hc):
+    post_upgrade_hc = []
+    clear_hc = copy.deepcopy(new_hc)
+    buff = 0
+    for host_comp in new_hc:
+        if "component_prototype_id" in host_comp:
+            if not hasattr(action, 'upgrade'):
+                err(
+                    'WRONG_ACTION_HC',
+                    'Hc map with components prototype available only in upgrade action',
+                )
+            proto = Prototype.obj.get(
+                type='component',
+                id=host_comp['component_prototype_id'],
+                bundle=action.upgrade.bundle,
+            )
+            for hc_acl in action.hostcomponentmap:
+                if proto.name == hc_acl['component']:
+                    buff += 1
+                    if hc_acl['action'] != 'add':
+                        err(
+                            'WRONG_ACTION_HC',
+                            'New components from bundle with upgrade you can only add, not remove',
+                        )
+            if buff == 0:
+                err('INVALID_INPUT', 'hc_acl doesn\'t allow actions with this component')
+            post_upgrade_hc.append(host_comp)
+            clear_hc.remove(host_comp)
+    return post_upgrade_hc, clear_hc
 
 
 def check_service_task(  # pylint: disable=inconsistent-return-statements
@@ -344,7 +416,7 @@ def get_adcm_config():
     return get_obj_config(adcm)
 
 
-def get_new_hc(cluster: Cluster):
+def get_actual_hc(cluster: Cluster):
     new_hc = []
     for hc in HostComponent.objects.filter(cluster=cluster):
         new_hc.append((hc.service, hc.host, hc.component))
@@ -379,7 +451,7 @@ def re_prepare_job(task: TaskLog, job: JobLog):
     if job.sub_action_id:
         sub_action = job.sub_action
     if action.hostcomponentmap:
-        new_hc = get_new_hc(cluster)
+        new_hc = get_actual_hc(cluster)
         old_hc = get_old_hc(task.hostcomponentmap)
         delta = cook_delta(cluster, new_hc, action.hostcomponentmap, old_hc)
     prepare_job(action, sub_action, job.pk, obj, conf, delta, hosts, task.verbose)
@@ -525,6 +597,7 @@ def create_task(
     hc: List[HostComponent],
     hosts: List[Host],
     verbose: bool,
+    post_upgrade_hc: List[dict],
 ) -> TaskLog:
     """Create task and jobs and lock objects for action"""
     task = TaskLog.objects.create(
@@ -534,6 +607,7 @@ def create_task(
         attr=attr,
         hostcomponentmap=hc,
         hosts=hosts,
+        post_upgrade_hc_map=post_upgrade_hc,
         verbose=verbose,
         start_date=timezone.now(),
         finish_date=timezone.now(),
@@ -556,8 +630,9 @@ def create_task(
             finish_date=timezone.now(),
             status=config.Job.CREATED,
         )
-        LogStorage.objects.create(job=job, name='ansible', type='stdout', format='txt')
-        LogStorage.objects.create(job=job, name='ansible', type='stderr', format='txt')
+        log_type = sub_action.script_type if sub_action else action.script_type
+        LogStorage.objects.create(job=job, name=log_type, type='stdout', format='txt')
+        LogStorage.objects.create(job=job, name=log_type, type='stderr', format='txt')
         set_job_status(job.pk, config.Job.CREATED, ctx.event)
         os.makedirs(os.path.join(config.RUN_DIR, f'{job.pk}', 'tmp'), exist_ok=True)
 
diff --git a/python/cm/management/commands/run_ldap_sync.py b/python/cm/management/commands/run_ldap_sync.py
new file mode 100644
index 0000000000..4ea3557f92
--- /dev/null
+++ b/python/cm/management/commands/run_ldap_sync.py
@@ -0,0 +1,56 @@
+# Licensed 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.
+
+from datetime import timedelta
+
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from cm.config import Job
+from cm.job import start_task
+from cm.models import ADCM, Action, ConfigLog, TaskLog
+from cm.logger import log_cron_task as log
+
+
+def get_settings(adcm_object):
+    current_configlog = ConfigLog.objects.get(
+        obj_ref=adcm_object.config, id=adcm_object.config.current
+    )
+    if current_configlog.attr['ldap_integration']['active']:
+        ldap_config = current_configlog.config['ldap_integration']
+        return ldap_config['sync_interval']
+    return 0
+
+
+class Command(BaseCommand):
+    help = 'Run synchronization with ldap if sync_interval is specified in ADCM settings'
+
+    def handle(self, *args, **options):
+        adcm_object = ADCM.objects.get(id=1)
+        action = Action.objects.get(name='run_ldap_sync', prototype=adcm_object.prototype)
+        period = get_settings(adcm_object)
+        if period <= 0:
+            return
+        if TaskLog.objects.filter(action__name='run_ldap_sync', status=Job.RUNNING).exists():
+            log.debug("Sync has already launched, we need to wait for the task end")
+            return
+        last_sync = TaskLog.objects.filter(
+            action__name='run_ldap_sync', status__in=[Job.SUCCESS, Job.FAILED]
+        ).last()
+        if last_sync is None:
+            log.debug("First ldap sync launched in %s", timezone.now())
+            start_task(action, adcm_object, {}, {}, [], [], False)
+            return
+        new_rotate_time = last_sync.finish_date + timedelta(minutes=period - 1)
+        if new_rotate_time <= timezone.now():
+            log.debug("Ldap sync launched in %s", timezone.now())
+            start_task(action, adcm_object, {}, {}, [], [], False)
diff --git a/python/cm/migrations/0089_tasklog_post_upgrade_hc_map.py b/python/cm/migrations/0089_tasklog_post_upgrade_hc_map.py
new file mode 100644
index 0000000000..323d026c8f
--- /dev/null
+++ b/python/cm/migrations/0089_tasklog_post_upgrade_hc_map.py
@@ -0,0 +1,30 @@
+# Licensed 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.
+
+# Generated by Django 3.1.2 on 2022-05-26 12:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cm', '0088_fix_group_keys_structure'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tasklog',
+            name='post_upgrade_hc_map',
+            field=models.JSONField(default=None, null=True),
+        ),
+    ]
diff --git a/python/cm/migrations/0089_rm_background_tasks_app.py b/python/cm/migrations/0090_rm_background_tasks_app.py
similarity index 94%
rename from python/cm/migrations/0089_rm_background_tasks_app.py
rename to python/cm/migrations/0090_rm_background_tasks_app.py
index a499f2df90..c9b6e42f47 100644
--- a/python/cm/migrations/0089_rm_background_tasks_app.py
+++ b/python/cm/migrations/0090_rm_background_tasks_app.py
@@ -18,7 +18,7 @@
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('cm', '0088_fix_group_keys_structure'),
+        ('cm', '0089_tasklog_post_upgrade_hc_map'),
     ]
 
     operations = [
diff --git a/python/cm/migrations/0090_migrate_adcm_logrotate_config.py b/python/cm/migrations/0091_migrate_adcm_logrotate_config.py
similarity index 96%
rename from python/cm/migrations/0090_migrate_adcm_logrotate_config.py
rename to python/cm/migrations/0091_migrate_adcm_logrotate_config.py
index f41fedeccf..f72b482580 100644
--- a/python/cm/migrations/0090_migrate_adcm_logrotate_config.py
+++ b/python/cm/migrations/0091_migrate_adcm_logrotate_config.py
@@ -43,7 +43,7 @@ def migrate_logrotate_config(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('cm', '0089_rm_background_tasks_app'),
+        ('cm', '0090_rm_background_tasks_app'),
     ]
 
     operations = [
diff --git a/python/cm/models.py b/python/cm/models.py
index bd115e86d8..156c648e98 100644
--- a/python/cm/models.py
+++ b/python/cm/models.py
@@ -226,22 +226,6 @@ def get_default_from_edition():
     return ['community']
 
 
-class Upgrade(ADCMModel):
-    bundle = models.ForeignKey(Bundle, on_delete=models.CASCADE)
-    name = models.CharField(max_length=160, blank=True)
-    description = models.TextField(blank=True)
-    min_version = models.CharField(max_length=80)
-    max_version = models.CharField(max_length=80)
-    from_edition = models.JSONField(default=get_default_from_edition)
-    min_strict = models.BooleanField(default=False)
-    max_strict = models.BooleanField(default=False)
-    state_available = models.JSONField(default=list)
-    state_on_success = models.CharField(max_length=64, blank=True)
-    action = models.OneToOneField('Action', on_delete=models.CASCADE, null=True)
-
-    __error_code__ = 'UPGRADE_NOT_FOUND'
-
-
 MONITORING_TYPE = (
     ('active', 'active'),
     ('passive', 'passive'),
@@ -534,10 +518,39 @@ def content_type(self):
 
     def delete(self, using=None, keep_parents=False):
         super().delete(using, keep_parents)
-        if self.config is not None:
+        if self.config is not None and not isinstance(self, ServiceComponent):
             self.config.delete()
 
 
+class Upgrade(ADCMModel):
+    bundle = models.ForeignKey(Bundle, on_delete=models.CASCADE)
+    name = models.CharField(max_length=160, blank=True)
+    description = models.TextField(blank=True)
+    min_version = models.CharField(max_length=80)
+    max_version = models.CharField(max_length=80)
+    from_edition = models.JSONField(default=get_default_from_edition)
+    min_strict = models.BooleanField(default=False)
+    max_strict = models.BooleanField(default=False)
+    state_available = models.JSONField(default=list)
+    state_on_success = models.CharField(max_length=64, blank=True)
+    action = models.OneToOneField('Action', on_delete=models.CASCADE, null=True)
+
+    __error_code__ = 'UPGRADE_NOT_FOUND'
+
+    def allowed(self, obj: ADCMEntity) -> bool:
+        """Check if upgrade is allowed to be run on object"""
+        if self.state_available:
+            available = self.state_available
+            if obj.state in available:
+                return True
+            elif available == 'any':
+                return True
+            else:
+                return False
+        else:
+            return self.action.allowed(obj)
+
+
 class ADCM(ADCMEntity):
     name = models.CharField(max_length=16, choices=(('ADCM', 'ADCM'),), unique=True)
 
@@ -1306,6 +1319,7 @@ class TaskLog(ADCMModel):
     config = models.JSONField(null=True, default=None)
     attr = models.JSONField(default=dict)
     hostcomponentmap = models.JSONField(null=True, default=None)
+    post_upgrade_hc_map = models.JSONField(null=True, default=None)
     hosts = models.JSONField(null=True, default=None)
     verbose = models.BooleanField(default=False)
     start_date = models.DateTimeField()
@@ -1342,7 +1356,7 @@ def unlock_affected(self) -> None:
         self.save()
         lock.delete()
 
-    def cancel(self, event_queue: 'cm.status_api.Event' = None):
+    def cancel(self, event_queue: 'cm.status_api.Event' = None, obj_deletion=False):
         """
         Cancel running task process
         task status will be updated in separate process of task runner
@@ -1358,7 +1372,7 @@ def cancel(self, event_queue: 'cm.status_api.Event' = None):
             Job.SUCCESS: ('TASK_IS_SUCCESS', f'task #{self.pk} is success'),
         }
         action = self.action
-        if action and not action.allow_to_terminate:
+        if action and not action.allow_to_terminate and not obj_deletion:
             raise AdcmEx(
                 'NOT_ALLOWED_TERMINATION',
                 f'not allowed termination task #{self.pk} for action #{action.pk}',
@@ -1376,6 +1390,12 @@ def cancel(self, event_queue: 'cm.status_api.Event' = None):
             event_queue.send_state()
         os.kill(self.pid, signal.SIGTERM)
 
+    @staticmethod
+    def get_adcm_tasks_qs():
+        return TaskLog.objects.filter(
+            object_type=ContentType.objects.get(app_label='cm', model='adcm')
+        )
+
 
 class JobLog(ADCMModel):
     task = models.ForeignKey(TaskLog, on_delete=models.SET_NULL, null=True, default=None)
@@ -1390,6 +1410,10 @@ class JobLog(ADCMModel):
 
     __error_code__ = 'JOB_NOT_FOUND'
 
+    @staticmethod
+    def get_adcm_jobs_qs():
+        return JobLog.objects.filter(task__in=TaskLog.get_adcm_tasks_qs())
+
 
 class GroupCheckLog(ADCMModel):
     job = models.ForeignKey(JobLog, on_delete=models.SET_NULL, null=True, default=None)
diff --git a/python/cm/stack.py b/python/cm/stack.py
index a98204bb69..9988befe28 100644
--- a/python/cm/stack.py
+++ b/python/cm/stack.py
@@ -409,10 +409,11 @@ def save_sub_actions(conf, action):
 def save_actions(proto, conf, bundle_hash):
     if in_dict(conf, 'versions'):
         conf['type'] = 'task'
-        upg_name = conf['name']
-        conf['display_name'] = f'Upgrade: {upg_name}'
-        name = re.sub(r'\s+', '_', upg_name).strip().lower()
-        action_name = f"{proto.name}_{proto.version}_{proto.edition}_upgrade_{name}"
+        upgrade_name = conf['name']
+        conf['display_name'] = f'Upgrade: {upgrade_name}'
+        action_name = f"{proto.name}_{proto.version}_{proto.edition}_upgrade_{upgrade_name}"
+        action_name = re.sub(r'\s+', '_', action_name).strip().lower()
+        action_name = re.sub(r'\(|\)', '', action_name)
         upgrade_action = save_action(proto, conf, bundle_hash, action_name)
         return upgrade_action
     if not in_dict(conf, 'actions'):
diff --git a/python/cm/upgrade.py b/python/cm/upgrade.py
index 41a5b5cb46..fc06009067 100644
--- a/python/cm/upgrade.py
+++ b/python/cm/upgrade.py
@@ -20,7 +20,7 @@
 import cm.job
 import cm.status_api
 from cm.adcm_config import proto_ref, obj_ref, switch_config, make_object_config
-from cm.api import check_license, version_in
+from cm.api import check_license, version_in, add_components_to_service, add_service_to_cluster
 from cm.errors import raise_AdcmEx as err
 from cm.logger import log
 from cm.models import (
@@ -50,14 +50,16 @@ def switch_object(obj: Union[Host, ClusterObject], new_prototype: Prototype) ->
 def switch_services(upgrade: Upgrade, cluster: Cluster) -> None:
     """Upgrade services and component"""
 
-    for prototype in Prototype.objects.filter(bundle=upgrade.bundle, type='service'):
+    for service in ClusterObject.objects.filter(cluster=cluster):
         try:
-            co = ClusterObject.objects.get(cluster=cluster, prototype__name=prototype.name)
-            switch_object(co, prototype)
-            switch_components(cluster, co, prototype)
-        except ClusterObject.DoesNotExist:
-            # co.delete() ?!
-            pass
+            prototype = Prototype.objects.get(
+                bundle=upgrade.bundle, type='service', name=service.prototype.name
+            )
+            switch_object(service, prototype)
+            switch_components(cluster, service, prototype)
+        except Prototype.DoesNotExist:
+            service.delete()
+
     switch_hc(cluster, upgrade)
 
 
@@ -68,13 +70,10 @@ def switch_components(cluster: Cluster, co: ClusterObject, new_co_proto: Prototy
             new_sc_prototype = Prototype.objects.get(
                 parent=new_co_proto, type='component', name=sc.prototype.name
             )
-            old_sc_prototype = sc.prototype
-            sc.prototype = new_sc_prototype
-            sc.save()
-            switch_config(sc, new_sc_prototype, old_sc_prototype)
+            switch_object(sc, new_sc_prototype)
         except Prototype.DoesNotExist:
-            # sc.delete() ?!
-            pass
+            sc.delete()
+
     for sc_proto in Prototype.objects.filter(parent=new_co_proto, type='component'):
         kwargs = dict(cluster=cluster, service=co, prototype=sc_proto)
         if not ServiceComponent.objects.filter(**kwargs).exists():
@@ -124,15 +123,8 @@ def check_upgrade_edition(obj: Union[Cluster, HostProvider], upgrade: Upgrade) -
 def check_upgrade_state(obj: Union[Cluster, HostProvider], upgrade: Upgrade) -> Tuple[bool, str]:
     if obj.locked:
         return False, 'object is locked'
-    if upgrade.state_available:
-        available = upgrade.state_available
-        if obj.state in available:
-            return True, ''
-        elif available == 'any':
-            return True, ''
-        else:
-            msg = '{} state "{}" is not in available states list: {}'
-            return False, msg.format(obj.prototype.type, obj.state, available)
+    if upgrade.allowed(obj):
+        return True, ''
     else:
         return False, 'no available states'
 
@@ -270,7 +262,8 @@ def rpm_cmp_reverse(obj1, obj2):
         ok, _msg = check_upgrade_state(obj, upg)
         upg.upgradable = bool(ok)
         upg.license = upg.bundle.license
-        res.append(upg)
+        if upg.upgradable:
+            res.append(upg)
 
     cm.issue.update_hierarchy_issues(obj)
     if order:
@@ -284,7 +277,30 @@ def rpm_cmp_reverse(obj1, obj2):
         return res
 
 
-def do_upgrade(obj: Union[Cluster, HostProvider], upgrade: Upgrade, config: dict) -> dict:
+def update_components_after_bundle_switch(cluster, upgrade):
+    if upgrade.action and upgrade.action.hostcomponentmap:
+        log.info('update component from %s after upgrade with hc_acl', cluster)
+        for hc_acl in upgrade.action.hostcomponentmap:
+            proto_service = Prototype.objects.filter(
+                type='service', bundle=upgrade.bundle, name=hc_acl['service']
+            ).first()
+            if not proto_service:
+                continue
+            try:
+                service = ClusterObject.objects.get(cluster=cluster, prototype=proto_service)
+                if not ServiceComponent.objects.filter(cluster=cluster, service=service).exists():
+                    add_components_to_service(cluster, service)
+            except ClusterObject.DoesNotExist:
+                add_service_to_cluster(cluster, proto_service)
+
+
+def do_upgrade(
+    obj: Union[Cluster, HostProvider],
+    upgrade: Upgrade,
+    config: dict,
+    attr: dict,
+    hc: list,
+) -> dict:
     old_proto = obj.prototype
     check_license(obj.prototype.bundle)
     check_license(upgrade.bundle)
@@ -301,7 +317,7 @@ def do_upgrade(obj: Union[Cluster, HostProvider], upgrade: Upgrade, config: dict
             obj.state = upgrade.state_on_success
             obj.save()
     else:
-        task = cm.job.start_task(upgrade.action, obj, config, {}, [], [], False)
+        task = cm.job.start_task(upgrade.action, obj, config, attr, hc, [], False)
         task_id = task.id
 
     obj.refresh_from_db()
@@ -334,7 +350,8 @@ def bundle_switch(obj: Union[Cluster, HostProvider], upgrade: Upgrade):
         elif obj.prototype.type == 'provider':
             switch_hosts(upgrade, obj)
         cm.issue.update_hierarchy_issues(obj)
-
+        if isinstance(obj, Cluster):
+            update_components_after_bundle_switch(obj, upgrade)
     log.info('upgrade %s OK to version %s', obj_ref(obj), obj.prototype.version)
     cm.status_api.post_event(
         'upgrade', obj.prototype.type, obj.id, 'version', str(obj.prototype.version)
diff --git a/python/job_runner.py b/python/job_runner.py
index 5598fb0df4..10c9a888a3 100755
--- a/python/job_runner.py
+++ b/python/job_runner.py
@@ -17,16 +17,17 @@
 import os
 import subprocess
 import sys
+from django.db import transaction
 
 import adcm.init_django  # DO NOT DELETE !!!
+import cm.job
 from cm import config
 from cm.ansible_plugin import finish_check
-import cm.job
+from cm.errors import AdcmEx
 from cm.logger import log
-from cm.models import LogStorage, JobLog
+from cm.models import LogStorage, JobLog, Prototype, ServiceComponent
 from cm.status_api import Event
 from cm.upgrade import bundle_switch
-from cm.errors import AdcmEx
 
 
 def open_file(root, tag, job_id):
@@ -101,15 +102,38 @@ def get_venv(job_id: int) -> str:
     return JobLog.objects.get(id=job_id).action.venv
 
 
+def process_err_out_file(job_id, job_type):
+    out_file = open_file(config.RUN_DIR, f'{job_type}-stdout', job_id)
+    err_file = open_file(config.RUN_DIR, f'{job_type}-stderr', job_id)
+    post_log(job_id, 'stdout', f'{job_type}')
+    post_log(job_id, 'stderr', f'{job_type}')
+    return out_file, err_file
+
+
+def start_subprocess(job_id, cmd, conf, out_file, err_file):
+    event = Event()
+    log.info("job run cmd: %s", ' '.join(cmd))
+    proc = subprocess.Popen(cmd, env=env_configuration(conf), stdout=out_file, stderr=err_file)
+    cm.job.set_job_status(job_id, config.Job.RUNNING, event, proc.pid)
+    event.send_state()
+    log.info("run job #%s, pid %s", job_id, proc.pid)
+    ret = proc.wait()
+    finish_check(job_id)
+    ret = set_job_status(job_id, ret, proc.pid, event)
+    event.send_state()
+
+    out_file.close()
+    err_file.close()
+
+    log.info("finish job subprocess #%s, pid %s, ret %s", job_id, proc.pid, ret)
+    return ret
+
+
 def run_ansible(job_id):
-    log.debug("job_runner.py called as: %s", sys.argv)
+    log.debug("job_runner.py starts to run ansible job %s", job_id)
     conf = read_config(job_id)
     playbook = conf['job']['playbook']
-    out_file = open_file(config.RUN_DIR, 'ansible-stdout', job_id)
-    err_file = open_file(config.RUN_DIR, 'ansible-stderr', job_id)
-    post_log(job_id, 'stdout', 'ansible')
-    post_log(job_id, 'stderr', 'ansible')
-    event = Event()
+    out_file, err_file = process_err_out_file(job_id, 'ansible')
 
     os.chdir(conf['env']['stack_dir'])
     cmd = [
@@ -129,37 +153,70 @@ def run_ansible(job_id):
             cmd.append('--tags=' + conf['job']['params']['ansible_tags'])
     if 'verbose' in conf['job'] and conf['job']['verbose']:
         cmd.append('-vvvv')
+    ret = start_subprocess(job_id, cmd, conf, out_file, err_file)
+    sys.exit(ret)
 
-    log.info("job run cmd: %s", ' '.join(cmd))
-    proc = subprocess.Popen(cmd, env=env_configuration(conf), stdout=out_file, stderr=err_file)
-    cm.job.set_job_status(job_id, config.Job.RUNNING, event, proc.pid)
-    event.send_state()
-    log.info("run ansible job #%s, pid %s, playbook %s", job_id, proc.pid, playbook)
-    ret = proc.wait()
-    finish_check(job_id)
-    ret = set_job_status(job_id, ret, proc.pid, event)
-    event.send_state()
 
+def run_upgrade(job):
+    event = Event()
+    cm.job.set_job_status(job.id, config.Job.RUNNING, event)
+    out_file, err_file = process_err_out_file(job.id, 'internal')
+    try:
+        with transaction.atomic():
+            bundle_switch(job.task.task_object, job.action.upgrade)
+            switch_hc(job.task, job.action)
+    except AdcmEx as e:
+        err_file.write(e.msg)
+        cm.job.set_job_status(job.id, config.Job.FAILED, event)
+        out_file.close()
+        err_file.close()
+        sys.exit(1)
+    cm.job.set_job_status(job.id, config.Job.SUCCESS, event)
+    event.send_state()
     out_file.close()
     err_file.close()
+    sys.exit(0)
+
 
-    log.info("finish ansible job #%s, pid %s, ret %s", job_id, proc.pid, ret)
+def run_python(job):
+    out_file, err_file = process_err_out_file(job.id, 'python')
+    conf = read_config(job.id)
+    script_path = conf['job']['playbook']
+    os.chdir(conf['env']['stack_dir'])
+    cmd = ['python', script_path]
+    ret = start_subprocess(job.id, cmd, conf, out_file, err_file)
     sys.exit(ret)
 
 
+def switch_hc(task, action):
+    cluster = task.task_object
+    old_hc = cm.api.get_hc(cluster)
+    new_hc = []
+    for hc in [*task.post_upgrade_hc_map, *old_hc]:
+        if hc not in new_hc:
+            new_hc.append(hc)
+    task.hostcomponentmap = old_hc
+    task.post_upgrade_hc_map = None
+    task.save()
+    for hc in new_hc:
+        if "component_prototype_id" in hc:
+            proto = Prototype.objects.get(type='component', id=hc.pop('component_prototype_id'))
+            comp = ServiceComponent.objects.get(cluster=cluster, prototype=proto)
+            hc['component_id'] = comp.id
+            hc['service_id'] = comp.service.id
+    host_map, _ = cm.job.check_hostcomponentmap(cluster, action, new_hc)
+    if host_map is not None:
+        cm.api.save_hc(cluster, host_map)
+
+
 def main(job_id):
+    log.debug("job_runner.py called as: %s", sys.argv)
     job = JobLog.objects.get(id=job_id)
-    if job.sub_action and job.sub_action.script_type == 'internal':
-        event = Event()
-        cm.job.set_job_status(job_id, config.Job.RUNNING, event)
-        try:
-            bundle_switch(job.task.task_object, job.action.upgrade)
-        except AdcmEx:
-            cm.job.set_job_status(job_id, config.Job.FAILED, event)
-            sys.exit(1)
-        cm.job.set_job_status(job_id, config.Job.SUCCESS, event)
-        event.send_state()
-        sys.exit(0)
+    job_type = job.sub_action.script_type if job.sub_action else job.action.script_type
+    if job_type == 'internal':
+        run_upgrade(job)
+    elif job_type == 'python':
+        run_python(job)
     else:
         run_ansible(job_id)
 
diff --git a/python/rbac/endpoints/group/views.py b/python/rbac/endpoints/group/views.py
index 34e52e0018..02a4fcea47 100644
--- a/python/rbac/endpoints/group/views.py
+++ b/python/rbac/endpoints/group/views.py
@@ -13,9 +13,11 @@
 """Group view sets"""
 
 from adwp_base.errors import AdwpEx
+from django_filters.rest_framework import FilterSet, CharFilter, DjangoFilterBackend
 from guardian.mixins import PermissionListMixin
 from rest_flex_fields.serializers import FlexFieldsSerializerMixin
 from rest_framework import serializers, status
+from rest_framework.filters import OrderingFilter
 from rest_framework.permissions import DjangoModelPermissions
 from rest_framework.viewsets import ModelViewSet
 
@@ -71,13 +73,14 @@ class GroupSerializer(FlexFieldsSerializerMixin, serializers.Serializer):
     """
 
     id = serializers.IntegerField(read_only=True)
-    name = serializers.RegexField(r'^[^\n]+$', max_length=150)
+    name = serializers.RegexField(r'^[^\n]+$', max_length=150, source='name_to_display')
     description = serializers.CharField(
         max_length=255, allow_blank=True, required=False, default=''
     )
     user = UserSerializer(many=True, required=False, source='user_set')
     url = serializers.HyperlinkedIdentityField(view_name='rbac:group-detail')
     built_in = serializers.BooleanField(read_only=True)
+    type = serializers.CharField(read_only=True)
 
     class Meta:
         expandable_fields = {'user': (ExpandedUserSerializer, {'many': True, 'source': 'user_set'})}
@@ -89,6 +92,34 @@ def create(self, validated_data):
         return group_services.create(**validated_data)
 
 
+class GroupFilterSet(FilterSet):
+    name = CharFilter(field_name='display_name', label='name')
+
+    class Meta:
+        model = models.Group
+        fields = ('id', 'type')
+
+
+class GroupOrderingFilter(OrderingFilter):
+    def filter_queryset(self, request, queryset, view):
+        ordering = self.get_ordering(request, queryset, view)
+
+        if not ordering:
+            return queryset
+
+        fix_ordering = []
+
+        for field in ordering:
+            if field == '-name':
+                fix_ordering.append('-display_name')
+                continue
+            if field == 'name':
+                fix_ordering.append('display_name')
+                continue
+            fix_ordering.append(field)
+        return queryset.order_by(*fix_ordering)
+
+
 class GroupViewSet(PermissionListMixin, ModelViewSet):  # pylint: disable=too-many-ancestors
     """Group view set"""
 
@@ -96,9 +127,10 @@ class GroupViewSet(PermissionListMixin, ModelViewSet):  # pylint: disable=too-ma
     serializer_class = GroupSerializer
     permission_classes = (DjangoModelPermissions,)
     permission_required = ['rbac.view_group']
-    filterset_fields = ('id', 'name')
+    filter_backends = (DjangoFilterBackend, GroupOrderingFilter)
+    filterset_class = GroupFilterSet
     ordering_fields = ('id', 'name')
-    search_fields = ('name', 'description')
+    search_fields = ('name', 'description', 'display_name')
 
     def destroy(self, request, *args, **kwargs):
         instance = self.get_object()
diff --git a/python/rbac/endpoints/me/views.py b/python/rbac/endpoints/me/views.py
index d5968e6bd0..44c6c790da 100644
--- a/python/rbac/endpoints/me/views.py
+++ b/python/rbac/endpoints/me/views.py
@@ -40,6 +40,8 @@ class UserSerializer(serializers.Serializer):
     is_superuser = serializers.BooleanField(read_only=True)
     password = PasswordField(trim_whitespace=False)
     profile = serializers.JSONField(required=False, default='')
+    type = serializers.CharField(read_only=True)
+    is_active = serializers.BooleanField(read_only=True)
 
     def update(self, instance, validated_data):
         context_user = self.context['request'].user
diff --git a/python/rbac/endpoints/user/views.py b/python/rbac/endpoints/user/views.py
index f79f412c51..c02db4253c 100644
--- a/python/rbac/endpoints/user/views.py
+++ b/python/rbac/endpoints/user/views.py
@@ -47,6 +47,7 @@ class ExpandedGroupSerializer(FlexFieldsSerializerMixin, serializers.ModelSerial
 
     user = GroupUserSerializer(many=True, source='user_set')
     url = serializers.HyperlinkedIdentityField(view_name='rbac:group-detail')
+    name = serializers.CharField(max_length=150, source='group.display_name')
 
     class Meta:
         model = models.Group
@@ -85,6 +86,8 @@ class UserSerializer(FlexFieldsSerializerMixin, serializers.Serializer):
     profile = serializers.JSONField(required=False, default='')
     group = GroupSerializer(many=True, required=False, source='groups')
     built_in = serializers.BooleanField(read_only=True)
+    type = serializers.CharField(read_only=True)
+    is_active = serializers.BooleanField(read_only=True)
 
     class Meta:
         expandable_fields = {'group': (ExpandedGroupSerializer, {'many': True, 'source': 'groups'})}
diff --git a/python/rbac/ldap.py b/python/rbac/ldap.py
new file mode 100644
index 0000000000..ce48774af2
--- /dev/null
+++ b/python/rbac/ldap.py
@@ -0,0 +1,261 @@
+# Licensed 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 os
+from contextlib import contextmanager, suppress
+
+import ldap
+from django.contrib.auth.models import Group as DjangoGroup
+from django.db.transaction import atomic
+from django_auth_ldap.backend import LDAPBackend
+from django_auth_ldap.config import LDAPSearch, MemberDNGroupType
+
+from cm.adcm_config import ansible_decrypt
+from cm.logger import log
+from cm.models import ADCM, ConfigLog
+from rbac.models import User, Group, OriginType
+
+
+CERT_ENV_KEY = 'LDAPTLS_CACERT'
+
+
+def _process_extra_filter(filterstr: str) -> str:
+    filterstr = filterstr or ''
+    if filterstr == '':
+        return filterstr
+
+    # simple single filter ex: `primaryGroupID=513`
+    if '(' not in filterstr and ')' not in filterstr:
+        return f'({filterstr})'
+    else:
+        # assume that composed filter is syntactically valid
+        return filterstr
+
+
+def configure_tls(enabled, cert_filepath='', conn=None):
+    os.environ.pop(CERT_ENV_KEY, None)
+    ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+
+    if not enabled:
+        return None
+
+    if cert_filepath:
+        os.environ[CERT_ENV_KEY] = cert_filepath
+
+    opts = {
+        ldap.OPT_X_TLS_CACERTFILE: cert_filepath,
+        ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_ALLOW,
+        ldap.OPT_X_TLS_NEWCTX: 0,
+    }
+
+    if not conn:
+        return opts
+
+    for opt_key, opt_val in opts.items():
+        conn.set_option(opt_key, opt_val)
+    return None
+
+
+def is_tls(ldap_uri):
+    if 'ldaps://' in ldap_uri.lower():
+        return True
+    return False
+
+
+def _get_ldap_default_settings():
+    configure_tls(enabled=False)
+
+    adcm_object = ADCM.objects.get(id=1)
+    current_configlog = ConfigLog.objects.get(
+        obj_ref=adcm_object.config, id=adcm_object.config.current
+    )
+
+    if current_configlog.attr['ldap_integration']['active']:
+        ldap_config = current_configlog.config['ldap_integration']
+
+        user_search = LDAPSearch(
+            base_dn=ldap_config['user_search_base'],
+            scope=ldap.SCOPE_SUBTREE,
+            filterstr=f'(&'
+            f'(objectClass={ldap_config.get("user_object_class") or "*"})'
+            f'({ldap_config["user_name_attribute"]}=%(user)s)'
+            f'{_process_extra_filter(ldap_config.get("user_search_filter"))}'
+            f')',
+        )
+        group_search = LDAPSearch(
+            base_dn=ldap_config['group_search_base'],
+            scope=ldap.SCOPE_SUBTREE,
+            filterstr=f'(objectClass={ldap_config.get("group_object_class") or "*"})'
+            f'{_process_extra_filter(ldap_config.get("group_search_filter"))}',
+        )
+        user_attr_map = {
+            "username": ldap_config["user_name_attribute"],
+            "first_name": 'givenName',
+            "last_name": "sn",
+            "email": "mail",
+        }
+        group_type = MemberDNGroupType(
+            member_attr=ldap_config['group_member_attribute_name'],
+            name_attr=ldap_config['group_name_attribute'],
+        )
+
+        default_settings = {
+            'SERVER_URI': ldap_config['ldap_uri'],
+            'BIND_DN': ldap_config['ldap_user'],
+            'BIND_PASSWORD': ansible_decrypt(ldap_config['ldap_password']),
+            'USER_SEARCH': user_search,
+            'USER_OBJECT_CLASS': ldap_config.get("user_object_class", "*"),
+            'USER_FILTER': _process_extra_filter(ldap_config.get("user_search_filter", '')),
+            'GROUP_SEARCH': group_search,
+            'GROUP_TYPE': group_type,
+            'GROUP_FILTER': _process_extra_filter(ldap_config.get("group_search_filter", '')),
+            'GROUP_OBJECT_CLASS': ldap_config.get("group_object_class", "*"),
+            'USER_ATTR_MAP': user_attr_map,
+            'MIRROR_GROUPS': True,
+            'ALWAYS_UPDATE_USER': True,
+            'FIND_GROUP_PERMS': True,
+            'CACHE_TIMEOUT': 3600,
+        }
+
+        if is_tls(ldap_config['ldap_uri']):
+            cert_filepath = ldap_config.get('tls_ca_cert_file', '')
+            if not cert_filepath or not os.path.exists(cert_filepath):
+                msg = 'NO_CERT_FILE'
+                log.warning(msg)
+                return {}, msg
+            connection_options = configure_tls(enabled=True, cert_filepath=cert_filepath)
+            default_settings.update({'CONNECTION_OPTIONS': connection_options})
+
+        return default_settings, None
+
+    return {}, 'NO_LDAP_SETTINGS'
+
+
+class CustomLDAPBackend(LDAPBackend):
+    def __init__(self):
+        self.default_settings = {}
+        self.is_tls = False
+
+    def authenticate_ldap_user(self, ldap_user, password):
+        self.default_settings, _ = _get_ldap_default_settings()
+        if not self.default_settings:
+            return None
+        self.is_tls = is_tls(self.default_settings['SERVER_URI'])
+
+        try:
+            if not self.__check_user(ldap_user):
+                return None
+            # pylint: disable=protected-access
+            user_local_groups = self.__get_local_groups_by_username(ldap_user._username)
+            user_or_none = super().authenticate_ldap_user(ldap_user, password)
+        except Exception as e:  # pylint: disable=broad-except
+            log.exception(e)
+            return None
+
+        if isinstance(user_or_none, User):
+            user_or_none.type = OriginType.LDAP
+            user_or_none.save()
+            self.__process_groups(user_or_none, user_local_groups)
+
+        return user_or_none
+
+    @staticmethod
+    def __get_local_groups_by_username(username):
+        groups = []
+        with suppress(User.DoesNotExist):
+            user = User.objects.get(username__iexact=username, type=OriginType.LDAP)
+            groups = [g.group for g in user.groups.all() if g.group.type == OriginType.Local]
+        return groups
+
+    def get_user_model(self):
+        return User
+
+    @contextmanager
+    def __ldap_connection(self):
+        ldap.set_option(ldap.OPT_REFERRALS, 0)
+        conn = ldap.initialize(self.default_settings['SERVER_URI'])
+        conn.protocol_version = ldap.VERSION3
+        configure_tls(self.is_tls, os.environ.get(CERT_ENV_KEY, ''), conn)
+        conn.simple_bind_s(self.default_settings['BIND_DN'], self.default_settings['BIND_PASSWORD'])
+        try:
+            yield conn
+        finally:
+            conn.unbind_s()
+
+    def __get_groups_by_group_search(self):
+        with self.__ldap_connection() as conn:
+            groups = self.default_settings['GROUP_SEARCH'].execute(conn)
+        log.debug('Found %s groups: %s', len(groups), [i[0] for i in groups])
+        return groups
+
+    def __process_groups(self, user, additional_groups=()):
+        ldap_groups = list(zip(user.ldap_user.group_names, user.ldap_user.group_dns))
+
+        # ladp-backend managed auth_groups
+        for group in user.groups.filter(name__in=[i[0] for i in ldap_groups]):
+            ldap_group_dn = self.__get_ldap_group_dn(group.name, ldap_groups)
+            rbac_group = self.__get_rbac_group(group, ldap_group_dn)
+            group.user_set.remove(user)
+            rbac_group.user_set.add(user)
+            if group.user_set.count() == 0:
+                group.delete()
+        for g in additional_groups:
+            g.user_set.add(user)
+
+    def __check_user(self, ldap_user):
+        user_dn = ldap_user.dn
+        username = ldap_user._username  # pylint: disable=protected-access
+
+        if User.objects.filter(username__iexact=username, type=OriginType.Local).exists():
+            log.exception('usernames collision: `%s`', username)
+            return False
+
+        group_member_attr = self.default_settings['GROUP_TYPE'].member_attr
+        for _, group_attrs in self.__get_groups_by_group_search():
+            if user_dn.lower() in [i.lower() for i in group_attrs.get(group_member_attr, [])]:
+                break
+        else:
+            return False
+
+        return True
+
+    @staticmethod
+    def __get_ldap_group_dn(group_name: str, ldap_groups: list) -> str:
+        group_dn = ''
+        with suppress(IndexError):
+            group_dn = [i for i in ldap_groups if i[0] == group_name][0][1]
+        return group_dn
+
+    @staticmethod
+    def __get_rbac_group(group, ldap_group_dn):
+        """
+        Get corresponding rbac_group for auth_group or create `ldap` type rbac_group if not exists
+        """
+        if isinstance(group, Group):
+            return group
+        elif isinstance(group, DjangoGroup):
+            try:
+                # maybe we'll need more accurate filtering here
+                return Group.objects.get(
+                    name=f'{group.name} [{OriginType.LDAP.value}]', type=OriginType.LDAP.value
+                )
+            except Group.DoesNotExist:
+                with atomic():
+                    rbac_group = Group.objects.create(
+                        name=group.name,
+                        type=OriginType.LDAP,
+                        description=ldap_group_dn,
+                    )
+                    return rbac_group
+        else:
+            raise ValueError('wrong group type')
diff --git a/python/rbac/migrations/0003_user_group_origin_type.py b/python/rbac/migrations/0003_user_group_origin_type.py
new file mode 100755
index 0000000000..1338c21fd3
--- /dev/null
+++ b/python/rbac/migrations/0003_user_group_origin_type.py
@@ -0,0 +1,39 @@
+# Licensed 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.
+
+# Generated by Django 3.2.13 on 2022-06-22 08:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('rbac', '0002_rm_default_policy'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='group',
+            name='type',
+            field=models.CharField(
+                choices=[('local', 'local'), ('ldap', 'ldap')], default='local', max_length=16
+            ),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='type',
+            field=models.CharField(
+                choices=[('local', 'local'), ('ldap', 'ldap')], default='local', max_length=16
+            ),
+        ),
+    ]
diff --git a/python/rbac/migrations/0004_fill_group_name_display_name.py b/python/rbac/migrations/0004_fill_group_name_display_name.py
new file mode 100755
index 0000000000..532668394d
--- /dev/null
+++ b/python/rbac/migrations/0004_fill_group_name_display_name.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.13 on 2022-06-29 09:01
+
+from django.db import migrations, models
+
+
+def fill_name_display_name(apps, schema_editor):
+    Group = apps.get_model('rbac', 'Group')
+    for g in Group.objects.all():
+        g.display_name = g.name
+        g.name = f'{g.name} [{g.type}]'
+        g.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('rbac', '0003_user_group_origin_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='group',
+            name='display_name',
+            field=models.CharField(max_length=150, null=True),
+        ),
+        migrations.RunPython(fill_name_display_name),
+    ]
diff --git a/python/rbac/migrations/0005_rbac_group_unique_display_name_type_constraint.py b/python/rbac/migrations/0005_rbac_group_unique_display_name_type_constraint.py
new file mode 100755
index 0000000000..0fc76efd1d
--- /dev/null
+++ b/python/rbac/migrations/0005_rbac_group_unique_display_name_type_constraint.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.13 on 2022-07-14 10:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('rbac', '0004_fill_group_name_display_name'),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name='group',
+            constraint=models.UniqueConstraint(
+                fields=('display_name', 'type'), name='unique_display_name_type'
+            ),
+        ),
+    ]
diff --git a/python/rbac/models.py b/python/rbac/models.py
index 5fea5d9648..c895e0890e 100644
--- a/python/rbac/models.py
+++ b/python/rbac/models.py
@@ -13,17 +13,21 @@
 """RBAC models"""
 
 import importlib
+import re
 
 from adwp_base.errors import raise_AdwpEx as err
 from django.contrib.auth.models import User as AuthUser, Group as AuthGroup, Permission
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
+from django.db.models.signals import pre_save
 from django.db.transaction import atomic
+from django.dispatch import receiver
+from django.utils import timezone
 from guardian.models import UserObjectPermission, GroupObjectPermission
 from rest_framework.exceptions import ValidationError
 
-from cm.models import Bundle, ProductCategory, HostComponent
+from cm.models import Bundle, ProductCategory, HostComponent, DummyData
 
 
 class ObjectType(models.TextChoices):
@@ -41,6 +45,11 @@ def validate_object_type(value):
         raise ValidationError('Not a valid object type.')
 
 
+class OriginType(models.TextChoices):
+    Local = 'local', 'local'
+    LDAP = 'ldap', 'ldap'
+
+
 class User(AuthUser):
     """
     Beware the Multi-table inheritance
@@ -49,6 +58,9 @@ class User(AuthUser):
 
     profile = models.JSONField(default=str)
     built_in = models.BooleanField(default=False, null=False)
+    type = models.CharField(
+        max_length=16, choices=OriginType.choices, null=False, default=OriginType.Local
+    )
 
 
 class Group(AuthGroup):
@@ -59,6 +71,37 @@ class Group(AuthGroup):
 
     description = models.CharField(max_length=255, null=True)
     built_in = models.BooleanField(default=False, null=False)
+    type = models.CharField(
+        max_length=16, choices=OriginType.choices, null=False, default=OriginType.Local
+    )
+    # works as `name` field because `name` field now contains name and type
+    # to bypass unique constraint on `AuthGroup` base table
+    display_name = models.CharField(max_length=150, null=True)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                fields=['display_name', 'type'], name='unique_display_name_type'
+            ),
+        ]
+
+    def name_to_display(self):
+        return self.display_name
+
+
+BASE_GROUP_NAME_PATTERN = re.compile(
+    rf'(?P<base_name>.*?)(?: \[(?:{"|".join(OriginType.values)})\]|$)'
+)
+
+
+@receiver(pre_save, sender=Group)
+def handle_name_type_display_name(sender, instance, **kwargs):
+    match = BASE_GROUP_NAME_PATTERN.match(instance.name)
+    if match and match.group('base_name'):
+        instance.name = f'{match.group("base_name")} [{instance.type}]'
+        instance.display_name = match.group("base_name")
+    else:
+        raise RuntimeError(f'Check regex. Data: `{instance.name}`')
 
 
 class RoleTypes(models.TextChoices):
@@ -197,21 +240,23 @@ class Policy(models.Model):
     group_object_perm = models.ManyToManyField(GroupObjectPermission, blank=True)
 
     def remove_permissions(self):
-        for pp in self.model_perm.all():
-            if pp.policy_set.count() <= 1:
-                if pp.user:
-                    pp.user.user_permissions.remove(pp.permission)
-                if pp.group:
-                    pp.group.permissions.remove(pp.permission)
-            pp.policy_set.remove(self)
-
-        for uop in self.user_object_perm.all():
-            if uop.policy_set.count() <= 1:
-                uop.delete()
-
-        for gop in self.group_object_perm.all():
-            if gop.policy_set.count() <= 1:
-                gop.delete()
+        with atomic():
+            DummyData.objects.filter(id=1).update(date=timezone.now())
+            for pp in self.model_perm.all():
+                if pp.policy_set.count() <= 1:
+                    if pp.user:
+                        pp.user.user_permissions.remove(pp.permission)
+                    if pp.group:
+                        pp.group.permissions.remove(pp.permission)
+                pp.policy_set.remove(self)
+
+            for uop in self.user_object_perm.all():
+                if uop.policy_set.count() <= 1:
+                    uop.delete()
+
+            for gop in self.group_object_perm.all():
+                if gop.policy_set.count() <= 1:
+                    gop.delete()
 
     def add_object(self, obj):
         po = PolicyObject(object=obj)
diff --git a/python/rbac/services/group.py b/python/rbac/services/group.py
index 6d6491bba2..a0f32352ce 100644
--- a/python/rbac/services/group.py
+++ b/python/rbac/services/group.py
@@ -26,7 +26,8 @@
 def _update_users(group: models.Group, users: [Empty, List[dict]]) -> None:
     if users is Empty:
         return
-
+    if group.type == models.OriginType.LDAP:
+        raise AdwpEx('GROUP_UPDATE_ERROR', msg="You can\'t change users in LDAP group")
     group_users = {u.id: u for u in group.user_set.all()}
     new_users = [u['id'] for u in users]
 
@@ -52,13 +53,13 @@ def _update_users(group: models.Group, users: [Empty, List[dict]]) -> None:
 @transaction.atomic
 def create(
     *,
-    name: str,
+    name_to_display: str,
     description: str = None,
     user_set: List[dict] = None,
 ) -> models.Group:
     """Create Group"""
     try:
-        group = models.Group.objects.create(name=name, description=description)
+        group = models.Group.objects.create(name=name_to_display, description=description)
     except IntegrityError as exc:
         raise AdwpEx('GROUP_CREATE_ERROR', msg=f'Group creation failed with error {exc}') from exc
     _update_users(group, user_set or [])
@@ -70,12 +71,14 @@ def update(
     group: models.Group,
     *,
     partial: bool = False,
-    name: str = Empty,
+    name_to_display: str = Empty,
     description: str = Empty,
     user_set: List[dict] = Empty,
 ) -> models.Group:
     """Full or partial Group object update"""
-    set_not_empty_attr(group, partial, 'name', name)
+    if group.type == models.OriginType.LDAP:
+        raise AdwpEx('GROUP_UPDATE_ERROR', msg='You cannot change LDAP type group')
+    set_not_empty_attr(group, partial, 'name', name_to_display)
     set_not_empty_attr(group, partial, 'description', description, '')
     try:
         group.save()
diff --git a/python/rbac/services/user.py b/python/rbac/services/user.py
index 21be264536..1ca8ff0bf2 100644
--- a/python/rbac/services/user.py
+++ b/python/rbac/services/user.py
@@ -46,7 +46,7 @@ def _update_groups(user: models.User, groups: [Empty, List[dict]]) -> None:
     if groups is Empty:
         return
 
-    user_groups = {g.id: g for g in user.groups.all()}
+    user_groups = {g.id: g.group for g in user.groups.all()}
     new_groups = [g['id'] for g in groups]
 
     for group_id in new_groups:
@@ -59,12 +59,16 @@ def _update_groups(user: models.User, groups: [Empty, List[dict]]) -> None:
             raise AdwpEx(
                 'USER_UPDATE_ERROR', msg=msg, http_code=status.HTTP_400_BAD_REQUEST
             ) from exc
+        if group.type == models.OriginType.LDAP:
+            raise AdwpEx('USER_UPDATE_ERROR', msg="You cannot add user to LDAP group")
         user.groups.add(group)
         user_groups[group_id] = group
 
     for group_id, group in user_groups.items():
         if group_id in new_groups:
             continue
+        if group.type == models.OriginType.LDAP:
+            raise AdwpEx('USER_UPDATE_ERROR', msg="You cannot remove user from original LDAP group")
         user.groups.remove(group)
 
 
@@ -103,7 +107,18 @@ def update(
         email_user = models.User.objects.get(email=email)
         if email_user != user:
             raise AdwpEx('USER_UPDATE_ERROR', msg='User with the same email already exist')
-
+    names = {
+        'username': username,
+        'first_name': first_name,
+        'last_name': last_name,
+        'email': email,
+        'is_superuser': is_superuser,
+        'password': password,
+    }
+    if user.type == models.OriginType.LDAP and any(
+        (value is not Empty and getattr(user, key) != value) for key, value in names.items()
+    ):
+        raise AdwpEx('USER_UPDATE_ERROR', msg='You cannot change LDAP type user')
     set_not_empty_attr(user, partial, 'first_name', first_name, '')
     set_not_empty_attr(user, partial, 'last_name', last_name, '')
     set_not_empty_attr(user, partial, 'email', email, '')
diff --git a/python/task_runner.py b/python/task_runner.py
index 81236bdb25..a82501e01a 100755
--- a/python/task_runner.py
+++ b/python/task_runner.py
@@ -83,10 +83,11 @@ def run_job(task_id, job_id, err_file):
         return 1
 
 
-def set_body_ansible(job):
-    log_storage = LogStorage.objects.filter(job=job, name='ansible', type__in=['stdout', 'stderr'])
+def set_log_body(job):
+    name = job.sub_action.script_type if job.sub_action else job.action.script_type
+    log_storage = LogStorage.objects.filter(job=job, name=name, type__in=['stdout', 'stderr'])
     for ls in log_storage:
-        file_path = os.path.join(config.RUN_DIR, f'{ls.job.id}', f'ansible-{ls.type}.{ls.format}')
+        file_path = os.path.join(config.RUN_DIR, f'{ls.job.id}', f'{ls.name}-{ls.type}.{ls.format}')
         with open(file_path, 'r', encoding='utf_8') as f:
             body = f.read()
         LogStorage.objects.filter(job=job, name=ls.name, type=ls.type).update(body=body)
@@ -119,14 +120,12 @@ def run_task(task_id, args=None):
         if args == 'restart' and job.status == config.Job.SUCCESS:
             log.info('skip job #%s status "%s" of task #%s', job.id, job.status, task_id)
             continue
+        task.refresh_from_db()
         cm.job.re_prepare_job(task, job)
         job.start_date = timezone.now()
         job.save()
         res = run_job(task.id, job.id, err_file)
-        if job.action.script_type == 'ansible' or (
-            job.sub_action and job.sub_action.script_type == 'ansible'
-        ):
-            set_body_ansible(job)
+        set_log_body(job)
         # For multi jobs task object state and/or config can be changed by adcm plugins
         if task.task_object is not None:
             try:
diff --git a/web/package.json b/web/package.json
index 3482e841ea..84b00a2e6b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,7 +16,7 @@
   },
   "private": true,
   "dependencies": {
-    "@adwp-ui/widgets": "0.0.63",
+    "@adwp-ui/widgets": "0.0.64",
     "@angular/animations": "^11.2.14",
     "@angular/cdk": "^11.2.13",
     "@angular/common": "^11.2.14",
diff --git a/web/src/app/admin/groups/groups.component.ts b/web/src/app/admin/groups/groups.component.ts
index baf2bb85a1..4d2eb6397f 100644
--- a/web/src/app/admin/groups/groups.component.ts
+++ b/web/src/app/admin/groups/groups.component.ts
@@ -50,6 +50,11 @@ export class GroupsComponent extends RbacEntityListDirective<RbacGroupModel> {
       label: 'Users',
       className: 'one-line-string',
       value: userNameMapper,
+    },
+    {
+      label: 'Type',
+      sort: 'type',
+      value: (row) => row.type,
     }
   ] as IColumns<RbacGroupModel>;
 
diff --git a/web/src/app/admin/pattern.component.ts b/web/src/app/admin/pattern.component.ts
index 15bb659b6e..3ccd32fa62 100644
--- a/web/src/app/admin/pattern.component.ts
+++ b/web/src/app/admin/pattern.component.ts
@@ -12,18 +12,19 @@
 import { Component, OnDestroy, OnInit } from '@angular/core';
 import { NavigationEnd, Router } from '@angular/router';
 import { select, Store } from '@ngrx/store';
-import { exhaustMap, filter } from 'rxjs/operators';
+import { exhaustMap, filter, map, switchMap } from 'rxjs/operators';
 import { BaseDirective } from '@adwp-ui/widgets';
-
 import { ApiService } from '@app/core/api';
 import { getProfileSelector, settingsSave, State } from '@app/core/store';
 import { IConfig } from '@app/shared/configuration/types';
+import { BaseEntity } from "../core/types";
+import { Observable } from "rxjs";
 
 @Component({
   selector: 'app-pattern',
   template: `
     <mat-toolbar>
-      <app-crumbs [navigation]="crumbs"></app-crumbs>
+      <app-crumbs [navigation]="crumbs" [actionsUrl]="actionsUrl$ | async"></app-crumbs>
       <div class="example-spacer"></div>
     </mat-toolbar>
     <mat-drawer-container [style.flex]="1" autosize>
@@ -47,6 +48,7 @@ import { IConfig } from '@app/shared/configuration/types';
   styleUrls: ['../styles/details.scss'],
 })
 export class PatternComponent extends BaseDirective implements OnInit, OnDestroy {
+  actionsUrl$: Observable<string>;
   title = '';
   crumbs = [];
   leftMenu = [
@@ -72,6 +74,10 @@ export class PatternComponent extends BaseDirective implements OnInit, OnDestroy
   }
 
   ngOnInit() {
+    this.actionsUrl$ = this.api.root.pipe(
+      switchMap((root) => this.api.get<BaseEntity>(root.adcm)),
+      map((adcm) => `/api/v1/adcm/${adcm[0].id}/action/`));
+
     this.getContext(this.router.routerState.snapshot.url);
 
     this.router.events
diff --git a/web/src/app/admin/users/users.component.ts b/web/src/app/admin/users/users.component.ts
index c65baa27f2..0efca776ee 100644
--- a/web/src/app/admin/users/users.component.ts
+++ b/web/src/app/admin/users/users.component.ts
@@ -61,6 +61,11 @@ export class UsersComponent extends RbacEntityListDirective<RbacUserModel> imple
       label: 'Groups',
       className: 'one-line-string',
       value: groupNameMapper,
+    },
+    {
+      label: 'Type',
+      sort: 'type',
+      value: (row) => row.type,
     }
   ] as IColumns<RbacUserModel>;
 
diff --git a/web/src/app/components/rbac/group-form/rbac-group-form.component.ts b/web/src/app/components/rbac/group-form/rbac-group-form.component.ts
index 8e45dd7977..0c72c2417c 100644
--- a/web/src/app/components/rbac/group-form/rbac-group-form.component.ts
+++ b/web/src/app/components/rbac/group-form/rbac-group-form.component.ts
@@ -30,7 +30,15 @@ export class RbacGroupFormComponent extends RbacFormDirective<RbacGroupModel> {
 
   ngOnInit() {
     super.ngOnInit();
+    this._checkType();
     this.form.markAllAsTouched();
   }
 
+  _checkType() {
+    if (this.value.type === 'ldap') {
+      this.form.controls.name.disable();
+      this.form.controls.description.disable();
+      this.form.controls.user.disable();
+    }
+  }
 }
diff --git a/web/src/app/components/rbac/user-form/rbac-user-form.component.html b/web/src/app/components/rbac/user-form/rbac-user-form.component.html
index 816d10d141..4b766dae99 100644
--- a/web/src/app/components/rbac/user-form/rbac-user-form.component.html
+++ b/web/src/app/components/rbac/user-form/rbac-user-form.component.html
@@ -18,6 +18,7 @@
                      #groups="rbacGroups"
                      [handler]="groups.label"
                      [rbac-groups-as-options]="filter"
+                     [selectRowDisableCheck]="isDisabled"
                      [options]="groups.options$ | async | falseAsEmptyArray"
                      (filter)="updateFilter('search', $event)"
 
diff --git a/web/src/app/components/rbac/user-form/rbac-user-form.component.ts b/web/src/app/components/rbac/user-form/rbac-user-form.component.ts
index 27f350fa09..d30147a5ea 100644
--- a/web/src/app/components/rbac/user-form/rbac-user-form.component.ts
+++ b/web/src/app/components/rbac/user-form/rbac-user-form.component.ts
@@ -25,6 +25,10 @@ export const passwordsConfirmValidator: ValidatorFn = (control: AbstractControl)
 export class RbacUserFormComponent extends RbacFormDirective<RbacUserModel> {
   private _isFirstTouch = true;
 
+  isDisabled = (value) => {
+    return value?.type === 'ldap';
+  };
+
   get userForm(): FormGroup {
     return this.form.get('user') as FormGroup;
   }
@@ -97,7 +101,7 @@ export class RbacUserFormComponent extends RbacFormDirective<RbacUserModel> {
   clearPasswordControlIfFocusIn(): void {
     const forms = Object.values(this.form.controls);
 
-    forms.forEach((form) => {
+    forms.forEach((form: AbstractControl) => {
       if (this._isFirstTouch) {
         form.get('password').setValue('');
         form.updateValueAndValidity();
@@ -115,12 +119,20 @@ export class RbacUserFormComponent extends RbacFormDirective<RbacUserModel> {
    */
   private _setValue(value: RbacUserModel): void {
     if (value) {
+      const type: string = this.value?.type;
       // ToDo(lihih) the "adwp-list" should not change the composition of the original model.
       //  Now he adds the "checked" key to the model
-      delete this.value['checked'];
-      this.form.get('user.username').disable();
-      this.userForm.setValue(this.value);
+      this._updateAndSetValueForForm(this.userForm);
       this.confirmForm.setValue({ password: this.value.password });
+      this.form.get('user.username').disable();
+
+      if (type === 'ldap') {
+        this.userForm.controls.first_name.disable();
+        this.userForm.controls.last_name.disable();
+        this.userForm.controls.email.disable();
+        this.userForm.controls.password.disable();
+        this.confirmForm.controls.password.disable();
+      }
     }
   }
 
@@ -147,4 +159,13 @@ export class RbacUserFormComponent extends RbacFormDirective<RbacUserModel> {
       }
     });
   }
+
+  _updateAndSetValueForForm(form) {
+    const formValue = { ...this.value };
+    Object.keys(formValue).forEach((prop) => {
+      if (!form.controls.hasOwnProperty(prop)) delete formValue[prop];
+    })
+
+    form.setValue(formValue);
+  }
 }
diff --git a/web/src/app/core/services/cluster.service.ts b/web/src/app/core/services/cluster.service.ts
index b7a29b6dc9..a09134688e 100644
--- a/web/src/app/core/services/cluster.service.ts
+++ b/web/src/app/core/services/cluster.service.ts
@@ -145,11 +145,11 @@ export class ClusterService {
   }
 
   getHosts(p: ParamMap) {
-    return this.api.getList<Host>(this.Cluster.host, p);
+    return this.api.getList<Host>(this.Cluster?.host, p);
   }
 
   addHost(host_id: number) {
-    return this.api.post(this.Cluster.host, { host_id });
+    return this.api.post(this.Cluster?.host, { host_id });
   }
 
   reset(): Observable<WorkerInstance> {
diff --git a/web/src/app/core/services/stack.service.ts b/web/src/app/core/services/stack.service.ts
index 01492ab184..36c2e3c221 100644
--- a/web/src/app/core/services/stack.service.ts
+++ b/web/src/app/core/services/stack.service.ts
@@ -16,10 +16,10 @@ import { Bundle } from '@app/core/types';
 import { environment } from '@env/environment';
 import { select, Store } from '@ngrx/store';
 import { combineLatest, Observable } from 'rxjs';
-import { filter, map, mergeMap, switchMap } from 'rxjs/operators';
+import {filter, map, mergeMap, switchMap, tap} from 'rxjs/operators';
 import { ListResult } from '@app/models/list-result';
 
-export type StackInfo = 'cluster' | 'host' | 'provider' | 'service' | 'bundle';
+export type StackInfo = 'cluster' | 'host' | 'provider' | 'service' | 'bundle' | 'prototype';
 
 const UPLOAD_URL = `${environment.apiRoot}stack/upload/`,
   LOAD_URL = `${environment.apiRoot}stack/load/`;
@@ -33,7 +33,8 @@ export class StackService {
     return this.store.pipe(
       select(getStack),
       filter((a) => a && !!Object.keys(a).length),
-      switchMap((s) => this.api.get<ListResult<T>>(s[name], params).pipe(map((a) => a.results)))
+      switchMap((s) => this.api.get<ListResult<T>>(s[name], params).pipe(
+        map((a) => a.results)))
     );
   }
 
diff --git a/web/src/app/factories/list.factory.ts b/web/src/app/factories/list.factory.ts
index b94da0b5d3..9e4e407f51 100644
--- a/web/src/app/factories/list.factory.ts
+++ b/web/src/app/factories/list.factory.ts
@@ -83,6 +83,16 @@ export class ListFactory {
     };
   }
 
+  static updateColumn(): IComponentColumn<any> {
+    return {
+      label: 'Upgrade',
+      type: 'component',
+      className: 'list-control',
+      headerClassName: 'list-control',
+      component: UpgradeComponent,
+    };
+  }
+
   static importColumn<T>(listDirective: AdwpListDirective<T>): IButtonsColumn<T> {
     return {
       label: 'Import',
@@ -117,16 +127,6 @@ export class ListFactory {
     };
   }
 
-  static updateColumn(): IComponentColumn<any> {
-    return {
-      label: 'Upgrade',
-      type: 'component',
-      className: 'list-control',
-      headerClassName: 'list-control',
-      component: UpgradeComponent,
-    };
-  }
-
   static deleteColumn<T>(listDirective: AdwpListDirective<T>): IButtonsColumn<T> {
     return {
       type: 'buttons',
diff --git a/web/src/app/models/details.ts b/web/src/app/models/details.ts
index 646be566d5..ba024562b0 100644
--- a/web/src/app/models/details.ts
+++ b/web/src/app/models/details.ts
@@ -34,6 +34,8 @@ export interface INavItem {
   status?: number;
   statusMessage?: string;
   action?: () => void;
+  path?: string;
+  name?: string;
 }
 
 export interface IStyledNavItem {
diff --git a/web/src/app/models/rbac/rbac-group.model.ts b/web/src/app/models/rbac/rbac-group.model.ts
index 5633d15e8d..8a91e8ad81 100644
--- a/web/src/app/models/rbac/rbac-group.model.ts
+++ b/web/src/app/models/rbac/rbac-group.model.ts
@@ -8,4 +8,5 @@ export interface RbacGroupModel extends Entity {
   user: RbacUserModel[];
   url: string;
   built_in: boolean;
+  type: string;
 }
diff --git a/web/src/app/models/rbac/rbac-user.model.ts b/web/src/app/models/rbac/rbac-user.model.ts
index 1a0e9b3f4f..8fb0e7f620 100644
--- a/web/src/app/models/rbac/rbac-user.model.ts
+++ b/web/src/app/models/rbac/rbac-user.model.ts
@@ -13,6 +13,7 @@ export interface RbacUserModel {
   profile: IProfile;
   group: Pick<RbacGroupModel, 'id' | 'name' | 'url'>[];
   built_in: boolean;
+  type: 'string';
 
   //
   change_password: string;
diff --git a/web/src/app/shared/add-component/add-button.component.ts b/web/src/app/shared/add-component/add-button.component.ts
index 0e3087a70d..f3b23bd36b 100644
--- a/web/src/app/shared/add-component/add-button.component.ts
+++ b/web/src/app/shared/add-component/add-button.component.ts
@@ -49,6 +49,7 @@ export class AddButtonComponent extends BaseDirective implements OnDestroy {
   @Input() name: string;
   @Input() component: Type<BaseFormDirective>;
   @Input() dialogConfig: AddButtonDialogConfig = {};
+  @Input() clusterId: number;
   @Output() added = new EventEmitter();
 
   constructor(@Inject(ADD_SERVICE_PROVIDER) private service: IAddService,
@@ -58,11 +59,15 @@ export class AddButtonComponent extends BaseDirective implements OnDestroy {
 
   showForm(data?: FormModel): void {
 
-    const model = data || this.service?.model(this.name);
+    const model: any = data || this.service?.model(this.name) || this.service.Cluster;
     if (this.component) {
       model.component = this.component;
     }
 
+    if (this.clusterId) {
+      model.clusterId = this.clusterId;
+    }
+
     const name = model.title || model.name;
     let title;
 
diff --git a/web/src/app/shared/add-component/add-form.component.ts b/web/src/app/shared/add-component/add-form.component.ts
index 81bff3a335..dfa76cf7a9 100644
--- a/web/src/app/shared/add-component/add-form.component.ts
+++ b/web/src/app/shared/add-component/add-form.component.ts
@@ -35,7 +35,7 @@ import { RbacFormDirective } from '@app/shared/add-component/rbac-form.directive
           <app-add-service #cc></app-add-service>
         </ng-container>
         <ng-container *ngSwitchCase="'host2cluster'">
-          <app-add-host2cluster (event)="message($event)" #cc></app-add-host2cluster>
+          <app-add-host2cluster (event)="message($event)" [clusterId]="model?.clusterId" #cc></app-add-host2cluster>
         </ng-container>
       </ng-container>
     </div>
diff --git a/web/src/app/shared/add-component/add-service-model.ts b/web/src/app/shared/add-component/add-service-model.ts
index e93c6f6905..347afc1e0d 100644
--- a/web/src/app/shared/add-component/add-service-model.ts
+++ b/web/src/app/shared/add-component/add-service-model.ts
@@ -16,6 +16,7 @@ export interface FormModel<T = any> {
   form?: FormGroup;
   success?: EventEmitter<{ flag: boolean; obj: any }>;
   component?: Type<BaseFormDirective>;
+  clusterId?: number;
 }
 
 export interface IAddService {
diff --git a/web/src/app/shared/add-component/add.service.ts b/web/src/app/shared/add-component/add.service.ts
index ac365ae67e..f8062fe67f 100644
--- a/web/src/app/shared/add-component/add.service.ts
+++ b/web/src/app/shared/add-component/add.service.ts
@@ -162,7 +162,7 @@ export class AddService implements IAddService {
   }
 
   getProtoServiceForCurrentCluster() {
-    return this.api.get<StackBase[]>(this.cluster.Cluster.serviceprototype).pipe(
+    return this.api.get<StackBase[]>(this.cluster.Cluster?.serviceprototype).pipe(
       map((a: ServicePrototype[]) =>
         a
           .filter((b) => !b.selected)
@@ -179,7 +179,7 @@ export class AddService implements IAddService {
   }
 
   getHostListForCurrentCluster() {
-    return this.api.get<Host[]>(this.cluster.Cluster.host).pipe(
+    return this.api.get<Host[]>(this.cluster.Cluster?.host).pipe(
       map((hosts) =>
         hosts
           .map((host) => ({
diff --git a/web/src/app/shared/add-component/host.component.ts b/web/src/app/shared/add-component/host.component.ts
index 686ef93ba8..063fd57ec6 100644
--- a/web/src/app/shared/add-component/host.component.ts
+++ b/web/src/app/shared/add-component/host.component.ts
@@ -134,7 +134,7 @@ export class HostComponent extends BaseFormDirective implements OnInit {
 
     if (provider && provider.actions) {
       const actions = provider.actions.filter((a) => a.button === ACTION_NAME);
-      if (actions && actions.length) {
+      if (actions && actions?.length) {
         this.action.inputData = { actions };
         this.onCancel();
         this.action.onClick();
@@ -144,7 +144,7 @@ export class HostComponent extends BaseFormDirective implements OnInit {
 
   save() {
     const data = clearEmptyField(this.form.value) as Host;
-    if (this.displayMode !== 0) data.cluster_id = this.service.Cluster.id;
+    if (this.displayMode !== 0) data.cluster_id = this.service.Cluster?.id;
     this.service
       .addHost(data)
       .pipe(
@@ -163,7 +163,7 @@ export class HostComponent extends BaseFormDirective implements OnInit {
   }
 
   getNextPageClusters() {
-    const count = this.clusters$.getValue().length;
+    const count = this.clusters$.getValue()?.length;
     if (count === this.pageCluster * this.limit) {
       this.pageCluster++;
       this.getClusters();
@@ -171,7 +171,7 @@ export class HostComponent extends BaseFormDirective implements OnInit {
   }
 
   getNextPageProvider() {
-    const count = this.providers$.getValue().length;
+    const count = this.providers$.getValue()?.length;
     if (count === this.pageProvider * this.limit) {
       this.pageProvider++;
       this.getProviders();
diff --git a/web/src/app/shared/add-component/host2cluster.component.ts b/web/src/app/shared/add-component/host2cluster.component.ts
index 8ae0859513..c9d16642e2 100644
--- a/web/src/app/shared/add-component/host2cluster.component.ts
+++ b/web/src/app/shared/add-component/host2cluster.component.ts
@@ -9,16 +9,17 @@
 // 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 { Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
 import { MatCheckbox } from '@angular/material/checkbox';
 import { MatSelectionList, MatSelectionListChange } from '@angular/material/list';
 import { MatPaginator, PageEvent } from '@angular/material/paginator';
 import { openClose } from '@app/core/animations';
 import { Host } from '@app/core/types';
-
 import { BaseFormDirective } from './base-form.directive';
 import { HostComponent } from './host.component';
 import { DisplayMode } from './provider.component';
+import { ICluster } from "@app/models/cluster";
+import { tap } from "rxjs/operators";
 
 @Component({
   selector: 'app-add-host2cluster',
@@ -67,6 +68,7 @@ export class Host2clusterComponent extends BaseFormDirective implements OnInit,
   showForm = false;
   Count = 0;
   displayMode: DisplayMode = DisplayMode.default;
+  @Input() clusterId: number;
   @Output() event = new EventEmitter();
   @ViewChild('form') hostForm: HostComponent;
   @ViewChild('listHosts') listHosts: MatSelectionList;
@@ -76,11 +78,18 @@ export class Host2clusterComponent extends BaseFormDirective implements OnInit,
   selected: { [key: number]: boolean } = {};
 
   get disabled() {
-    return !Object.keys(this.selected).length;
+    return !Object.keys(this.selected)?.length;
   }
 
   ngOnInit() {
-    this.getAvailableHosts();
+    if (!this.service.Cluster) {
+      const cluster$ = this.service['api'].getOne<ICluster>('cluster', this.clusterId);
+      cluster$.pipe(
+        tap((cluster: ICluster) => (this.service['cluster'].Cluster = cluster))
+      ).subscribe(() => this.getAvailableHosts());
+    } else {
+      this.getAvailableHosts();
+    }
   }
 
   getAvailableHosts(pageIndex = 0, pageSize = 10) {
@@ -92,7 +101,7 @@ export class Host2clusterComponent extends BaseFormDirective implements OnInit,
         this.showForm = !r.count;
         this.displayMode = r.count > 0 ? 2 : 1;
         this.list = r.results;
-        if (this.listHosts?.options.length) this.allCbx.checked = false;
+        if (this.listHosts?.options?.length) this.allCbx.checked = false;
       });
   }
 
@@ -114,8 +123,8 @@ export class Host2clusterComponent extends BaseFormDirective implements OnInit,
 
   save() {
     if (this.hostForm.form.valid) {
-      const host = this.hostForm.form.value;
-      host.cluster_id = this.service.Cluster.id;
+      const host = this.hostForm?.form?.value;
+      host.cluster_id = this.service?.Cluster?.id || this.clusterId;
       this.service
         .addHost(host)
         .pipe(this.takeUntil())
diff --git a/web/src/app/shared/add-component/rbac-form.directive.ts b/web/src/app/shared/add-component/rbac-form.directive.ts
index e74e7d9768..409ddde731 100644
--- a/web/src/app/shared/add-component/rbac-form.directive.ts
+++ b/web/src/app/shared/add-component/rbac-form.directive.ts
@@ -32,8 +32,11 @@ export class RbacFormDirective<T extends { url: string } = { url: string }> exte
     if (this.value) {
       // ToDo(lihih) the "adwp-list" should not change the composition of the original model.
       //  Now he adds the "checked" key to the model
-      delete this.value['checked'];
-      this.form.setValue(this.value);
+      const formValue = { ...this.value };
+      Object.keys(formValue).forEach((prop) => {
+        if (!this.form.controls.hasOwnProperty(prop)) delete formValue[prop];
+      })
+      this.form.setValue(formValue);
     }
   }
 
diff --git a/web/src/app/shared/components/actions/action-list/action-list.component.html b/web/src/app/shared/components/actions/action-list/action-list.component.html
new file mode 100644
index 0000000000..780bb0cae7
--- /dev/null
+++ b/web/src/app/shared/components/actions/action-list/action-list.component.html
@@ -0,0 +1,26 @@
+<button
+  *ngIf="!asButton; else btn"
+  mat-icon-button
+  color="accent"
+  [disabled]="disabled"
+  [matMenuTriggerFor]="panel.menu"
+  (click)="getData()"
+  matTooltip="Choose action"
+>
+  <mat-icon>play_circle_outline</mat-icon>
+</button>
+<ng-template #btn>
+  <button
+    mat-raised-button
+    color="accent"
+    [disabled]="disabled"
+    [matMenuTriggerFor]="panel.menu"
+    (click)="getData()"
+  >
+    <span>Run action</span>
+    &nbsp;
+    <mat-icon class="icon-locked running" *ngIf="state === 'locked'; else pi">autorenew</mat-icon>
+    <ng-template #pi><mat-icon>play_circle_outline</mat-icon></ng-template>
+  </button>
+</ng-template>
+<app-menu-item #panel [items]="actions$ | async" [cluster]="cluster"></app-menu-item>
diff --git a/web/src/app/shared/components/actions/action-list/action-list.component.scss b/web/src/app/shared/components/actions/action-list/action-list.component.scss
new file mode 100644
index 0000000000..eb4bd276c4
--- /dev/null
+++ b/web/src/app/shared/components/actions/action-list/action-list.component.scss
@@ -0,0 +1,7 @@
+::ng-deep .crumbs-action-button {
+  app-action-list {
+    mat-icon {
+      margin-bottom: 5px;
+    }
+  }
+}
diff --git a/web/src/app/shared/components/actions/action-list/action-list.component.ts b/web/src/app/shared/components/actions/action-list/action-list.component.ts
index 56f6ba67f1..5a95874085 100644
--- a/web/src/app/shared/components/actions/action-list/action-list.component.ts
+++ b/web/src/app/shared/components/actions/action-list/action-list.component.ts
@@ -17,34 +17,8 @@ import { ActionsService } from '../actions.service';
 
 @Component({
   selector: 'app-action-list',
-  template: `
-    <button
-      *ngIf="!asButton; else btn"
-      mat-icon-button
-      color="accent"
-      [disabled]="disabled"
-      [matMenuTriggerFor]="panel.menu"
-      (click)="getData()"
-      matTooltip="Choose action"
-    >
-      <mat-icon>play_circle_outline</mat-icon>
-    </button>
-    <ng-template #btn>
-      <button
-        mat-raised-button
-        color="accent"
-        [disabled]="disabled"
-        [matMenuTriggerFor]="panel.menu"
-        (click)="getData()"
-      >
-        <span>Run action</span>
-        &nbsp;
-        <mat-icon class="icon-locked running" *ngIf="state === 'locked'; else pi">autorenew</mat-icon>
-        <ng-template #pi><mat-icon>play_circle_outline</mat-icon></ng-template>
-      </button>
-    </ng-template>
-    <app-menu-item #panel [items]="actions$ | async" [cluster]="cluster"></app-menu-item>
-  `,
+  templateUrl: './action-list.component.html',
+  styleUrls: ['./action-list.component.scss']
 })
 export class ActionListComponent {
   @Input() cluster: { id: number; hostcomponent: string };
diff --git a/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts b/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
index 18dc49fb68..ba20259efa 100644
--- a/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
+++ b/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
@@ -16,7 +16,7 @@ import { MatMenu } from '@angular/material/menu';
 @Component({
   selector: 'app-menu-item',
   styleUrls: ['./menu-item.component.scss'],
-  template: ` <mat-menu #menu="matMenu" xPosition="before" yPosition="below" overlapTrigger="false">
+  template: ` <mat-menu #menu="matMenu" xPosition="after" yPosition="below" overlapTrigger="false">
     <div mat-menu-item disabled *ngIf="!items?.length; else list">
       <i>No actions</i>
     </div>
@@ -25,7 +25,7 @@ import { MatMenu } from '@angular/material/menu';
         <div *ngIf="!a.children; else branch" [matTooltip]="actionListItemTooltip(a.disabling_cause)">
           <button
             mat-menu-item
-            [disabled]="a.disabling_cause === 'maintenance_mode'"
+            [disabled]="a.disabling_cause === 'maintenance_mode' || a.disabling_cause === 'no_ldap_settings'"
             [appForTest]="'action_btn'"
             [appActions]="{ cluster: cluster, actions: [a] }">
             <span>{{ a.display_name }}</span>
diff --git a/web/src/app/shared/components/actions/actions.component.spec.ts b/web/src/app/shared/components/actions/actions.component.spec.ts
index c070bba953..5570616a7e 100644
--- a/web/src/app/shared/components/actions/actions.component.spec.ts
+++ b/web/src/app/shared/components/actions/actions.component.spec.ts
@@ -39,7 +39,7 @@ describe('ActionsComponent', () => {
   it('Func `calcWidth` when the width of the window is greater than the buttons width sum', () => {
     const arr = [40, 30, 60];
     const result = component.calcWidth(1200, arr);
-    expect(result).toEqual([arr.length, 130, false]);
+    expect(result).toEqual([arr?.length, 130, false]);
   });
 
   it('Func `calcWidth` when the width of the window is less than the buttons width sum', () => {
diff --git a/web/src/app/shared/components/actions/actions.component.ts b/web/src/app/shared/components/actions/actions.component.ts
index 5c0eb312f3..64778ef990 100644
--- a/web/src/app/shared/components/actions/actions.component.ts
+++ b/web/src/app/shared/components/actions/actions.component.ts
@@ -61,7 +61,7 @@ export class ActionsComponent extends BaseDirective implements OnInit, AfterView
   @Input() cluster: { id: number; hostcomponent: string };
   @Input() set source(actions: IAction[]) {
     this.actions = actions;
-    if (!actions.length) this.render.setStyle(this.more.nativeElement, 'display', 'none');
+    if (!actions?.length) this.render.setStyle(this.more.nativeElement, 'display', 'none');
   }
 
   stateButtons = 0;
@@ -87,8 +87,8 @@ export class ActionsComponent extends BaseDirective implements OnInit, AfterView
   }
 
   ngAfterViewChecked(): void {
-    if (this.stateButtons !== this.buttons.length) {
-      this.stateButtons = this.buttons.length;
+    if (this.stateButtons !== this.buttons?.length) {
+      this.stateButtons = this.buttons?.length;
       setTimeout(() => this.onresize(), 0);
     }
   }
diff --git a/web/src/app/shared/components/actions/actions.directive.ts b/web/src/app/shared/components/actions/actions.directive.ts
index 67cdee0307..eeaadbbe01 100644
--- a/web/src/app/shared/components/actions/actions.directive.ts
+++ b/web/src/app/shared/components/actions/actions.directive.ts
@@ -46,10 +46,10 @@ export class ActionsDirective {
     if (!model.actions?.length) return { data: { title: 'No parameters for run the action', model: null, component: null } };
 
     const act = model.actions[0];
-    const isMulty = model.actions.length > 1;
+    const isMulty = model.actions?.length > 1;
 
-    const width = isMulty || act.config?.config.length || act.hostcomponentmap?.length ? '90%' : '400px';
-    const title = act.ui_options?.disclaimer ? act.ui_options.disclaimer : isMulty ? 'Run an actions?' : `Run an action [ ${act.display_name} ]?`;
+    const width = isMulty || act.config?.config?.length || act.hostcomponentmap?.length ? '90%' : '400px';
+    const title = act.ui_options?.disclaimer ? act.ui_options.disclaimer : isMulty ? 'Run actions?' : `Run an action [ ${act.display_name} ]?`;
 
     return {
       width,
diff --git a/web/src/app/shared/components/actions/master/master.component.html b/web/src/app/shared/components/actions/master/master.component.html
index 6ef51411eb..08da7c21b7 100644
--- a/web/src/app/shared/components/actions/master/master.component.html
+++ b/web/src/app/shared/components/actions/master/master.component.html
@@ -1,4 +1,4 @@
-<ng-container *ngIf="model?.actions.length === 0">
+<ng-container *ngIf="model?.actions?.length === 0">
   <p>No data for the action!</p>
 </ng-container>
 
@@ -54,7 +54,7 @@
     <mat-checkbox [(ngModel)]="verbose">Verbose</mat-checkbox>
 
     <div class="controls">
-      <ng-container *ngIf="model.actions.length > 1">
+      <ng-container *ngIf="model.actions?.length > 1">
         <button mat-raised-button color="primary" (click)="action = null">Back</button>
         <span [style.flex]="1"></span>
       </ng-container>
diff --git a/web/src/app/shared/components/actions/master/master.component.spec.ts b/web/src/app/shared/components/actions/master/master.component.spec.ts
index 06d8335877..c416c08858 100644
--- a/web/src/app/shared/components/actions/master/master.component.spec.ts
+++ b/web/src/app/shared/components/actions/master/master.component.spec.ts
@@ -94,7 +94,7 @@ describe('MasterComponent', () => {
     fixture.detectChanges();
     const compHost: HTMLElement = fixture.debugElement.nativeElement;
     // expect(compHost.querySelector('i').textContent).toBe("Let's choose first");
-    const buttonsLength = compHost.querySelector('mat-action-list').getElementsByTagName('button').length;
+    const buttonsLength = compHost.querySelector('mat-action-list').getElementsByTagName('button')?.length;
     expect(buttonsLength).toBeGreaterThan(1);
   });
 
diff --git a/web/src/app/shared/components/actions/master/master.component.ts b/web/src/app/shared/components/actions/master/master.component.ts
index bbc5878d07..8b23e7f309 100644
--- a/web/src/app/shared/components/actions/master/master.component.ts
+++ b/web/src/app/shared/components/actions/master/master.component.ts
@@ -56,7 +56,7 @@ export class ActionMasterComponent extends BaseDirective implements DynamicCompo
   }
 
   ngOnInit(): void {
-    if (this.model.actions.length === 1) this.choose(this.model.actions[0]);
+    if (this.model?.actions?.length === 1) this.choose(this.model.actions[0]);
   }
 
   choose(action: IAction) {
diff --git a/web/src/app/shared/components/crumbs/crumbs.component.scss b/web/src/app/shared/components/crumbs/crumbs.component.scss
new file mode 100644
index 0000000000..68e1622654
--- /dev/null
+++ b/web/src/app/shared/components/crumbs/crumbs.component.scss
@@ -0,0 +1,14 @@
+:host {
+  font-size: 0.8em;
+  margin-left: 8px;
+}
+mat-nav-list {
+  display: flex;
+  align-items: center;
+}
+a {
+  line-height: normal;
+}
+mat-icon {
+  margin-bottom: 5px;
+}
diff --git a/web/src/app/shared/components/crumbs.component.ts b/web/src/app/shared/components/crumbs/crumbs.component.ts
similarity index 62%
rename from web/src/app/shared/components/crumbs.component.ts
rename to web/src/app/shared/components/crumbs/crumbs.component.ts
index 93ca5fbb1c..a9746b65ed 100644
--- a/web/src/app/shared/components/crumbs.component.ts
+++ b/web/src/app/shared/components/crumbs/crumbs.component.ts
@@ -8,14 +8,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 import { Component, Input } from '@angular/core';
-import { INavItem } from '@app/models/details';
+import { INavItem } from '../../../models/details';
 
 @Component({
   selector: 'app-crumbs',
   template: `
     <mat-nav-list>
       <a routerLink="/admin"><mat-icon>apps</mat-icon></a>
-      <span>&nbsp;/&nbsp;</span>
+      <span>&nbsp;{{ this.rootBreadcrumb() }}&nbsp;</span>
+      <app-actions-button *ngIf="this.isAdminPage()"
+                          class="crumbs-action-button"
+                          [row]="{id: 1, hostcomponent: null, action: this.actionsUrl}">
+
+      </app-actions-button>
       <ng-container *ngFor="let item of navigation; last as isLast; trackBy: trackBy">
         <a routerLink="{{ item.url }}">{{ item.title | uppercase }}</a>
         <mat-icon *ngIf="item.issue" [matTooltip]="item.issue" color="warn">priority_hight</mat-icon>
@@ -23,28 +28,21 @@ import { INavItem } from '@app/models/details';
       </ng-container>
     </mat-nav-list>
   `,
-  styles: [
-    `
-      :host {
-        font-size: 0.8em;
-        margin-left: 8px;
-      }
-      mat-nav-list {
-        display: flex;
-        align-items: center;
-      }
-      a {
-        line-height: normal;
-      }
-      mat-icon {
-        margin-bottom: 5px;
-      }
-    `,
-  ],
+  styleUrls: ['./crumbs.component.scss'],
 })
 export class CrumbsComponent {
   @Input() navigation: INavItem[];
-  trackBy(index: number, item: INavItem) {
+  @Input() actionsUrl: string;
+
+  isAdminPage(): boolean {
+    return this.navigation.length == 1 && this.navigation[0]?.path?.includes('admin');
+  }
+
+  rootBreadcrumb(): string {
+    return this.isAdminPage() ? '/ ADCM' : '/';
+  }
+
+  trackBy(index: number, item: INavItem): string {
     return item.url;
   }
 }
diff --git a/web/src/app/shared/components/dialog.component.ts b/web/src/app/shared/components/dialog.component.ts
index fc84169c77..1feea93f9c 100644
--- a/web/src/app/shared/components/dialog.component.ts
+++ b/web/src/app/shared/components/dialog.component.ts
@@ -30,7 +30,7 @@ export interface DialogData {
 @Component({
   selector: 'app-dialog',
   template: `
-    <h3 mat-dialog-title class="overflow">{{ data.title || 'Notification' }}</h3>
+    <h3 mat-dialog-title class="overflow">{{ _title() }}</h3>
     <mat-dialog-content class="content" appScroll (read)="scroll($event)">
       <pre *ngIf="data.text">{{ data.text }}</pre>
       <ng-template appDynamic></ng-template>
@@ -51,7 +51,16 @@ export interface DialogData {
       </button>
     </ng-template>
   `,
-  styles: ['pre {white-space: pre-wrap;}'],
+  styles: [`
+    mat-dialog-content {
+      height: 100%;
+    }
+    pre {
+      overflow: hidden;
+      white-space: pre-wrap;
+      word-break: break-word;
+    }
+  `],
 })
 export class DialogComponent implements OnInit {
   controls: string[];
@@ -81,6 +90,10 @@ export class DialogComponent implements OnInit {
   }
 
   ngOnInit(): void {
+    if (this._isTypeLdap() && this._isGroupDialog()) {
+      this.data.disabled = true;
+    }
+
     if (this.data.component) {
       const componentFactory = this.componentFactoryResolever.resolveComponentFactory(this.data.component);
       const viewContainerRef = this.dynamic.viewContainerRef;
@@ -103,4 +116,21 @@ export class DialogComponent implements OnInit {
   _noClick(): void {
     this.dialogRef.close();
   }
+
+  _isTypeLdap() {
+    return this.data?.model?.value?.type === 'ldap'
+  }
+
+  _title(): string {
+    if (this._isTypeLdap() && this._isGroupDialog()) return 'Group Info'
+    return this.data.title || 'Notification';
+  }
+
+  _isGroupDialog() {
+    return this.data.model.name === 'Group';
+  }
+
+  _isUserDialog() {
+    return this.data.model.name === 'user';
+  }
 }
diff --git a/web/src/app/shared/components/index.ts b/web/src/app/shared/components/index.ts
index 35a4a4f93b..cf07f6cfa0 100644
--- a/web/src/app/shared/components/index.ts
+++ b/web/src/app/shared/components/index.ts
@@ -13,10 +13,10 @@
 export * from './button-spinner.component';
 export * from './issue-info.component';
 export * from './status-info.component';
-export * from './crumbs.component';
+export * from './crumbs/crumbs.component';
 export * from './dialog.component';
 export * from './main-info.component';
-export * from './upgrade.component';
+export * from './upgrades/upgrade.component';
 export * from './actions/actions.component';
 export * from './actions/master/master.component';
 export * from './import/import.component';
diff --git a/web/src/app/shared/components/upgrades/master/master.component.html b/web/src/app/shared/components/upgrades/master/master.component.html
new file mode 100644
index 0000000000..1e33a0346e
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/master.component.html
@@ -0,0 +1,70 @@
+<ng-container *ngIf="model?.upgrades?.length === 0">
+  <p>No data for the upgrades!</p>
+</ng-container>
+
+<ng-container *ngIf="!upgrade; else simple">
+  <em>Let's choose first</em>
+  <mat-action-list>
+    <button class="action-button warn" color="primary" mat-list-item *ngFor="let upgrade of model.upgrades"
+      (click)="choose(upgrade)">{{ upgrade.name }}</button>
+  </mat-action-list>
+  <p><button mat-raised-button (click)="cancel()" color="primary">Cancel</button></p>
+</ng-container>
+
+<ng-template #simple>
+  <div *ngIf="show === 'none'">
+    <ng-container *ngTemplateOutlet="controls; context: { value: undefined }"></ng-container>
+  </div>
+
+  <div *ngIf="show === 'config'">
+    <app-upgrade-master-config [upgrade]="upgrade" #wc></app-upgrade-master-config>
+    <ng-container *ngTemplateOutlet="controls; context: { value: { config: wc.fields } }"></ng-container>
+  </div>
+
+  <div *ngIf="show === 'hostmap'">
+    <app-service-host [tabIndex]="2" [cluster]="model.cluster" [hideButton]="true" #hostmap
+      [actionParameters]="upgrade.hostcomponentmap" [fixedButton]="false"></app-service-host>
+    <ng-container *ngTemplateOutlet="controls; context: { value: { hostmap: hostmap } }"></ng-container>
+  </div>
+
+  <mat-horizontal-stepper #stepper linear *ngIf="show === 'stepper'">
+    <mat-step [stepControl]="wcs.fields.form">
+      <ng-template matStepLabel>Configuration</ng-template>
+      <app-upgrade-master-config [upgrade]="upgrade" #wcs></app-upgrade-master-config>
+      <div class="controls-container" [class.margin]="needMargin()">
+        <mat-checkbox [(ngModel)]="verbose">Verbose</mat-checkbox>
+        <div class="controls">
+          <button mat-raised-button (click)="cancel()" color="primary">Cancel</button>
+          <button mat-raised-button matStepperNext color="primary">Next</button>
+        </div>
+      </div>
+    </mat-step>
+    <mat-step>
+      <ng-template matStepLabel>Host Components</ng-template>
+      <app-service-host [tabIndex]="2" [cluster]="model.cluster" #hms [hideButton]="true"
+        [actionParameters]="upgrade.hostcomponentmap" [fixedButton]="false"></app-service-host>
+      <ng-container *ngTemplateOutlet="controls; context: { value: { config: wcs.fields, hostmap: hms } }">
+      </ng-container>
+    </mat-step>
+    <mat-step label="Done"></mat-step>
+  </mat-horizontal-stepper>
+
+</ng-template>
+
+<ng-template #controls let-value="value">
+  <div class="controls-container" [class.margin]="needMargin()">
+
+    <mat-checkbox [(ngModel)]="verbose">Verbose</mat-checkbox>
+
+    <div class="controls">
+      <ng-container *ngIf="model['upgradable']">
+        <button mat-raised-button color="primary" (click)="upgrade = null">Back</button>
+        <span [style.flex]="1"></span>
+      </ng-container>
+
+      <button mat-raised-button (click)="cancel()" color="primary" tabindex="-1">Cancel</button>
+      <button mat-raised-button #runBtn (click)="run(value)" color="accent" [disabled]="isDisabled(value)" tabindex="1">Run</button>
+    </div>
+
+  </div>
+</ng-template>
diff --git a/web/src/app/shared/components/upgrades/master/master.component.scss b/web/src/app/shared/components/upgrades/master/master.component.scss
new file mode 100644
index 0000000000..96d0fd8a14
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/master.component.scss
@@ -0,0 +1,18 @@
+.action-button {
+  background: none !important;
+  margin: 6px 0;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.04) !important;
+  }
+}
+
+.controls-container {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  &.margin {
+    margin-top: 26px !important;
+  }
+}
diff --git a/web/src/app/shared/components/upgrades/master/master.component.spec.ts b/web/src/app/shared/components/upgrades/master/master.component.spec.ts
new file mode 100644
index 0000000000..7ebd618889
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/master.component.spec.ts
@@ -0,0 +1,295 @@
+// Licensed 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 { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { MatListModule } from '@angular/material/list';
+import { ApiService } from '@app/core/api/api.service';
+import { FieldService } from '@app/shared/configuration/services/field.service';
+import { ConfigFieldsComponent } from '@app/shared/configuration/fields/fields.component';
+import { ServiceHostComponent } from '@app/shared/host-components-map/services2hosts/service-host.component';
+import { UpgradeMasterConfigComponent } from './upgrade-master-config.component';
+import { UpgradeMasterComponent as MasterComponent } from './master.component';
+import { MasterService } from './master.service';
+import {IConfig} from "@app/shared/configuration/types";
+import {IActionParameter, IUIOptions} from "@app/core/types";
+
+describe('MasterComponent', () => {
+  let component: MasterComponent;
+  let fixture: ComponentFixture<MasterComponent>;
+
+  let ApiServiceStub: Partial<ApiService>;
+  let FieldServiceStub: Partial<FieldService>;
+
+  beforeEach(async () => {
+    ApiServiceStub = {};
+    FieldServiceStub = new FieldService({} as FormBuilder);
+
+    TestBed.configureTestingModule({
+      imports: [MatListModule],
+      declarations: [MasterComponent, UpgradeMasterConfigComponent],
+      providers: [MasterService, {provide: ApiService, useValue: ApiServiceStub}, {
+        provide: FieldService,
+        useValue: FieldServiceStub
+      }],
+      schemas: [NO_ERRORS_SCHEMA]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MasterComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('input data for model property must be defined', () => {
+    component.model = {upgrades: []};
+    fixture.detectChanges();
+    expect(component.model).toBeDefined();
+  });
+
+  it('input data for model.actons property must be array', () => {
+    component.model = {upgrades: []};
+    fixture.detectChanges();
+    expect(component.model.upgrades).toBeDefined();
+    expect(component.model.upgrades).toEqual(jasmine.any(Array));
+  });
+
+  it('should be show template if model.upgrades.length === 0', () => {
+    component.model = {upgrades: []};
+    fixture.detectChanges();
+    const compHost: HTMLElement = fixture.debugElement.nativeElement;
+    expect(compHost.querySelector('p').textContent).toBe('No data for the upgrades!');
+  });
+
+  it('should be show template for current upgrade if model.upgrades.length === 1 and config = null and host-map = null', () => {
+    component.model = {
+      upgrades: [{
+        bundle_id: 1,
+        config: null,
+        description: '',
+        do: 'url',
+        from_edition: ['community'],
+        hostcomponentmap: null,
+        id: 1,
+        license: '',
+        license_url: '',
+        max_strict: true,
+        max_version: '',
+        min_strict: false,
+        min_version: '',
+        name: '',
+        state_available: '',
+        state_on_success: '',
+        ui_options: null,
+        upgradable: true,
+        url: ''
+      }]
+    };
+    fixture.detectChanges();
+    const compHost: HTMLElement = fixture.debugElement.nativeElement;
+    const cancelButton = compHost.querySelector('button[color="primary"]');
+    const runButton = compHost.querySelector('button[color="accent"]');
+    expect(runButton).toBeTruthy();
+    expect(cancelButton).toBeTruthy();
+    expect(cancelButton.textContent).toContain('Cancel');
+    expect(runButton.textContent).toContain('Run');
+  });
+
+  it('should be show upgrades list for choose current upgrade if model.upgrades.length > 1', () => {
+    component.model = {
+      upgrades: [
+        {
+          bundle_id: 1,
+          config: {} as IConfig,
+          description: '',
+          do: 'url',
+          from_edition: ['community'],
+          hostcomponentmap: [] as IActionParameter[],
+          id: 1,
+          license: '',
+          license_url: '',
+          max_strict: true,
+          max_version: '',
+          min_strict: false,
+          min_version: '',
+          name: '',
+          state_available: '',
+          state_on_success: '',
+          ui_options: {} as IUIOptions,
+          upgradable: true,
+          url: ''
+        },
+        {
+          bundle_id: 1,
+          config: {} as IConfig,
+          description: '',
+          do: 'url',
+          from_edition: ['community'],
+          hostcomponentmap: [] as IActionParameter[],
+          id: 1,
+          license: '',
+          license_url: '',
+          max_strict: true,
+          max_version: '',
+          min_strict: false,
+          min_version: '',
+          name: '',
+          state_available: '',
+          state_on_success: '',
+          ui_options: {} as IUIOptions,
+          upgradable: true,
+          url: ''
+        }
+      ]
+    };
+    fixture.detectChanges();
+    const compHost: HTMLElement = fixture.debugElement.nativeElement;
+    // expect(compHost.querySelector('i').textContent).toBe("Let's choose first");
+    const buttonsLength = compHost.querySelector('mat-action-list').getElementsByTagName('button').length;
+    expect(buttonsLength).toBeGreaterThan(1);
+  });
+
+  it('should be show template for current upgrade if config exist only', () => {
+    component.model = {
+      upgrades: [
+        {
+          bundle_id: 1,
+          config: {
+            config: [
+              {
+                type: 'string',
+                name: 'test',
+                display_name: 'display name test',
+                subname: '',
+                default: null,
+                value: null,
+                required: false,
+                description: '',
+                read_only: false,
+                activatable: false,
+                group_config: null,
+              }
+            ]
+          } as IConfig,
+          description: '',
+          do: 'url',
+          from_edition: ['community'],
+          hostcomponentmap: null,
+          id: 1,
+          license: '',
+          license_url: '',
+          max_strict: true,
+          max_version: '',
+          min_strict: false,
+          min_version: '',
+          name: '',
+          state_available: '',
+          state_on_success: '',
+          ui_options: {} as IUIOptions,
+          upgradable: true,
+          url: ''
+        }
+      ]
+    };
+    fixture.detectChanges();
+    const de = fixture.debugElement.nativeElement;
+    const config = de.querySelector('app-upgrade-master-config');
+    const hm = de.querySelector('app-service-host');
+    expect(hm).toBeNull();
+    expect(config).toBeTruthy();
+  });
+
+  it('should be show template for current upgrade if host-map exist only', () => {
+    component.model = {
+      upgrades: [{
+        bundle_id: 1, config: null, description: '', do: 'url', from_edition: ['community'],
+        hostcomponentmap: [{component: '', action: 'add', service: ''}] as IActionParameter[], id: 1, license: '',
+        license_url: '', max_strict: true, max_version: '', min_strict: false, min_version: '', name: '',
+        state_available: '', state_on_success: '', ui_options: {} as IUIOptions, upgradable: true, url: ''
+      }]
+    };
+    fixture.detectChanges();
+    const de = fixture.debugElement.nativeElement;
+    const config = de.querySelector('app-upgrade-master-config');
+    const hm = de.querySelector('app-service-host');
+    expect(config).toBeNull();
+    expect(hm).toBeTruthy();
+  });
+
+  it('should be show template for current upgrade with config and host-map', () => {
+    component.model = {
+      upgrades: [{
+        bundle_id: 1, config: {
+          config: [
+            {
+              type: 'string',
+              name: 'test',
+              display_name: 'display name test',
+              subname: '',
+              default: null,
+              value: null,
+              required: false,
+              description: '',
+              read_only: false,
+              activatable: false,
+              group_config: null,
+            }
+          ]
+        } as IConfig, description: '', do: 'url', from_edition: ['community'],
+        hostcomponentmap: [{component: '', action: 'add', service: ''}] as IActionParameter[], id: 1, license: '',
+        license_url: '', max_strict: true, max_version: '', min_strict: false, min_version: '', name: '',
+        state_available: '', state_on_success: '', ui_options: {} as IUIOptions, upgradable: true, url: ''
+      }]
+    };
+    fixture.detectChanges();
+    const de = fixture.debugElement.nativeElement;
+    const config = de.querySelector('app-upgrade-master-config');
+    const hm = de.querySelector('app-service-host');
+    expect(config).toBeTruthy();
+    expect(hm).toBeTruthy();
+  });
+
+  it('should be undefined value if ServiceHostComponent ConfigFieldComponent not exist', () => {
+    const service = fixture.debugElement.injector.get(MasterService);
+    const result = service.parseData(undefined);
+    expect(result).toBeUndefined();
+  });
+
+  it('check value when ConfigFieldComponent exist', () => {
+    const service = fixture.debugElement.injector.get(MasterService);
+
+    const config = {
+      form: new FormGroup({string_ctr: new FormControl('string_test'), bool_ctr: new FormControl(true)}),
+      rawConfig: {
+        config: [
+          {name: 'string_ctr', type: 'string', value: 'string_test'},
+          {name: 'bool_ctr', type: 'boolean', value: true}
+        ]
+      }
+    } as ConfigFieldsComponent;
+
+    const result = service.parseData({config});
+    expect(result).toEqual({config: {string_ctr: 'string_test', bool_ctr: true}, hc: undefined, attr: undefined});
+  });
+
+  it('check value when ServiceHostComponent exist', () => {
+    const service = fixture.debugElement.injector.get(MasterService);
+    const hc = [{host_id: 1, service_id: 4, component_id: 1, id: 9}];
+    const hostmap = {statePost: {data: hc}} as ServiceHostComponent;
+    const result = service.parseData({hostmap});
+    expect(result).toEqual({config: undefined, hc, attr: undefined});
+  });
+});
diff --git a/web/src/app/shared/components/upgrades/master/master.component.ts b/web/src/app/shared/components/upgrades/master/master.component.ts
new file mode 100644
index 0000000000..3f56dabc38
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/master.component.ts
@@ -0,0 +1,76 @@
+// Licensed 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 { Component, ElementRef, EventEmitter, OnInit, ViewChild } from '@angular/core';
+import { DynamicComponent, DynamicEvent } from '@app/shared/directives/dynamic/dynamic.directive';
+import { BaseDirective } from '@app/shared/directives';
+import { IMasterData, IValue, MasterService, whatShow } from './master.service';
+import { IUpgrade } from "@app/shared/components";
+import { UpgradeParameters } from "@app/shared/components/upgrades/upgrade.directive";
+
+@Component({
+  selector: 'app-master',
+  templateUrl: './master.component.html',
+  styleUrls: ['./master.component.scss'],
+  providers: [MasterService],
+})
+export class UpgradeMasterComponent extends BaseDirective implements DynamicComponent, OnInit {
+  event: EventEmitter<DynamicEvent> = new EventEmitter();
+  model: UpgradeParameters;
+  upgrade: IUpgrade;
+  show: whatShow;
+
+  verbose = false;
+
+  @ViewChild('runBtn', { read: ElementRef }) runBtn: ElementRef;
+
+  constructor(private service: MasterService) {
+    super();
+  }
+
+  ngOnInit(): void {
+    if (this.model.upgrades?.length === 1) this.choose(this.model.upgrades[0]);
+  }
+
+  choose(upgrade: IUpgrade) {
+    this.upgrade = upgrade;
+    this.show = this.service.spotShow(upgrade);
+  }
+
+  isDisabled(value: IValue) {
+    return value && ((value.hostmap && value.hostmap.noValid) || (value.config && !value.config.form?.valid));
+  }
+
+  run(value: IValue = {}) {
+    const data: IMasterData = this.service.parseData(value);
+
+    if (data) {
+      data.verbose = this.verbose;
+    }
+
+    this.service
+      .send(this.upgrade.do, data)
+      .pipe(this.takeUntil())
+      .subscribe(() => this.cancel());
+  }
+
+  onEnterKey() {
+    this.runBtn.nativeElement.click();
+  }
+
+  cancel() {
+    this.event.emit({ name: 'cancel' });
+  }
+
+  needMargin() {
+    return !(this?.model?.upgrades[0]?.config?.config?.length > 0 && this?.model?.upgrades[0]?.hostcomponentmap?.length > 0);
+  }
+}
diff --git a/web/src/app/shared/components/upgrades/master/master.service.ts b/web/src/app/shared/components/upgrades/master/master.service.ts
new file mode 100644
index 0000000000..d99e6f599f
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/master.service.ts
@@ -0,0 +1,63 @@
+// Licensed 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 { Injectable } from '@angular/core';
+import { ApiService } from '@app/core/api';
+import { FieldService } from '@app/shared/configuration/services/field.service';
+import { ConfigFieldsComponent } from '@app/shared/configuration/fields/fields.component';
+import { ServiceHostComponent } from '@app/shared/host-components-map/services2hosts/service-host.component';
+import { Post } from '@app/shared/host-components-map/types';
+import { IConfigAttr } from '@app/shared/configuration/types';
+import { IOutput } from '@app/shared/configuration/services/field.service';
+import { IUpgrade } from "@app/shared/components";
+
+export interface IValue {
+  config?: ConfigFieldsComponent;
+  hostmap?: ServiceHostComponent;
+}
+
+export enum whatShow {
+  none = 'none',
+  config = 'config',
+  hostMap = 'hostmap',
+  stepper = 'stepper',
+}
+
+export interface IMasterData {
+  attr: IConfigAttr;
+  config: IOutput;
+  hc: Post[];
+  verbose?: boolean;
+}
+
+@Injectable()
+export class MasterService {
+  constructor(private api: ApiService, private configService: FieldService) {}
+
+  spotShow(upgrade: IUpgrade): whatShow {
+    const config = upgrade.config?.config?.length;
+    const hm = upgrade.hostcomponentmap?.length;
+    return config ? (hm ? whatShow.stepper : whatShow.config) : hm ? whatShow.hostMap : whatShow.none;
+  }
+
+  parseData(v: IValue): IMasterData {
+    const getData = (attr: IConfigAttr, c: ConfigFieldsComponent, h: ServiceHostComponent) => {
+      const config = c ? this.configService.parseValue(c.form.value, c.rawConfig.config) : undefined;
+      const hc = h?.statePost.data;
+      return { attr, config, hc };
+    };
+    return v ? getData(v.config?.attr, v.config, v.hostmap) : undefined;
+  }
+
+  send(url: string, value: { config: any; hc: Post[] }) {
+    return this.api.post(url, value);
+  }
+}
diff --git a/web/src/app/shared/components/upgrades/master/upgrade-master-config.component.ts b/web/src/app/shared/components/upgrades/master/upgrade-master-config.component.ts
new file mode 100644
index 0000000000..c8c515ee29
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/master/upgrade-master-config.component.ts
@@ -0,0 +1,36 @@
+// Licensed 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 { Component, Input, ViewChild } from '@angular/core';
+import { FieldService } from '@app/shared/configuration/services/field.service';
+import { ConfigFieldsComponent } from '@app/shared/configuration/fields/fields.component';
+import { IUpgrade } from "@app/shared/components";
+
+@Component({
+  selector: 'app-upgrade-master-config',
+  template: `
+    <div class="config-tools">
+      <mat-checkbox [(ngModel)]="advanced" *ngIf="fields.isAdvanced">Advanced</mat-checkbox>
+    </div>
+    <app-config-fields #fields [model]="upgrade?.config" [ngStyle]="{ display: 'inherit' }"></app-config-fields>
+  `,
+})
+export class UpgradeMasterConfigComponent {
+  @Input() upgrade: IUpgrade;
+
+  @ViewChild('fields') fields: ConfigFieldsComponent = {} as ConfigFieldsComponent;
+
+  set advanced(value: boolean) {
+    this.config.filterApply(this.fields.dataOptions, { advanced: value, search: '' });
+  }
+
+  constructor(private config: FieldService) {}
+}
diff --git a/web/src/app/shared/components/upgrade.component.ts b/web/src/app/shared/components/upgrades/upgrade.component.ts
similarity index 54%
rename from web/src/app/shared/components/upgrade.component.ts
rename to web/src/app/shared/components/upgrades/upgrade.component.ts
index 3e7a7d9eba..6dd895e43d 100644
--- a/web/src/app/shared/components/upgrade.component.ts
+++ b/web/src/app/shared/components/upgrades/upgrade.component.ts
@@ -10,17 +10,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 import { Component, EventEmitter, Input, Output } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
-import { ApiService } from '@app/core/api';
-import { EmmitRow } from '@app/core/types';
-import { concat, Observable, of } from 'rxjs';
-import { filter, map, switchMap } from 'rxjs/operators';
+import { ApiService } from '../../../core/api';
+import { EmmitRow, IActionParameter, IUIOptions } from '../../../core/types';
+import { Observable } from 'rxjs';
+import { filter } from 'rxjs/operators';
 import { EventHelper } from '@adwp-ui/widgets';
-
-import { BaseDirective } from '../directives';
-import { DialogComponent } from './dialog.component';
-import { IIssues } from '@app/models/issue';
-import { IssueHelper } from '@app/helpers/issue-helper';
+import { IIssues } from '../../../models/issue';
+import { IssueHelper } from '../../../helpers/issue-helper';
+import { IConfig } from "../../configuration/types";
 
 export interface UpgradeItem {
   upgradable: boolean;
@@ -28,22 +25,25 @@ export interface UpgradeItem {
   issue: IIssues;
 }
 
-export interface Upgrade {
-  id: number;
+export interface IUpgrade {
   bundle_id: number;
-  name: string;
+  config: IConfig;
   description: string;
   do: string;
-  upgradable: boolean;
-  from_edition: string[];
-  license: 'unaccepted' | 'absent';
+  from_edition: string[]
+  hostcomponentmap:IActionParameter[];
+  id: number;
+  license: string;
   license_url: string;
   max_strict: boolean;
   max_version: string;
   min_strict: boolean;
   min_version: string;
+  name: string;
   state_available: string;
   state_on_success: string;
+  ui_options: IUIOptions;
+  upgradable: boolean;
   url: string;
 }
 
@@ -62,17 +62,16 @@ export interface Upgrade {
     </button>
     <mat-menu #menu="matMenu" [overlapTrigger]="false" [xPosition]="xPosition" yPosition="below">
       <ng-template matMenuContent>
-        <button *ngFor="let item of list$ | async" mat-menu-item (click)="runUpgrade(item)">
+        <button *ngFor="let item of list$ | async" mat-menu-item [appUpgrades]="item" [clusterId]="pRow['id']">
           <span>{{ item.name || 'No name' }}</span>
         </button>
       </ng-template>
     </mat-menu>
   `
 })
-export class UpgradeComponent extends BaseDirective {
+export class UpgradeComponent {
   EventHelper = EventHelper;
-
-  list$: Observable<Upgrade[]>;
+  list$: Observable<IUpgrade[]>;
   pRow: UpgradeItem = { upgradable: false, upgrade: '', issue: null };
 
   @Input() xPosition = 'before';
@@ -86,51 +85,15 @@ export class UpgradeComponent extends BaseDirective {
   @Output()
   refresh: EventEmitter<EmmitRow> = new EventEmitter<EmmitRow>();
 
-  constructor(private api: ApiService, private dialog: MatDialog) {
-    super();
-  }
+  constructor(private api: ApiService) {}
 
   checkIssue() {
     return this.pRow.upgradable && !IssueHelper.isIssue(this.pRow.issue);
   }
 
-  runUpgrade(item: Upgrade) {
-    const license$ = item.license === 'unaccepted' ? this.api.put(`${item.license_url}accept/`, {}) : of();
-    const do$ = this.api.post<{ id: number }>(item.do, {});
-    this.fork(item)
-      .pipe(
-        switchMap(text =>
-          this.dialog
-            .open(DialogComponent, {
-              data: {
-                title: 'Are you sure you want to upgrade?',
-                text,
-                disabled: !item.upgradable,
-                controls: item.license === 'unaccepted' ? {
-                  label: 'Do you accept the license agreement?',
-                  buttons: ['Yes', 'No']
-                } : ['Yes', 'No']
-              }
-            })
-            .beforeClosed()
-            .pipe(
-              this.takeUntil(),
-              filter(yes => yes),
-              switchMap(() => concat(license$, do$))
-            )
-        )
-      )
-      .subscribe(row => this.refresh.emit({ cmd: 'refresh', row }));
-  }
-
-  fork(item: Upgrade) {
-    const flag = item.license === 'unaccepted';
-    return flag ? this.api.get<{ text: string }>(item.license_url).pipe(map(a => a.text)) : of(item.description);
-  }
-
   getUpgrades(upgrade: string): Observable<any> {
     return this.api.get(`${upgrade}?ordering=-name`).pipe(
-      filter((list: Upgrade[]) => !!list.length)
+      filter((list: IUpgrade[]) => !!list.length)
     );
   }
 }
diff --git a/web/src/app/shared/components/upgrades/upgrade.directive.ts b/web/src/app/shared/components/upgrades/upgrade.directive.ts
new file mode 100644
index 0000000000..38067b7b11
--- /dev/null
+++ b/web/src/app/shared/components/upgrades/upgrade.directive.ts
@@ -0,0 +1,221 @@
+// Licensed 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 { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
+import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
+import { DialogComponent } from '../dialog.component';
+import { IUpgrade } from "./upgrade.component";
+import { concat, Observable, of } from "rxjs";
+import { filter, map, switchMap, tap } from "rxjs/operators";
+import { ApiService } from "@app/core/api";
+import { EmmitRow } from "@app/core/types";
+import { BaseDirective } from "../../directives";
+import { UpgradeMasterComponent as component } from "../upgrades/master/master.component";
+import { AddService } from "@app/shared/add-component/add.service";
+import { IRawHosComponent } from "@app/shared/host-components-map/types";
+
+export interface UpgradeParameters {
+  cluster?: {
+    id: number;
+    hostcomponent: IRawHosComponent;
+  };
+  upgrades: IUpgrade[];
+}
+
+@Directive({
+  selector: '[appUpgrades]'
+})
+export class UpgradesDirective extends BaseDirective {
+  @Input('appUpgrades') inputData: IUpgrade;
+  @Input() clusterId: number;
+  @Output() refresh: EventEmitter<EmmitRow> = new EventEmitter<EmmitRow>();
+
+  hc: IRawHosComponent;
+  needPrototype = false;
+
+  constructor(private api: ApiService, private dialog: MatDialog, private service: AddService) {
+    super();
+  }
+
+  @HostListener('click')
+  onClick() {
+    this.dialog.closeAll();
+    if (this.hasHostComponent) {
+      this.checkHostComponents();
+    } else {
+      this.prepare();
+    }
+  }
+
+  get hasConfig(): boolean {
+    return this?.inputData?.config?.config?.length > 0
+  }
+
+  get hasHostComponent(): boolean {
+    return this?.inputData?.hostcomponentmap?.length > 0
+  }
+
+  get hasDisclaimer(): boolean {
+    return !!this?.inputData?.ui_options['disclaimer']
+  }
+
+  prepare(): void {
+    let dialogModel: MatDialogConfig
+    const maxWidth = '1400px';
+    const isMulty = this?.inputData.upgradable;
+    const width = isMulty || this.hasConfig || this.hasHostComponent ? '90%' : '400px';
+    const title = this?.inputData.ui_options['disclaimer'] ? this?.inputData.ui_options['disclaimer'] : isMulty ? 'Run upgrades?' : `Run upgrade [ ${this?.inputData.name} ]?`;
+    const data: IUpgrade = this.inputData as IUpgrade;
+    const model: UpgradeParameters = {
+      cluster: {
+        id: this.clusterId,
+        hostcomponent: this.hc,
+      },
+      upgrades: [this.inputData],
+    }
+
+    dialogModel =  {
+      width,
+      maxWidth,
+      data: {
+        title,
+        model,
+        component,
+      }
+    };
+
+    if (this.hasDisclaimer) {
+      if (this.hasConfig || this.hasHostComponent) {
+        this.runUpgrade(data, dialogModel);
+      } else if (!this.hasConfig && !this.hasHostComponent) {
+        this.runOldUpgrade(data);
+      }
+    } else {
+      if (!this.hasConfig && !this.hasHostComponent) {
+        dialogModel.maxWidth = '1400px';
+        dialogModel.width = '400px';
+        dialogModel.minHeight = '180px';
+        dialogModel.data.title = 'Are you sure you want to upgrade?'
+        dialogModel.data.text = 'The cluster will be prepared for upgrade';
+      }
+
+      this.dialog.open(DialogComponent, dialogModel);
+    }
+  }
+
+  runUpgrade(item: IUpgrade, dialogModel: MatDialogConfig) {
+    this.fork(item)
+      .pipe(
+        tap(text => {
+            return this.dialog
+              .open(DialogComponent, {
+                data: {
+                  title: 'Are you sure you want to upgrade?',
+                  text: item.ui_options['disclaimer'] || text,
+                  disabled: !item.upgradable,
+                  controls: item.license === 'unaccepted' ? {
+                    label: 'Do you accept the license agreement?',
+                    buttons: ['Yes', 'No']
+                  } : ['Yes', 'No']
+                }
+              })
+              .afterClosed()
+              .subscribe((res) => {
+                if (res) this.dialog.open(DialogComponent, dialogModel);
+              })
+          }
+        )
+      ).subscribe();
+  }
+
+  runOldUpgrade(item: IUpgrade) {
+    const license$ = item.license === 'unaccepted' ? this.api.put(`${item.license_url}accept/`, {}) : of();
+    const do$ = this.api.post<{ id: number }>(item.do, {});
+
+    this.fork(item)
+      .pipe(
+        switchMap(text =>
+          this.dialog
+            .open(DialogComponent, {
+              data: {
+                title: 'Are you sure you want to upgrade?',
+                text: item.ui_options['disclaimer'] || text,
+                disabled: !item.upgradable,
+                controls: item.license === 'unaccepted' ? {
+                  label: 'Do you accept the license agreement?',
+                  buttons: ['Yes', 'No']
+                } : ['Yes', 'No']
+              }
+            })
+            .beforeClosed()
+            .pipe(
+              filter(yes => yes),
+              switchMap(() => concat(license$, do$))
+            )
+        )
+      )
+      .subscribe(row => this.refresh.emit({cmd: 'refresh', row}));
+  }
+
+  fork(item: IUpgrade) {
+    const flag = item.license === 'unaccepted';
+    return flag ? this.api.get<{ text: string }>(item.license_url).pipe(map(a => a.text)) : of(item.description);
+  }
+
+  checkHostComponents() {
+    this.getClusterInfo()
+      .pipe(
+        tap((cluster): any => {
+          const hostComponentMap = this.inputData.hostcomponentmap;
+
+          hostComponentMap.forEach((hc, index) => {
+            if (!cluster.component.find(c => c.name === hc.component)) {
+              this.needPrototype = true;
+              const params = {
+                bundle_id: this.inputData.bundle_id,
+                type: 'component',
+                name: hc.component,
+                parent_name: hc.service,
+                limit: 50,
+                offset: 0
+              };
+
+              // fix later
+              this.service.getPrototype('prototype', params).subscribe((prototype): any => {
+                if (prototype[0]) {
+                  cluster.component.push(prototype[0]);
+                  this.hc = cluster;
+                }
+
+                if (hostComponentMap.length === index + 1) {
+                  if (!this.hc) this.hc = cluster;
+                  this.prepare();
+                }
+              })
+            }
+          })
+
+          if (!this.needPrototype) {
+            this.hc = cluster;
+            this.prepare();
+          }
+        }),
+      ).subscribe();
+  }
+
+  getClusterInfo(): Observable<any> {
+    return this.api.get(`api/v1/cluster/${this.clusterId}/hostcomponent/`)
+  }
+
+  getPrototype(params): Observable<any> {
+    return this.service.getPrototype('prototype', params)
+  }
+}
diff --git a/web/src/app/shared/configuration/attributes/attribute.service.ts b/web/src/app/shared/configuration/attributes/attribute.service.ts
index c8da66cc79..9b2d735955 100644
--- a/web/src/app/shared/configuration/attributes/attribute.service.ts
+++ b/web/src/app/shared/configuration/attributes/attribute.service.ts
@@ -93,7 +93,7 @@ export class AttributeService {
       for (const [key, value] of this._attributes.entries()) {
         json = {
           ...json,
-          [key]: value.form.value
+          [key]: value.form.getRawValue()
         };
       }
     }
diff --git a/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts b/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
index 4facd76c33..841715179d 100644
--- a/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
+++ b/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
@@ -90,10 +90,12 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
     });
 
     if (!enabled) {
-      this.groupControl.disable();
-      this.parameterControl().disable();
+      Promise.resolve().then(() => {
+        this.groupControl.disable();
+        this.parameterControl().disable();
 
-      this.tooltipText = text;
+        this.tooltipText = text;
+      });
     } else {
       Promise.resolve().then(() => {
         this.groupControl.enable();
@@ -102,6 +104,7 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
         } else {
           this.parameterControl().disable();
         }
+        this.disableIfReadOnly();
       })
 
       this.tooltipText = this.wrapperOptions.tooltipText;
diff --git a/web/src/app/shared/configuration/field/field.component.html b/web/src/app/shared/configuration/field/field.component.html
index 0fede65b88..c381e6e613 100644
--- a/web/src/app/shared/configuration/field/field.component.html
+++ b/web/src/app/shared/configuration/field/field.component.html
@@ -48,7 +48,7 @@
     </span>
   </ng-container>
 </div>
-<mat-list class="compare-list-container" dense *ngIf="!options.hidden && options.compare.length">
+<mat-list class="compare-list-container" dense *ngIf="!options.hidden && options.compare?.length">
   <mat-list-item *ngFor="let item of options.compare" class="compare-list">
     <mat-icon matListIcon [style.backgroundColor]="item.color">more_vert</mat-icon>
     <span class="label">
diff --git a/web/src/app/shared/configuration/fields/fields.component.ts b/web/src/app/shared/configuration/fields/fields.component.ts
index 569083794a..85d553b805 100644
--- a/web/src/app/shared/configuration/fields/fields.component.ts
+++ b/web/src/app/shared/configuration/fields/fields.component.ts
@@ -41,11 +41,10 @@ export class ConfigFieldsComponent extends BaseDirective {
   @Output()
   event = new EventEmitter<{ name: string; data?: any }>();
 
-
   rawConfig: IConfig;
   shapshot: any;
   isAdvanced = false;
-
+  isCustomGroup = false;
 
   @Input()
   set model(data: IConfig) {
@@ -54,6 +53,7 @@ export class ConfigFieldsComponent extends BaseDirective {
     this.dataOptions = this.service.getPanels(data);
     this.service.getAttrs(data, this.dataOptions.map(a => a.name), this.dataOptions);
     this.form = this.service.toFormGroup(this.dataOptions);
+    this.isCustomGroup = this.checkCustomGroup();
     this.isAdvanced = data.config.some((a) => a.ui_options && a.ui_options.advanced);
     this.shapshot = { ...this.form.value };
     this.event.emit({ name: 'load', data: { form: this.form } });
@@ -85,6 +85,18 @@ export class ConfigFieldsComponent extends BaseDirective {
     return item.name;
   }
 
+  checkCustomGroup() {
+    const check = (value) => {
+      if (!value.hasOwnProperty('custom_group')) {
+        return true;
+      }
+
+      return value.custom_group === true;
+    }
+
+    return this.dataOptions.some((config) => check(config));
+  }
+
   /**
    * This method detects the moment rendering final of all fields and groups (with internal fields) on the page
    * it's need for test
diff --git a/web/src/app/shared/configuration/main/config.component.html b/web/src/app/shared/configuration/main/config.component.html
index 325b318dfa..f3a12fb622 100644
--- a/web/src/app/shared/configuration/main/config.component.html
+++ b/web/src/app/shared/configuration/main/config.component.html
@@ -1,7 +1,7 @@
 <app-tools #tools (applyFilter)="filter($event)"
            (save)="save(configUrl)"
            (showHistory)="historyShow=$event"
-           [disabledSave]="isLock || fls?.form?.invalid">
+           [disabledSave]="isLock || fls?.form?.untouched || fls?.form?.invalid">
 
 </app-tools>
 
diff --git a/web/src/app/shared/configuration/main/config.component.spec.ts b/web/src/app/shared/configuration/main/config.component.spec.ts
index d0d1d0e86a..7fab865ce0 100644
--- a/web/src/app/shared/configuration/main/config.component.spec.ts
+++ b/web/src/app/shared/configuration/main/config.component.spec.ts
@@ -46,6 +46,7 @@ const rawConfig: IConfig = {
       description: '',
       required: false,
       group_config: null,
+      custom_group: true,
     },
     {
       name: 'group',
@@ -59,6 +60,7 @@ const rawConfig: IConfig = {
       description: '',
       required: false,
       group_config: null,
+      custom_group: false,
     },
     {
       name: 'group',
@@ -72,6 +74,7 @@ const rawConfig: IConfig = {
       description: '',
       required: true,
       group_config: null,
+      custom_group: false,
     },
   ],
 };
@@ -159,7 +162,7 @@ describe('Configuration : ConfigComponent >> ', () => {
     fixture.detectChanges();
     component.rawConfig.next(rawConfig);
     component.cd.detectChanges();
-    component.tools.disabledSave = component.fields.form.invalid;
+    component.tools.disabledSave = !component.fields.form.untouched || component.fields.form.invalid;
     component.cd.detectChanges();
     const saveBtn = fixture.nativeElement.querySelector('app-tools div.control-buttons button.form_config_button_save');
     expect(saveBtn.disabled).toBeFalse();
@@ -168,6 +171,10 @@ describe('Configuration : ConfigComponent >> ', () => {
     component.cd.detectChanges();
 
     component.fields.form.get('group').get('field_in_group').setValue('string_not_valid');
+    component.fields.form.markAsTouched();
+    component.fields.form.get('group').markAsTouched();
+    component.fields.form.get('group').get('field_in_group').markAsTouched();
+    component.tools.disabledSave = !component.fields.form.untouched;
     component.cd.detectChanges();
 
     expect(saveBtn.disabled).toBeTruthy();
diff --git a/web/src/app/shared/configuration/types.ts b/web/src/app/shared/configuration/types.ts
index 64596b797b..383d88a44a 100644
--- a/web/src/app/shared/configuration/types.ts
+++ b/web/src/app/shared/configuration/types.ts
@@ -83,6 +83,7 @@ export interface IFieldStack {
   activatable: boolean;
   read_only: boolean;
   description?: string;
+  custom_group?: boolean;
   limits?: ILimits;
   ui_options?: IUIoptions;
   group_config?: { [key: string]: boolean };
diff --git a/web/src/app/shared/details/details.module.ts b/web/src/app/shared/details/details.module.ts
index a2f65805b3..7b586f42a4 100644
--- a/web/src/app/shared/details/details.module.ts
+++ b/web/src/app/shared/details/details.module.ts
@@ -19,12 +19,9 @@ import { MatSidenavModule } from '@angular/material/sidenav';
 import { MatToolbarModule } from '@angular/material/toolbar';
 import { RouterModule } from '@angular/router';
 import { AdwpListModule } from '@adwp-ui/widgets';
-
 import { StuffModule } from '../stuff.module';
 import { SubtitleComponent } from './subtitle.component';
 import { NavigationComponent } from '@app/components/navigation/navigation.component';
-import { ActionsButtonComponent } from '@app/components/actions-button/actions-button.component';
-
 import { NavItemPipe } from '@app/pipes/nav-item.pipe';
 import { MatTooltipModule } from '@angular/material/tooltip';
 import { ObjectLinkColumnPipe } from '@app/pipes/object-link-column.pipe';
@@ -73,7 +70,6 @@ import {
     ClusterDetailsComponent,
     BundleDetailsComponent,
     JobDetailsComponent,
-    ActionsButtonComponent,
     MaintenanceModeButtonComponent,
     ObjectLinkColumnPipe,
     SortObjectsPipe,
@@ -91,7 +87,6 @@ import {
     JobDetailsComponent,
     SubtitleComponent,
     NavigationComponent,
-    ActionsButtonComponent,
     MaintenanceModeButtonComponent,
     NavItemPipe,
     ObjectLinkColumnPipe,
diff --git a/web/src/app/shared/form-elements/map.component.ts b/web/src/app/shared/form-elements/map.component.ts
index a4ac0019d7..9ab793b9c9 100644
--- a/web/src/app/shared/form-elements/map.component.ts
+++ b/web/src/app/shared/form-elements/map.component.ts
@@ -38,11 +38,15 @@ export class BaseMapListDirective extends FieldDirective implements OnInit {
       if (state === 'DISABLED') {
         this.items.controls.forEach((control) => {
           control.disable({ emitEvent: false });
+          control.markAsUntouched();
         });
+        this.control.markAsUntouched();
       } else {
         this.items.controls.forEach((control) => {
           control.enable({ emitEvent: false });
+          control.markAsTouched();
         });
+        this.control.markAsTouched();
       }
     });
   }
@@ -54,6 +58,7 @@ export class BaseMapListDirective extends FieldDirective implements OnInit {
     }), {}) : null;
     if (value && this.asList) value = (value as Array<string>).length ? value : null;
     this.control.setValue(value);
+    this.control.markAsTouched();
   }
 
   reload() {
diff --git a/web/src/app/shared/form-elements/secret-text/secret-text.component.ts b/web/src/app/shared/form-elements/secret-text/secret-text.component.ts
index 1e29a8bb52..2332e19035 100644
--- a/web/src/app/shared/form-elements/secret-text/secret-text.component.ts
+++ b/web/src/app/shared/form-elements/secret-text/secret-text.component.ts
@@ -50,8 +50,12 @@ export class SecretTextComponent extends FieldDirective implements OnInit, OnCha
     this.control.statusChanges.pipe(this.takeUntil()).subscribe((status) => {
       if (status === 'DISABLED') {
         this.dummyControl.disable();
+        this.dummyControl.markAsUntouched();
+        this.control.markAsUntouched();
       } else {
         this.dummyControl.enable();
+        this.dummyControl.markAsTouched();
+        this.control.markAsTouched();
       }
     });
   }
diff --git a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
index 7b8f77d3f1..af63450331 100644
--- a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
+++ b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
@@ -6,7 +6,7 @@
         [style.color]="model.color">{{ model.name }}</span>
     </button>
     <button mat-raised-button color="primary"
-      (click)="toggleRelations()">{{ model.limit?.length ? setNotify() : model.relations.length }}</button>
+      (click)="toggleRelations()">{{ model.limit?.length ? setNotify() : model.relations?.length }}</button>
   </div>
   <div [ngClass]="{'relations-list':true, 'show': model.relations.length && isShow, 'disabled': isHostDisabled()}">
     <div *ngFor="let rel of model.relations">
diff --git a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
index 845648b266..0eefcc724f 100644
--- a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
+++ b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
@@ -37,8 +37,8 @@ export class Much2ManyComponent implements AfterViewChecked {
   }
 
   isDisabled() {
-    if (this.model.actions) return !this.model.actions.length;
-    return this.model.disabled;
+    if (this.model?.actions) return !this.model.actions.length;
+    return this.model?.disabled;
   }
 
   isHostDisabled() {
diff --git a/web/src/app/shared/host-components-map/services2hosts/service-host.component.html b/web/src/app/shared/host-components-map/services2hosts/service-host.component.html
index ce0c56252a..4fd26a919b 100644
--- a/web/src/app/shared/host-components-map/services2hosts/service-host.component.html
+++ b/web/src/app/shared/host-components-map/services2hosts/service-host.component.html
@@ -17,7 +17,7 @@ <h3><span>Components</span></h3>
   </div>
   <div>
     <h3><span>Hosts</span>
-      <app-add-button [name]="'host2cluster'" [asIcon]="true" [style.lineHeight.px]="16"></app-add-button>
+      <app-add-button [name]="'host2cluster'" [asIcon]="true" [style.lineHeight.px]="16" [clusterId]="cluster?.id"></app-add-button>
     </h3>
     <div class="wrapper">
       <div *ngIf="!Hosts?.length">
diff --git a/web/src/app/shared/host-components-map/services2hosts/service-host.component.spec.ts b/web/src/app/shared/host-components-map/services2hosts/service-host.component.spec.ts
index 5b26c73042..2d84023761 100644
--- a/web/src/app/shared/host-components-map/services2hosts/service-host.component.spec.ts
+++ b/web/src/app/shared/host-components-map/services2hosts/service-host.component.spec.ts
@@ -93,7 +93,7 @@ describe('Service Host Map Component', () => {
     expect(component).toBeDefined();
   });
 
-  it('should display compnents and hosts as button', () => {
+  it('should display components and hosts as button', () => {
     initDefault(genData());
     const dom = domFirstElems(fixture.nativeElement);
     const components = dom.components;
@@ -133,7 +133,7 @@ describe('Service Host Map Component', () => {
     const host_isLinked = () => host.querySelector('.m2m').classList.contains('linked');
 
     // start: host - select
-    host_btn.click();
+    host_btn.click(); // added class .selected
     fixture.detectChanges();
     expect(host_isSelect()).toBeTrue();
 
diff --git a/web/src/app/shared/host-components-map/services2hosts/service-host.component.ts b/web/src/app/shared/host-components-map/services2hosts/service-host.component.ts
index a716cf4d74..aab21be83e 100644
--- a/web/src/app/shared/host-components-map/services2hosts/service-host.component.ts
+++ b/web/src/app/shared/host-components-map/services2hosts/service-host.component.ts
@@ -16,10 +16,11 @@ import { keyChannelStrim } from '@app/core/services';
 import { EventMessage, IEMObject, SocketState } from '@app/core/store';
 import { IActionParameter } from '@app/core/types';
 import { Store } from '@ngrx/store';
-
 import { SocketListenerDirective } from '@app/shared/directives';
 import { getSelected, TakeService } from '../take.service';
 import { CompTile, HostTile, IRawHosComponent, Post, StatePost, Tile } from '../types';
+import { ApiService } from "@app/core/api";
+import { Observable } from "rxjs";
 
 @Component({
   selector: 'app-service-host',
@@ -39,7 +40,7 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
   form = new FormGroup({});
 
   @Input()
-  cluster: { id: number; hostcomponent: string };
+  cluster: { id: number; hostcomponent: string | IRawHosComponent };
 
   /**
    * fixed position buttons for the scrolling
@@ -79,7 +80,7 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
     this.sourceMap.set('compo', v);
   }
 
-  constructor(public service: TakeService, private channel: ChannelService, socket: Store<SocketState>) {
+  constructor(public service: TakeService, private channel: ChannelService, socket: Store<SocketState>, private api: ApiService) {
     super(socket);
   }
 
@@ -97,11 +98,15 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
       .subscribe((e) => (this.scrollEventData = e));
   }
 
+  getClusterInfo(): Observable<any> {
+    return this.api.get(`api/v1/cluster/${this.cluster.id}/hostcomponent/`);
+  }
+
   socketListener(m: EventMessage) {
-    const isCurrent = (type: string, id: number) => type === 'cluster' && id === this.cluster.id;
+    const isCurrent = (type: string, id: number) => type === 'cluster' && id === this.cluster?.id;
     if (
       (m.event === 'change_hostcomponentmap' || m.event === 'change_state') &&
-      isCurrent(m.object.type, m.object.id) &&
+      isCurrent(m.object.type, m.object?.id) &&
       !this.saveFlag
     ) {
       this.reset().load();
@@ -126,16 +131,16 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
 
   add(io: IEMObject) {
     const { id, type, details } = io;
-    if (details.type === 'cluster' && +details.value === this.cluster.id) {
+    if (details.type === 'cluster' && +details.value === this.cluster?.id && typeof this.cluster.hostcomponent === 'string') {
       this.service
-        .load(this.cluster.hostcomponent)
+        .load(this.cluster.hostcomponent as string)
         .pipe(this.takeUntil())
         .subscribe((raw: IRawHosComponent) => {
           if (type === 'host')
             this.Hosts = [
               ...this.Hosts,
               ...this.service.fillHost(
-                raw.host.map((h) => new HostTile(h)).filter((h) => h.id === id),
+                raw.host.map((h) => new HostTile(h)).filter((h) => h?.id === id),
                 this.actionParameters
               ),
             ];
@@ -143,11 +148,23 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
             this.Components = [
               ...this.Components,
               ...this.service.fillComponent(
-                raw.component.filter((a) => a.service_id === id && this.Components.every((b) => b.id !== a.id)),
+                raw.component.filter((a) => a.service_id === id && this.Components.every((b) => b?.id !== a?.id)),
                 this.actionParameters
               ),
             ];
         });
+    } else if (typeof this.cluster.hostcomponent !== 'string') {
+      this.getClusterInfo()
+        .pipe(this.takeUntil())
+        .subscribe((res) => {
+          this.Hosts = [
+            ...this.Hosts,
+            ...this.service.fillHost(
+              res.host.map((h) => new HostTile(h)).filter((h) => h?.id === id),
+              this.actionParameters
+            ),
+          ];
+        });
     }
   }
 
@@ -155,7 +172,7 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
   remove(io: IEMObject) {
     if (io.type === 'host') {
       const { id } = io;
-      this.Hosts = this.Hosts.filter((a) => a.id !== id);
+      this.Hosts = this.Hosts.filter((a) => a?.id !== id);
     }
   }
 
@@ -164,10 +181,14 @@ export class ServiceHostComponent extends SocketListenerDirective implements OnI
       if (this.initFlag) return;
       this.initFlag = true;
 
-      this.service
-        .load(this.cluster.hostcomponent)
-        .pipe(this.takeUntil())
-        .subscribe((raw: IRawHosComponent) => this.init(raw));
+      if (typeof this.cluster.hostcomponent === 'string' ) {
+        this.service
+          .load(this.cluster.hostcomponent)
+          .pipe(this.takeUntil())
+          .subscribe((raw: IRawHosComponent) => this.init(raw));
+      } else {
+        this.init(this.cluster.hostcomponent);
+      }
     }
   }
 
diff --git a/web/src/app/shared/host-components-map/take.service.ts b/web/src/app/shared/host-components-map/take.service.ts
index 1d2730f6a7..7ed2a93f1a 100644
--- a/web/src/app/shared/host-components-map/take.service.ts
+++ b/web/src/app/shared/host-components-map/take.service.ts
@@ -19,7 +19,17 @@ import { filter, map, switchMap, take } from 'rxjs/operators';
 import { AddService } from '../add-component/add.service';
 import { DialogComponent } from '../components';
 import { DependenciesComponent } from './dependencies.component';
-import { CompTile, TConstraint, HostTile, IRawHosComponent, IStream, Post, StatePost, Tile } from './types';
+import {
+  CompTile,
+  TConstraint,
+  HostTile,
+  IRawHosComponent,
+  IStream,
+  Post,
+  StatePost,
+  Tile,
+  PrototypePost
+} from './types';
 
 export const getSelected = (from: Tile[]): Tile => from.find((a) => a.isSelected);
 
@@ -82,9 +92,9 @@ export class TakeService {
     return this.api.get<IRawHosComponent>(url);
   }
 
-  save(cluster_id: number, hostcomponent: string, hc: Post[]) {
+  save(cluster_id: number, hostcomponent: string | IRawHosComponent, hc: Post[]) {
     const send = { cluster_id, hc };
-    return this.api.post<Post[]>(hostcomponent, send).pipe(take(1));
+    return this.api.post<Post[]>(hostcomponent as string, send).pipe(take(1));
   }
   //#endregion
 
@@ -102,7 +112,7 @@ export class TakeService {
       (c) =>
         new CompTile(
           c,
-          ap ? ap.filter((a) => a.service === c.service_name && a.component === c.name).map((b) => b.action) : null
+          ap ? ap.filter((a) => a?.service === c?.service_name && a?.component === c?.name).map((b) => b.action) : null
         )
     );
   }
@@ -236,7 +246,7 @@ export class TakeService {
     host.isLink = false;
     host.relations = host.relations.filter((r) => r.id !== component.id);
 
-    state.delete(new Post(host.id, component.service_id, component.id));
+    state.delete(this.containsPrototype(component, host));
     this.clearDependencies(component, state, cs, hs, form);
     this.setFormValue(component, form);
   }
@@ -294,7 +304,7 @@ export class TakeService {
       link.relations.push(target);
       target.relations.push(link);
       target.isLink = true;
-      state.add(new Post(host.id, component.service_id, component.id));
+      state.add(this.containsPrototype(component, host));
     }
     this.setFormValue(component, form);
   }
@@ -317,5 +327,13 @@ export class TakeService {
       )
       .subscribe();
   }
+
+  containsPrototype(component, host) {
+    if (component?.is_prototype) {
+      return new PrototypePost(host.id, component.service_id, component.id);
+    }
+
+    return new Post(host.id, component.service_id, component.id)
+  }
   //#endregion
 }
diff --git a/web/src/app/shared/host-components-map/types.ts b/web/src/app/shared/host-components-map/types.ts
index 4286bc2bee..ae7259310a 100644
--- a/web/src/app/shared/host-components-map/types.ts
+++ b/web/src/app/shared/host-components-map/types.ts
@@ -25,11 +25,19 @@ export interface Post {
   id?: number;
   host_id: number;
   service_id: number;
-  component_id: number;
+  component_id?: number;
+}
+
+export interface PrototypePost extends Post {
+  component_prototype_id: number;
 }
 
 export class Post implements Post {
-  constructor(public host_id: number, public service_id: number, public component_id: number, public id?: number) {}
+  constructor(public host_id: number, public service_id: number, public component_id?: number,  public id?: number ) {}
+}
+
+export class PrototypePost implements Post {
+  constructor(public host_id: number, public service_id: number, public component_prototype_id: number, public id?: number ) {}
 }
 
 /**
@@ -61,6 +69,7 @@ export class Tile {
   color?: 'none' | 'white' | 'gray' | 'yellow';
   notification?: string;
   mm?: string;
+  is_prototype?: boolean;
 }
 
 export class HostTile extends Tile {
@@ -79,14 +88,21 @@ export class CompTile extends Tile {
   requires: IRequires[];
   constructor(rawComponent: IComponent, public actions?: ActionParam[]) {
     super();
-    this.id = rawComponent.id;
-    this.service_id = rawComponent.service_id;
-    this.component = `${rawComponent.service_name}/${rawComponent.name}`;
-    this.name = rawComponent.display_name;
-    this.disabled = rawComponent.service_state !== 'created';
-    this.limit = rawComponent.constraint;
-    this.requires = rawComponent.requires;
-    this.prototype_id = rawComponent.prototype_id;
+    if (rawComponent) {
+      this.id = rawComponent.id;
+      this.service_id = rawComponent.service_id;
+      this.component = `${rawComponent.service_name}/${rawComponent.name}`;
+      this.name = rawComponent.display_name;
+      this.disabled = rawComponent.service_state !== 'created';
+      this.limit = rawComponent.constraint;
+      this.requires = rawComponent.requires;
+      this.prototype_id = rawComponent.prototype_id;
+    }
+    this.is_prototype = this.isPrototype(rawComponent);
+  }
+
+  isPrototype(component): boolean {
+    return component?.url?.includes('prototype')
   }
 }
 
@@ -125,7 +141,15 @@ export class StatePost {
   }
 
   update(data: Post[]) {
-    data.forEach((a) => this.add(new Post(a.host_id, a.service_id, a.component_id, a.id)));
+    data.forEach((a) => this.add(this.containsPrototype(a)));
+  }
+
+  containsPrototype(component): Post | PrototypePost {
+   if (component?.url?.includes('prototype')) {
+     return new PrototypePost(component.host_id, component.service_id, component.id);
+   }
+
+   return new Post(component.host_id, component.service_id, component.component_id, component.id);
   }
 }
 /**
diff --git a/web/src/app/shared/shared.module.ts b/web/src/app/shared/shared.module.ts
index d4e5cb6134..2d7109eab4 100644
--- a/web/src/app/shared/shared.module.ts
+++ b/web/src/app/shared/shared.module.ts
@@ -17,6 +17,7 @@ import { TranslateModule } from '@ngx-translate/core';
 import { MatTreeModule } from '@angular/material/tree';
 
 import { AddingModule } from './add-component/adding.module';
+import { UpgradeMasterComponent } from "@app/shared/components/upgrades/master/master.component";
 import {
   ActionMasterComponent,
   ButtonSpinnerComponent,
@@ -61,6 +62,7 @@ import { RbacRoleService } from '@app/services/rbac-role.service';
 import { RbacPolicyService } from '@app/services/rbac-policy.service';
 import { DynamicModule } from '@app/shared/directives/dynamic/dynamic.module';
 import { RbacObjectCandidateService } from '@app/services/rbac-object-candidate.service';
+import {UpgradeMasterConfigComponent} from "@app/shared/components/upgrades/master/upgrade-master-config.component";
 
 @NgModule({
   imports: [
@@ -96,6 +98,8 @@ import { RbacObjectCandidateService } from '@app/services/rbac-object-candidate.
     ExportComponent,
     ActionMasterComponent,
     ActionMasterConfigComponent,
+    UpgradeMasterComponent,
+    UpgradeMasterConfigComponent,
     StatusColumnComponent,
     StateColumnComponent,
     EditionColumnComponent,
diff --git a/web/src/app/shared/stuff.module.ts b/web/src/app/shared/stuff.module.ts
index d91f775cc8..aea995e168 100644
--- a/web/src/app/shared/stuff.module.ts
+++ b/web/src/app/shared/stuff.module.ts
@@ -16,6 +16,7 @@ import { RouterModule } from '@angular/router';
 import { ActionsComponent, CrumbsComponent, UpgradeComponent } from './components';
 import { ActionListComponent } from './components/actions/action-list/action-list.component';
 import { ActionsDirective } from './components/actions/actions.directive';
+import { UpgradesDirective } from './components/upgrades/upgrade.directive';
 import { TooltipDirective } from '@app/shared/components/tooltip';
 import { PopoverDirective } from '@app/directives/popover.directive';
 import {
@@ -40,6 +41,7 @@ import { ConcernListComponent } from '@app/components/concern/concern-list/conce
 import { ConcernListRefComponent } from '@app/components/concern/concern-list-ref/concern-list-ref.component';
 import { TooltipModule } from '@app/shared/components/tooltip/tooltip.module';
 import { HasSelectedPipe } from '@app/pipes/has-selected.pipe';
+import { ActionsButtonComponent } from "@app/components/actions-button/actions-button.component";
 
 @NgModule({
   declarations: [
@@ -53,6 +55,7 @@ import { HasSelectedPipe } from '@app/pipes/has-selected.pipe';
     InfinityScrollDirective,
     ActionsComponent,
     ActionsDirective,
+    UpgradesDirective,
     ActionListComponent,
     MenuItemComponent,
     PopoverDirective,
@@ -66,6 +69,7 @@ import { HasSelectedPipe } from '@app/pipes/has-selected.pipe';
     IsArrayPipe,
     HasSelectedPipe,
     IssuePathPipe,
+    ActionsButtonComponent,
   ],
   imports: [
     CommonModule,
@@ -85,6 +89,7 @@ import { HasSelectedPipe } from '@app/pipes/has-selected.pipe';
     InfinityScrollDirective,
     ActionsComponent,
     ActionsDirective,
+    UpgradesDirective,
     ActionListComponent,
     MenuItemComponent,
     PopoverDirective,
@@ -98,7 +103,8 @@ import { HasSelectedPipe } from '@app/pipes/has-selected.pipe';
     HasSelectedPipe,
     IssuePathPipe,
     ConcernListRefComponent,
-    TooltipModule
+    TooltipModule,
+    ActionsButtonComponent
   ],
   providers: [
     ConcernService,


Test patch:
diff --git a/conf/adcm/python_scripts/testing_ldap_connection.py b/conf/adcm/python_scripts/testing_ldap_connection.py
new file mode 100644
index 0000000000..8b9f6f0ead
--- /dev/null
+++ b/conf/adcm/python_scripts/testing_ldap_connection.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+# Licensed 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 os
+import sys
+import ldap
+
+os.environ["PYTHONPATH"] = "/adcm/python/"
+sys.path.append("/adcm/python/")
+
+import adcm.init_django  # pylint: disable=unused-import
+from rbac.ldap import _get_ldap_default_settings, configure_tls, is_tls
+from cm.errors import AdcmEx
+
+CERT_ENV_KEY = 'LDAPTLS_CACERT'
+
+
+def bind():
+    ldap_config, error_code = _get_ldap_default_settings()
+    if error_code is not None:
+        error = AdcmEx(error_code)
+        sys.stdout.write(error.msg)
+        raise error
+    if ldap_config:
+        ldap.set_option(ldap.OPT_REFERRALS, 0)
+        ldap_URI = ldap_config['SERVER_URI']
+        try:
+            conn = ldap.initialize(ldap_URI)
+            conn.protocol_version = ldap.VERSION3
+            configure_tls(is_tls(ldap_URI), os.environ.get(CERT_ENV_KEY, ''), conn)
+            conn.simple_bind_s(ldap_config['BIND_DN'], ldap_config['BIND_PASSWORD'])
+        except ldap.LDAPError as e:
+            sys.stdout.write(f"Can't connect to {ldap_URI} with user: {ldap_config['BIND_DN']}. Error: {e}\n")
+            raise
+        sys.stdout.write(f"Connection successful to {ldap_URI}\n")
+
+
+if __name__ == '__main__':
+    bind()
diff --git a/python/cm/tests_hc.py b/python/cm/tests_hc.py
index aaff5b3314..3d97797b80 100644
--- a/python/cm/tests_hc.py
+++ b/python/cm/tests_hc.py
@@ -29,12 +29,12 @@ def test_action_hc_simple(self):  # pylint: disable=too-many-locals
         h1 = Host.objects.get(provider=provider, fqdn='server01.inter.net')
 
         action = Action(name="run")
-        hc_list = cm.job.check_hostcomponentmap(cluster, action, [])
-        self.assertEqual(hc_list, [])
+        hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, [])
+        self.assertEqual(hc_list, None)
 
         try:
             action = Action(name="run", hostcomponentmap='qwe')
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, [])
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, [])
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'TASK_ERROR')
@@ -45,7 +45,7 @@ def test_action_hc_simple(self):  # pylint: disable=too-many-locals
         try:
             action = Action(name="run", hostcomponentmap='qwe')
             hc = [{"service_id": co.id, "component_id": sc1.id, "host_id": 500}]
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'HOST_NOT_FOUND')
@@ -53,7 +53,7 @@ def test_action_hc_simple(self):  # pylint: disable=too-many-locals
         try:
             action = Action(name="run", hostcomponentmap='qwe')
             hc = [{"service_id": co.id, "component_id": sc1.id, "host_id": h1.id}]
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'FOREIGN_HOST')
@@ -62,7 +62,7 @@ def test_action_hc_simple(self):  # pylint: disable=too-many-locals
         try:
             action = Action(name="run", hostcomponentmap="qwe")
             hc = [{"service_id": 500, "component_id": sc1.id, "host_id": h1.id}]
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'CLUSTER_SERVICE_NOT_FOUND')
@@ -70,7 +70,7 @@ def test_action_hc_simple(self):  # pylint: disable=too-many-locals
         try:
             action = Action(name="run", hostcomponentmap="qwe")
             hc = [{"service_id": co.id, "component_id": 500, "host_id": h1.id}]
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'COMPONENT_NOT_FOUND')
@@ -94,7 +94,7 @@ def test_action_hc(self):  # pylint: disable=too-many-locals
             act_hc = [{'service': 'hadoop', 'component': 'server', 'action': 'delete'}]
             action = Action(name="run", hostcomponentmap=act_hc)
             hc = [{"service_id": co.id, "component_id": sc1.id, "host_id": h1.id}]
-            hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+            hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
             self.assertNotEqual(hc_list, None)
         except AdcmEx as e:
             self.assertEqual(e.code, 'WRONG_ACTION_HC')
@@ -106,7 +106,7 @@ def test_action_hc(self):  # pylint: disable=too-many-locals
             {"service_id": co.id, "component_id": sc1.id, "host_id": h1.id},
             {"service_id": co.id, "component_id": sc1.id, "host_id": h2.id},
         ]
-        hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+        hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
         self.assertNotEqual(hc_list, None)
 
         cm.api.save_hc(cluster, hc_list)
@@ -115,5 +115,5 @@ def test_action_hc(self):  # pylint: disable=too-many-locals
         hc = [
             {"service_id": co.id, "component_id": sc1.id, "host_id": h2.id},
         ]
-        hc_list = cm.job.check_hostcomponentmap(cluster, action, hc)
+        hc_list, _ = cm.job.check_hostcomponentmap(cluster, action, hc)
         self.assertNotEqual(hc_list, None)
diff --git a/python/cm/tests_job.py b/python/cm/tests_job.py
index a5c4194570..920c41a91f 100644
--- a/python/cm/tests_job.py
+++ b/python/cm/tests_job.py
@@ -438,13 +438,13 @@ def test_prepare_job_config(
 
     @patch('cm.job.cook_delta')
     @patch('cm.job.get_old_hc')
-    @patch('cm.job.get_new_hc')
+    @patch('cm.job.get_actual_hc')
     @patch('cm.job.prepare_job')
     def test_re_prepare_job(
-        self, mock_prepare_job, mock_get_new_hc, mock_get_old_hc, mock_cook_delta
+        self, mock_prepare_job, mock_get_actual_hc, mock_get_old_hc, mock_cook_delta
     ):
         new_hc = Mock()
-        mock_get_new_hc.return_value = new_hc
+        mock_get_actual_hc.return_value = new_hc
         old_hc = Mock()
         mock_get_old_hc.return_value = old_hc
         delta = Mock()
@@ -490,7 +490,7 @@ def test_re_prepare_job(
 
         job_module.re_prepare_job(task, job)
 
-        mock_get_new_hc.assert_called_once_with(cluster)
+        mock_get_actual_hc.assert_called_once_with(cluster)
         mock_get_old_hc.assert_called_once_with(task.hostcomponentmap)
         mock_cook_delta.assert_called_once_with(cluster, new_hc, action.hostcomponentmap, old_hc)
         mock_prepare_job.assert_called_once_with(
diff --git a/python/cm/tests_upgrade.py b/python/cm/tests_upgrade.py
index ec2b77c24a..cc02766e13 100644
--- a/python/cm/tests_upgrade.py
+++ b/python/cm/tests_upgrade.py
@@ -355,7 +355,7 @@ def test_cluster_upgrade(self):
         co1 = ClusterObject.objects.get(cluster=cluster, prototype__name='hadoop')
 
         try:
-            r = cm.upgrade.do_upgrade(co1, upgrade, {})
+            r = cm.upgrade.do_upgrade(co1, upgrade, {}, {}, [])
             self.assertEqual(r, 'ok')
         except AdcmEx as e:
             self.assertEqual(e.code, 'UPGRADE_ERROR')
@@ -365,7 +365,7 @@ def test_cluster_upgrade(self):
         new_proto = Prototype.objects.get(type="service", name="hadoop", bundle=b2)
         self.assertEqual(co1.prototype.id, old_proto.id)
 
-        cm.upgrade.do_upgrade(cluster, upgrade, {})
+        cm.upgrade.do_upgrade(cluster, upgrade, {}, {}, [])
         co2 = ClusterObject.objects.get(cluster=cluster, prototype__name='hadoop')
         self.assertEqual(co1.id, co2.id)
         self.assertEqual(co2.prototype.id, new_proto.id)
@@ -400,7 +400,7 @@ def test_hc(self):  # pylint: disable=too-many-locals
         new_comp_node.delete()
 
         upgrade = setup.cook_upgrade(b2)
-        cm.upgrade.do_upgrade(cluster, upgrade, {})
+        cm.upgrade.do_upgrade(cluster, upgrade, {}, {}, [])
         hc2 = HostComponent.objects.get(cluster=cluster, service=co, component=sc1)
         self.assertEqual(hc2.component.id, sc1.id)
         r = HostComponent.objects.filter(cluster=cluster, service=co, component=sc2)
@@ -448,7 +448,7 @@ def test_provider_upgrade(self):
         h1 = Host.objects.get(provider=provider, fqdn='server01.inter.net')
 
         try:
-            r = cm.upgrade.do_upgrade(h1, upgrade, {})
+            r = cm.upgrade.do_upgrade(h1, upgrade, {}, {}, [])
             self.assertEqual(r, 'ok')
         except AdcmEx as e:
             self.assertEqual(e.code, 'UPGRADE_ERROR')
@@ -458,7 +458,7 @@ def test_provider_upgrade(self):
         new_proto = Prototype.objects.get(type="host", name="DfHost", bundle=b2)
         self.assertEqual(h1.prototype.id, old_proto.id)
 
-        cm.upgrade.do_upgrade(provider, upgrade, {})
+        cm.upgrade.do_upgrade(provider, upgrade, {}, {}, [])
         h2 = Host.objects.get(provider=provider, fqdn='server01.inter.net')
         self.assertEqual(h1.id, h2.id)
         self.assertEqual(h2.prototype.id, new_proto.id)
diff --git a/python/rbac/tests/tests_functional/test_role.py b/python/rbac/tests/tests_functional/test_role.py
index 7e92058610..be0b06ebf9 100644
--- a/python/rbac/tests/tests_functional/test_role.py
+++ b/python/rbac/tests/tests_functional/test_role.py
@@ -183,7 +183,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 1,
+                'action_id': 3,
                 'app_name': 'cm',
                 'model': 'Cluster',
                 'filter': {
@@ -202,7 +202,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 2,
+                'action_id': 4,
                 'app_name': 'cm',
                 'model': 'ClusterObject',
                 'filter': {
@@ -227,7 +227,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 3,
+                'action_id': 5,
                 'app_name': 'cm',
                 'model': 'ServiceComponent',
                 'filter': {
@@ -252,7 +252,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 4,
+                'action_id': 6,
                 'app_name': 'cm',
                 'model': 'ServiceComponent',
                 'filter': {
@@ -271,7 +271,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 5,
+                'action_id': 7,
                 'app_name': 'cm',
                 'model': 'ClusterObject',
                 'filter': {
@@ -296,7 +296,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 6,
+                'action_id': 8,
                 'app_name': 'cm',
                 'model': 'ServiceComponent',
                 'filter': {
@@ -321,7 +321,7 @@ def check_roles(bundle):
             'module_name': 'rbac.roles',
             'class_name': 'ActionRole',
             'init_params': {
-                'action_id': 7,
+                'action_id': 9,
                 'app_name': 'cm',
                 'model': 'ServiceComponent',
                 'filter': {
diff --git a/python/rbac/tests/tests_model/test_group.py b/python/rbac/tests/tests_model/test_group.py
new file mode 100644
index 0000000000..ffe716b333
--- /dev/null
+++ b/python/rbac/tests/tests_model/test_group.py
@@ -0,0 +1,125 @@
+# Licensed 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 pytest
+
+from django.contrib.auth.models import Group as AuthGroup
+from rbac.models import Group, OriginType
+
+
+@pytest.mark.django_db
+def test_group_creation_blank():
+    try:
+        Group.objects.create()
+    except RuntimeError as e:
+        assert 'Check regex. Data: ' in str(e)
+    else:
+        raise AssertionError('group creation with no args is not allowed')
+
+
+@pytest.mark.django_db
+def test_group_creation_deletion():
+    data = [
+        (
+            {
+                'name': 'test_group_name',
+                'description': 'test_group_description',
+                'type': OriginType.Local,
+            },
+            {
+                'name': f'test_group_name [{OriginType.Local.value}]',
+                'description': 'test_group_description',
+                'type': OriginType.Local,
+                'display_name': 'test_group_name',
+            },
+        ),
+        (
+            {
+                'name': 'test_group_name_2',
+            },
+            {
+                'name': f'test_group_name_2 [{OriginType.Local.value}]',
+                'description': None,
+                'type': OriginType.Local,
+                'display_name': 'test_group_name_2',
+            },
+        ),
+        (
+            {
+                'name': 'test_group_name3',
+                'description': 'test_group_description3',
+                'type': OriginType.LDAP,
+            },
+            {
+                'name': f'test_group_name3 [{OriginType.LDAP.value}]',
+                'description': 'test_group_description3',
+                'type': OriginType.LDAP,
+                'display_name': 'test_group_name3',
+                'built_in': False,
+            },
+        ),
+    ]
+
+    for create_args, expected in data:
+        g = Group.objects.create(**create_args)
+        g_pk = g.pk
+        basse_g_pk = g.group_ptr_id
+
+        assert int(basse_g_pk)
+
+        for attr, expected_value in expected.items():
+            actual_value = getattr(g, attr)
+            assert (
+                actual_value == expected_value
+            ), f'{g}: wrong {attr} (`{actual_value}`, expected: `{expected_value}`)'
+
+        g.delete()
+
+        try:
+            Group.objects.get(pk=g_pk)
+        except Group.DoesNotExist:
+            pass
+        else:
+            raise AssertionError(f'Group #{g_pk} is not deleted')
+
+        try:
+            AuthGroup.objects.get(pk=basse_g_pk)
+        except AuthGroup.DoesNotExist:
+            pass
+        else:
+            raise AssertionError(f'AuthGroup #{basse_g_pk} is not deleted')
+
+
+@pytest.mark.django_db
+def test_group_name_type_mutation():
+    """test for pre_save signal"""
+    g = Group.objects.create(name='test_group_name')
+    ag = AuthGroup.objects.get(pk=g.group_ptr_id)
+
+    name = 'another_test_group_name'
+    g.name = name
+    g.save()
+
+    g.refresh_from_db()
+    ag.refresh_from_db()
+    assert g.type == OriginType.Local.value
+    assert g.name == ag.name == f'{name} [{OriginType.Local.value}]'
+    assert g.display_name == name
+
+    g.type = OriginType.LDAP
+    g.save()
+
+    g.refresh_from_db()
+    ag.refresh_from_db()
+    assert g.type == OriginType.LDAP.value
+    assert g.name == ag.name == f'{name} [{OriginType.LDAP.value}]'
+    assert g.display_name == name
diff --git a/requirements-test.txt b/requirements-test.txt
index 4121073bd0..2aed3c443a 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,18 +1,20 @@
 --extra-index-url https://ci.arenadata.io/artifactory/api/pypi/python-packages/simple
-# TODO: adcm-client >= needed to develop
+# TODO: adcm-client >= and adcm-pytest-plugin ~= needed to develop
 adcm-client>=2022.6.2.8
-adcm-pytest-plugin==4.18.0
+adcm-pytest-plugin~=4.20
 attr==0.3.1
 allure-pytest==2.9.45
 docker==5.0.3
 jsonschema==4.6.1
 pylint==2.14.4
 pytest==6.2.5
+pytest-asyncio==0.18.3
 pytest-lazy-fixture==0.6.3
 pytest-xdist==2.5.0
 pytest-rerunfailures==10.2
 pytest-timeout==2.1.0
 pytest-check==1.0.5
+python-ldap==3.4.0
 PyYAML==6.0
 requests==2.28.1
 requests-toolbelt==0.9.1
diff --git a/tests/api/utils/data_classes.py b/tests/api/utils/data_classes.py
index e977163677..4b968e2a77 100644
--- a/tests/api/utils/data_classes.py
+++ b/tests/api/utils/data_classes.py
@@ -282,6 +282,8 @@ class RbacUserFields(BaseClass):
     profile = Field(name="profile", f_type=Json(), default_value="", postable=True, changeable=True)
     url = Field(name="url", f_type=String(), default_value=AUTO_VALUE)
     built_in = Field(name="built_in", f_type=Boolean(), default_value=AUTO_VALUE)
+    type = Field(name="type", f_type=String(max_length=16), default_value=AUTO_VALUE)
+    is_active = Field(name='is_active', f_type=Boolean(), default_value=True)
 
 
 class RbacGroupFields(BaseClass):
@@ -301,6 +303,7 @@ class RbacGroupFields(BaseClass):
     description = Field(name="description", f_type=Text(), postable=True, changeable=True, default_value="")
     url = Field(name="url", f_type=String(), default_value=AUTO_VALUE)
     built_in = Field(name="built_in", f_type=Boolean(), default_value=AUTO_VALUE)
+    type = Field(name="type", f_type=String(max_length=16), default_value=AUTO_VALUE)
 
 
 RbacUserFields.group = Field(
diff --git a/tests/conftest.py b/tests/conftest.py
index 28a71b93d9..fc6d18c567 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,15 +18,16 @@
 import sys
 import tarfile
 from pathlib import PosixPath
-from typing import Optional, List, Tuple, Union
+from typing import Optional, List, Tuple, Union, Generator
 
 import allure
+import ldap
 import pytest
 import websockets.client
 import yaml
 
 from _pytest.python import Function, FunctionDefinition, Module
-from adcm_client.objects import ADCMClient, User, Provider
+from adcm_client.objects import ADCMClient, User, Provider, Bundle
 from adcm_pytest_plugin.utils import random_string
 from allure_commons.model2 import TestResult, Parameter
 from allure_pytest.listener import AllureListener
@@ -34,6 +35,8 @@
 
 from tests.library.adcm_websockets import ADCMWebsocket
 from tests.library.db import QueryExecutioner
+from tests.library.ldap_interactions import LDAPEntityManager, LDAPTestConfig, configure_adcm_for_ldap
+from tests.library.utils import ConfigError
 
 pytest_plugins = "adcm_pytest_plugin"
 
@@ -136,6 +139,7 @@ def pytest_addoption(parser):
                 admin_dn: admin user DN
                 admin_pass: admin password
                 base_ou_dn: DN in which to create all test-related entities
+                cert: plaintest of a certificate for admin user to access LDAP server securely
             """
         ),
         type=pathlib.Path,
@@ -173,6 +177,14 @@ def _get_listener_by_item_if_present(item: Function) -> Optional[AllureListener]
 GENERIC_BUNDLES_DIR = pathlib.Path(__file__).parent / 'generic_bundles'
 
 
+@pytest.fixture()
+def generic_bundle(request, sdk_client_fs) -> Bundle:
+    """Upload bundle from generic bundles dir"""
+    if not hasattr(request, "param") or not isinstance(request.param, str):
+        raise ValueError('You should parametrize "generic_bundle" fixture with bundle dir name as string')
+    return sdk_client_fs.upload_from_fs(GENERIC_BUNDLES_DIR / request.param)
+
+
 @pytest.fixture()
 def generic_provider(sdk_client_fs) -> Provider:
     """Create generic simple provider to use as "dummy" provider in tests"""
@@ -294,3 +306,158 @@ async def adcm_ws(sdk_client_fs, adcm_fs) -> ADCMWebsocket:
 def adcm_db(adcm_fs) -> QueryExecutioner:
     """Initialized QueryExecutioner for a function scoped ADCM"""
     return QueryExecutioner(adcm_fs.container)
+
+
+# ADCM + LDAP
+
+LDAP_PREFIX = 'ldap://'
+LDAPS_PREFIX = 'ldaps://'
+
+
+@allure.title('[SS] Get LDAP config')
+@pytest.fixture(scope='session')
+def ldap_config(cmd_opts) -> dict:
+    """
+    Load LDAP config from file as a dictionary.
+    Returns empty dict if `--ldap-conf` is not presented.
+    """
+    if not cmd_opts.ldap_conf:
+        return {}
+    config_fp = pathlib.Path(cmd_opts.ldap_conf)
+    if not config_fp.exists():
+        raise ConfigError(f'Path to LDAP config file should exist: {config_fp}')
+    with config_fp.open('r', encoding='utf-8') as file:
+        config = yaml.safe_load(file)
+    if not isinstance(config, dict):
+        raise ConfigError('LDAP config file should have root type "dict"')
+    ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)  # pylint: disable=no-member
+    return config
+
+
+@allure.title('[SS] Extract AD config')
+@pytest.fixture(scope='session')
+def ad_config(ldap_config: dict) -> LDAPTestConfig:
+    """Create AD config from config file"""
+    required_keys = {'uri', 'admin_dn', 'admin_pass', 'base_ou_dn'}
+    if 'ad' not in ldap_config:
+        raise ConfigError('To test LDAP with AD LDAP config file should have "ad" key')
+    config = ldap_config['ad']
+    if not required_keys.issubset(config.keys()):
+        raise ConfigError(
+            'Not all required keys are presented in "ad" LDAP config.\n'
+            f'Required: {required_keys}\n'
+            f'Actual: {config.keys()}'
+        )
+    return LDAPTestConfig(
+        config['uri'],
+        config['admin_dn'],
+        config['admin_pass'],
+        config['base_ou_dn'],
+        config.get('cert', None),
+    )
+
+
+@allure.title('Prepare LDAP entities manager')
+@pytest.fixture()
+def ldap_ad(request, ad_config) -> Generator[LDAPEntityManager, None, None]:
+    """Create LDAP entities manager from AD config"""
+    with LDAPEntityManager(ad_config, request.node.nodeid) as ldap_manager:
+        yield ldap_manager
+
+
+@allure.title('Create basic OUs for testing')
+@pytest.fixture()
+def ldap_basic_ous(ldap_ad):
+    """Get LDAP group (ou) DNs for groups and users"""
+    groups_ou_dn = ldap_ad.create_ou('groups-main')
+    users_ou_dn = ldap_ad.create_ou('users-main')
+    return groups_ou_dn, users_ou_dn
+
+
+@allure.title('Create LDAP user without group')
+@pytest.fixture()
+def ldap_user(ldap_ad, ldap_basic_ous) -> dict:
+    """Create LDAP AD user"""
+    _, users_dn = ldap_basic_ous
+    user = {'name': f'user_wo_group_{random_string(6)}', 'password': random_string(12)}
+    user['dn'] = ldap_ad.create_user(**user, custom_base_dn=users_dn)
+    user_fields_to_modify = _create_extra_user_modlist(user)
+    ldap_ad.update_user(user['dn'], **user_fields_to_modify)
+    user.update(user_fields_to_modify)
+    return user
+
+
+@allure.title('Create LDAP group')
+@pytest.fixture()
+def ldap_group(ldap_ad, ldap_basic_ous) -> dict:
+    """Create LDAP AD group for adding users"""
+    group = {'name': 'adcm_users'}
+    groups_dn, _ = ldap_basic_ous
+    group['dn'] = ldap_ad.create_group(**group, custom_base_dn=groups_dn)
+    return group
+
+
+@allure.title('Create LDAP user in group')
+@pytest.fixture()
+def ldap_user_in_group(ldap_ad, ldap_basic_ous, ldap_group) -> dict:
+    """Create LDAP AD user and add it to a default "allowed to log to ADCM" group"""
+    user = {'name': f'user_in_group_{random_string(6)}', 'password': random_string(12)}
+    _, users_dn = ldap_basic_ous
+    user['dn'] = ldap_ad.create_user(**user, custom_base_dn=users_dn)
+    user_fields_to_modify = _create_extra_user_modlist(user)
+    ldap_ad.update_user(user['dn'], **user_fields_to_modify)
+    user.update(user_fields_to_modify)
+    ldap_ad.add_user_to_group(user['dn'], ldap_group['dn'])
+
+    return user
+
+
+@allure.title('Create one more LDAP group')
+@pytest.fixture()
+def another_ldap_group(ldap_ad, ldap_basic_ous) -> dict:
+    """Create LDAP AD group for adding users"""
+    group = {'name': 'another_adcm_users'}
+    groups_dn, _ = ldap_basic_ous
+    group['dn'] = ldap_ad.create_group(**group, custom_base_dn=groups_dn)
+    return group
+
+
+@allure.title('Create LDAP user in non-default group')
+@pytest.fixture()
+def another_ldap_user_in_group(ldap_ad, ldap_basic_ous, another_ldap_group) -> dict:
+    """Create LDAP AD user and add it to "another" ADCM in AD group"""
+    _, users_dn = ldap_basic_ous
+    user = {'name': f'a_user_in_group_{random_string(4)}', 'password': random_string(12)}
+    user_fields_to_modify = _create_extra_user_modlist(user)
+    user['dn'] = ldap_ad.create_user(**user, custom_base_dn=users_dn)
+    ldap_ad.update_user(user['dn'], **user_fields_to_modify)
+    user.update(user_fields_to_modify)
+    ldap_ad.add_user_to_group(user['dn'], another_ldap_group['dn'])
+
+    return user
+
+
+@pytest.fixture()
+def ad_ssl_cert(adcm_fs, ad_config) -> Optional[pathlib.Path]:
+    """Put SSL certificate from config to ADCM container and return path to it"""
+    if ad_config.cert is None:
+        return None
+    path = pathlib.Path('/adcm/.ad-cert')
+    result = adcm_fs.container.exec_run(['sh', '-c', f'echo "{ad_config.cert}" > {path}'])
+    if result.exit_code != 0:
+        raise ValueError('Failed to upload AD certificate to ADCM')
+    return path
+
+
+@allure.title('Configure ADCM for LDAP (AD) integration')
+@pytest.fixture(params=[False], ids=['ssl_off'])
+def configure_adcm_ldap_ad(request, sdk_client_fs: ADCMClient, ldap_basic_ous, ad_config, ad_ssl_cert):
+    """Configure ADCM to allow AD users"""
+    ssl_on = request.param
+    groups_ou, users_ou = ldap_basic_ous
+
+    configure_adcm_for_ldap(sdk_client_fs, ad_config, ssl_on, ad_ssl_cert, users_ou, groups_ou)
+
+
+def _create_extra_user_modlist(user: dict) -> dict:
+    return {'first_name': user['name'], 'last_name': 'Testovich', 'email': f'{user["name"]}@nexistent.ru'}
diff --git a/tests/functional/ldap_auth/__init__.py b/tests/functional/ldap_auth/__init__.py
new file mode 100644
index 0000000000..cbb5bf83b8
--- /dev/null
+++ b/tests/functional/ldap_auth/__init__.py
@@ -0,0 +1,21 @@
+# Licensed 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.
+"""
+Suite with maintenance mode tests
+"""
+import pytest
+
+pytestmark = [
+    pytest.mark.allure_label("API Tests", label_type="layer"),
+    pytest.mark.allure_label("Functional Tests", label_type="parentSuite"),
+    pytest.mark.allure_label("Functional Tests", label_type="page_url"),
+]
diff --git a/tests/functional/ldap_auth/bundles/cluster/config.yaml b/tests/functional/ldap_auth/bundles/cluster/config.yaml
new file mode 100644
index 0000000000..de2100d3dd
--- /dev/null
+++ b/tests/functional/ldap_auth/bundles/cluster/config.yaml
@@ -0,0 +1,20 @@
+# Licensed 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.
+
+- type: cluster
+  version: 2.2
+  name: test_cluster
+
+  config:
+    - name: param
+      type: string
+      default: something
diff --git a/tests/functional/ldap_auth/conftest.py b/tests/functional/ldap_auth/conftest.py
new file mode 100644
index 0000000000..0dbc6d1fa5
--- /dev/null
+++ b/tests/functional/ldap_auth/conftest.py
@@ -0,0 +1,17 @@
+# Licensed 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.
+
+"""conftest for ldap_auth tests"""
+
+import pathlib
+
+BASE_BUNDLES_DIR = pathlib.Path(__file__).parent / 'bundles'
diff --git a/tests/functional/ldap_auth/test_access.py b/tests/functional/ldap_auth/test_access.py
new file mode 100644
index 0000000000..ff02072b2c
--- /dev/null
+++ b/tests/functional/ldap_auth/test_access.py
@@ -0,0 +1,104 @@
+# Licensed 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.
+
+"""
+Test granting RBAC access to "users from LDAP"
+"""
+
+from typing import Tuple
+
+import allure
+import pytest
+from adcm_client.objects import ADCMClient, Cluster, User
+from adcm_pytest_plugin.utils import random_string
+
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.ldap_auth.conftest import BASE_BUNDLES_DIR
+from tests.functional.ldap_auth.utils import get_ldap_user_from_adcm, get_ldap_group_from_adcm
+from tests.functional.rbac.conftest import is_denied, BusinessRoles, create_policy, is_allowed, delete_policy
+
+pytestmark = [only_clean_adcm, pytest.mark.usefixtures('configure_adcm_ldap_ad')]
+
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture()
+def cluster(sdk_client_fs) -> Cluster:
+    """Create cluster"""
+    bundle = sdk_client_fs.upload_from_fs(BASE_BUNDLES_DIR / 'cluster')
+    return bundle.cluster_create('Test Cluster')
+
+
+def test_grant_policy_for_ldap_user(sdk_client_fs, cluster, ldap_user_in_group):
+    """
+    Test that granting policy for LDAP user in ADCM works the same way as with regular user.
+    """
+    username, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+
+    user_client, user = _login_as_ldap_user_and_check_no_access(sdk_client_fs, username, password, cluster)
+
+    with allure.step('Grant access for a cluster and check it is granted'):
+        policy = create_policy(sdk_client_fs, BusinessRoles.EditClusterConfigurations, [cluster], [user], [])
+        user_client.reread()
+        user_cluster = is_allowed(user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_allowed(user_cluster, BusinessRoles.EditClusterConfigurations)
+
+    with allure.step('Withdraw policy from LDAP user'):
+        delete_policy(policy)
+        is_denied(user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_denied(user_cluster, BusinessRoles.EditClusterConfigurations, client=user_client)
+
+
+# pylint: disable-next=unused-variable,too-many-arguments,too-many-locals
+def test_grant_policy_for_ldap_group(sdk_client_fs, cluster, ldap_ad, ldap_basic_ous, ldap_group, ldap_user_in_group):
+    """
+    Test that granting policy for LDAP group in ADCM works the same way as with regular group.
+    """
+    _, users_ou = ldap_basic_ous
+    username, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    second_username, second_password = f'Newcomer_{random_string(6)}', random_string(12)
+
+    user_client, _ = _login_as_ldap_user_and_check_no_access(sdk_client_fs, username, password, cluster)
+
+    with allure.step('Grant access to group from LDAP and check permissions of existing users'):
+        group = get_ldap_group_from_adcm(sdk_client_fs, ldap_group['name'])
+        policy = create_policy(sdk_client_fs, BusinessRoles.EditClusterConfigurations, [cluster], [], [group])
+        user_client.reread()
+        user_cluster = is_allowed(user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_allowed(user_cluster, BusinessRoles.EditClusterConfigurations)
+
+    with allure.step('Create new LDAP user in group and check they will have permissions'):
+        new_user_dn = ldap_ad.create_user(second_username, second_password, custom_base_dn=users_ou)
+        ldap_ad.add_user_to_group(new_user_dn, ldap_group['dn'])
+        second_user_client = ADCMClient(url=sdk_client_fs.url, user=second_username, password=second_password)
+        second_user_cluster = is_allowed(second_user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_allowed(second_user_cluster, BusinessRoles.EditClusterConfigurations)
+
+    with allure.step('Withdraw policy from LDAP group and check users have no permissions'):
+        delete_policy(policy)
+        is_denied(user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_denied(user_cluster, BusinessRoles.EditClusterConfigurations, client=user_client)
+        is_denied(second_user_client, BusinessRoles.GetCluster, id=cluster.id)
+        is_denied(second_user_cluster, BusinessRoles.EditClusterConfigurations, client=second_user_client)
+
+
+def _login_as_ldap_user_and_check_no_access(client, username, password, cluster) -> Tuple[ADCMClient, User]:
+    """:returns: New ADCM Client connection (logged as new user) and user (from admin perspective)"""
+    with allure.step('Login as LDAP user and check user is created'):
+        user_sdk = ADCMClient(url=client.url, user=username, password=password)
+        user = get_ldap_user_from_adcm(client, username)
+
+    with allure.step('Check no access'):
+        is_denied(user_sdk, BusinessRoles.GetCluster, id=cluster.id)
+
+    return user_sdk, user
diff --git a/tests/functional/ldap_auth/test_basic.py b/tests/functional/ldap_auth/test_basic.py
new file mode 100644
index 0000000000..744747b3a7
--- /dev/null
+++ b/tests/functional/ldap_auth/test_basic.py
@@ -0,0 +1,207 @@
+# Licensed 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.
+
+"""Test basic login scenarios"""
+
+from typing import Union, Callable
+
+import allure
+import pytest
+from adcm_client.objects import ADCMClient, User
+from adcm_pytest_plugin.params import including_https
+from adcm_pytest_plugin.steps.actions import wait_for_task_and_assert_result
+from adcm_pytest_plugin.utils import random_string
+
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.ldap_auth.utils import (
+    check_existing_users,
+    DEFAULT_LOCAL_USERS,
+    check_existing_groups,
+    login_should_succeed,
+    login_should_fail,
+)
+
+pytestmark = [only_clean_adcm, pytest.mark.usefixtures('configure_adcm_ldap_ad')]
+
+
+@pytest.mark.parametrize('configure_adcm_ldap_ad', [False, True], ids=['ssl_off', 'ssl_on'], indirect=True)
+def test_basic_ldap_auth(sdk_client_fs, ldap_user, ldap_user_in_group):
+    """
+    Test basic scenarios of LDAP auth:
+    1. Login of user in "correct" group is permitted
+    2. Login of user not in group is not permitted
+    """
+    login_should_succeed(
+        'login with LDAP user in group', sdk_client_fs, ldap_user_in_group['name'], ldap_user_in_group['password']
+    )
+    login_should_fail(
+        'login with LDAP user not in allowed group',
+        sdk_client_fs,
+        ldap_user['name'],
+        ldap_user['password'],
+    )
+
+
+@including_https
+def test_remove_from_group_leads_to_access_loss(sdk_client_fs, ldap_ad, ldap_user_in_group, ldap_group):
+    """
+    Test that removing LDAP user from "allowed" group leads to lost access to ADCM.
+    """
+    username, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    login_should_succeed('login with LDAP user in group', sdk_client_fs, username, password)
+    ldap_ad.remove_user_from_group(ldap_user_in_group['dn'], ldap_group['dn'])
+    login_should_fail('login with removed from group LDAP user', sdk_client_fs, username, password)
+
+
+@including_https
+def test_deactivation_leads_to_access_loss(sdk_client_fs, ldap_ad, ldap_user_in_group):
+    """
+    Test that LDAP user deactivation leads to lost access to ADCM.
+    """
+    username, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    login_should_succeed('login with LDAP user in group', sdk_client_fs, username, password)
+    ldap_ad.deactivate_user(ldap_user_in_group['dn'])
+    login_should_fail('login with removed from group LDAP user', sdk_client_fs, username, password)
+
+
+def test_ldap_user_access_restriction(sdk_client_fs, ldap_ad, ldap_group, ldap_basic_ous):
+    """
+    Test that access is denied for LDAP user:
+    - inactive not in group
+    - inactive in group
+    - in group, but with "old" password
+    - active and not in group
+    """
+    username, password = f'testUser-{random_string(6)}', 'awesomepass'
+    new_password = 'anotherpassword'
+    _, users_ou = ldap_basic_ous
+
+    with allure.step('Create user and deactivate it'):
+        user_dn = ldap_ad.create_user(username, password, users_ou)
+        ldap_ad.deactivate_user(user_dn)
+    login_should_fail('login as deactivated user not in group', sdk_client_fs, username, password)
+    ldap_ad.add_user_to_group(user_dn, ldap_group['dn'])
+    login_should_fail('login as deactivated user in group', sdk_client_fs, username, password)
+    ldap_ad.activate_user(user_dn)
+    login_should_succeed('login as activated user in group', sdk_client_fs, username, password)
+    ldap_ad.set_user_password(user_dn, new_password)
+    login_should_fail('login as activated user with wrong password', sdk_client_fs, username, password, None)
+    login_should_succeed('login as activated user with new password', sdk_client_fs, username, new_password)
+    ldap_ad.remove_user_from_group(user_dn, ldap_group['dn'])
+    login_should_fail('login as user removed from group', sdk_client_fs, username, new_password)
+
+
+@including_https
+@pytest.mark.usefixtures('configure_adcm_ldap_ad')
+@pytest.mark.parametrize('configure_adcm_ldap_ad', [True], ids=['ssl-on'], indirect=True)
+def test_ssl_ldap_fails_with_wrong_path(sdk_client_fs, ldap_user_in_group):
+    """
+    Test that incorrect certificate path leads ldaps connection to fail to give the access to a user.
+    """
+    user, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    adcm = sdk_client_fs.adcm()
+
+    login_should_succeed('login with LDAP user and OK config', sdk_client_fs, user, password)
+    with allure.step('Set incorrect path to a file and check login fails'):
+        adcm.config_set_diff({'ldap_integration': {'tls_ca_cert_file': '/does/not/exist'}})
+        login_should_fail('login with LDAP user and wrong file in config', sdk_client_fs, user, password)
+
+
+@including_https
+@pytest.mark.usefixtures('configure_adcm_ldap_ad')
+@pytest.mark.parametrize('configure_adcm_ldap_ad', [True], ids=['ssl-on'], indirect=True)
+def test_ssl_ldap_fails_with_wrong_cert_content(adcm_fs, sdk_client_fs, ldap_user_in_group):
+    """
+    Test that incorrect certificate file content leads ldaps connection to fail to give the access to a user.
+    """
+    user, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    adcm = sdk_client_fs.adcm()
+
+    login_should_succeed('login with LDAP user and OK config', sdk_client_fs, user, password)
+    with allure.step('Set incorrect data to a cert file and check login fails'):
+        path = adcm.config()['ldap_integration']['tls_ca_cert_file']
+        result = adcm_fs.container.exec_run(['sh', '-c', f'echo "notacert" > {path}'])
+        if result.exit_code != 0:
+            raise ValueError('Failed to change certificate content')
+        login_should_fail('login with LDAP user and wrong cert content', sdk_client_fs, user, password)
+
+
+def _alter_user_search_base(client: ADCMClient) -> dict:
+    old_base: str = client.adcm().config()['ldap_integration']['user_search_base']
+    new_base = f'CN=notexist-{old_base[3:]}'
+    return {'ldap_integration': {'user_search_base': new_base}}
+
+
+_wrong_user_password = (
+    ('wrong user', {'ldap_integration': {'ldap_user': '!#)F(JDKSLJ'}}),
+    ('wrong password', {'ldap_integration': {'ldap_password': random_string(6)}}),
+)
+
+_deactivate_ldap_integration = ('LDAP config turned off', {'attr': {'ldap_integration': {'active': False}}})
+
+
+@pytest.mark.parametrize(
+    ('change_name', 'config'),
+    [_deactivate_ldap_integration, *_wrong_user_password],
+    ids=lambda x: x.replace(' ', '_') if isinstance(x, str) else x,
+)
+def test_ldap_config_change(change_name: str, config: Union[dict, Callable], sdk_client_fs, ldap_user_in_group):
+    """Test that changing ldap config to "incorrect" leads to users access loss"""
+    login_operation = 'login as LDAP user'
+    user, password = ldap_user_in_group['name'], ldap_user_in_group['password']
+    adcm = sdk_client_fs.adcm()
+
+    login_should_succeed(login_operation, sdk_client_fs, user, password)
+    with allure.step(f'Check login is disabled after: {change_name}'):
+        config = config if not callable(config) else config(sdk_client_fs)
+        adcm.config_set_diff(config)
+        login_should_fail(login_operation, sdk_client_fs, user, password)
+
+
+@pytest.mark.parametrize('action_name', ['test_ldap_connection', 'run_ldap_sync'])
+def test_wrong_ldap_config_fail_actions(action_name: str, sdk_client_fs):
+    """Test that incorrect/turned off LDAP configuration leads ldap-related actions to have disabling cause"""
+    adcm = sdk_client_fs.adcm()
+    original_config = adcm.config()
+    for change_name, config in _wrong_user_password:
+        with allure.step(f'Change LDAP config: {change_name}; and expect {action_name} to fail'):
+            adcm.config_set_diff(config)
+            task = adcm.action(name=action_name).run()
+            wait_for_task_and_assert_result(task, 'failed')
+            adcm.config_set_diff(original_config)
+    with allure.step(f'Deactivate LDAP integration in settings and check action {action_name} is disabled'):
+        adcm.config_set_diff(_deactivate_ldap_integration[1])
+        assert action_name in [
+            a.name for a in adcm.action_list() if a.disabling_cause == 'no_ldap_settings'
+        ], f'Action {action_name} have "no_ldap_settings" disabling cause'
+
+
+def test_login_as_existing_user_is_forbidden(sdk_client_fs, ldap_user_in_group):
+    """Test that login as existing user doesn't create user"""
+    local_password = random_string(16)
+    expected_local_users = (*DEFAULT_LOCAL_USERS, ldap_user_in_group['name'])
+    with allure.step('Create user with the name of LDAP user'):
+        local_user: User = sdk_client_fs.user_create(ldap_user_in_group['name'], local_password)
+    check_existing_users(sdk_client_fs, expected_local=expected_local_users)
+    check_existing_groups(sdk_client_fs)
+    login_should_succeed('local user login', sdk_client_fs, local_user.username, local_password)
+    login_should_fail(
+        'login as LDAP user with same username as local',
+        sdk_client_fs,
+        ldap_user_in_group['name'],
+        ldap_user_in_group['password'],
+    )
+    local_user.reread()
+    with allure.step('Check user is of local type'):
+        assert local_user.type == 'local', 'User type should be "local"'
+    check_existing_users(sdk_client_fs, expected_local=expected_local_users)
+    check_existing_groups(sdk_client_fs)
diff --git a/tests/functional/ldap_auth/test_complex.py b/tests/functional/ldap_auth/test_complex.py
new file mode 100644
index 0000000000..8456546b20
--- /dev/null
+++ b/tests/functional/ldap_auth/test_complex.py
@@ -0,0 +1,188 @@
+# Licensed 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.
+
+"""Test complex scenarios with ADCM LDAP integration"""
+
+from typing import Tuple, Set
+
+import allure
+import pytest
+from adcm_client.objects import User, Group, ADCMClient
+from adcm_pytest_plugin.steps.actions import wait_for_task_and_assert_result
+from adcm_pytest_plugin.utils import random_string
+
+from tests.functional.ldap_auth.utils import (
+    get_ldap_user_from_adcm,
+    get_ldap_group_from_adcm,
+    check_existing_groups,
+    check_existing_users,
+    login_should_succeed,
+    login_should_fail,
+    DEFAULT_LOCAL_USERS,
+    SYNC_ACTION_NAME,
+)
+
+# pylint: disable=redefined-outer-name
+
+UserInfo = dict
+GroupInfo = dict
+
+
+@pytest.fixture()
+def two_ldap_groups_with_users(ldap_ad, ldap_basic_ous) -> Tuple[GroupInfo, UserInfo, GroupInfo, UserInfo]:
+    """Create two ldap users and groups with a user in each one"""
+    groups_ou, users_ou = ldap_basic_ous
+    group_1 = {'name': 'group-with-users-1'}
+    group_2 = {'name': 'group-with-users-2'}
+    user_1 = {'name': f'user-1-{random_string(4)}-in-group', 'password': random_string(12)}
+    user_2 = {'name': f'user-2-{random_string(4)}-in-group', 'password': random_string(12)}
+    user_1['dn'] = ldap_ad.create_user(**user_1, custom_base_dn=users_ou)
+    user_2['dn'] = ldap_ad.create_user(**user_2, custom_base_dn=users_ou)
+    group_1['dn'] = ldap_ad.create_group(**group_1, custom_base_dn=groups_ou)
+    ldap_ad.add_user_to_group(user_1['dn'], group_1['dn'])
+    group_2['dn'] = ldap_ad.create_group(**group_2, custom_base_dn=groups_ou)
+    ldap_ad.add_user_to_group(user_2['dn'], group_2['dn'])
+    return group_1, user_1, group_2, user_2
+
+
+@pytest.fixture()
+def two_ldap_users(ldap_ad, ldap_basic_ous) -> Tuple[UserInfo, UserInfo]:
+    """Create two ldap user that're not in any group"""
+    _, users_ou = ldap_basic_ous
+    user_1 = {'name': f'user-3-{random_string(4)}', 'password': random_string(12)}
+    user_2 = {'name': f'user-4-{random_string(4)}', 'password': random_string(12)}
+    user_1['dn'] = ldap_ad.create_user(**user_1, custom_base_dn=users_ou)
+    user_2['dn'] = ldap_ad.create_user(**user_2, custom_base_dn=users_ou)
+    return user_1, user_2
+
+
+@pytest.fixture()
+def two_adcm_users(sdk_client_fs) -> Tuple[User, User]:
+    """Create two ADCM users"""
+    return (
+        sdk_client_fs.user_create('first-adcm-user', random_string(12)),
+        sdk_client_fs.user_create('second-adcm-user', random_string(12)),
+    )
+
+
+@pytest.fixture()
+def two_adcm_groups(sdk_client_fs) -> Tuple[Group, Group]:
+    """Create two ADCM groups"""
+    return sdk_client_fs.group_create('first-adcm-group'), sdk_client_fs.group_create('second-adcm-group')
+
+
+@pytest.mark.usefixtures(
+    'configure_adcm_ldap_ad', 'two_ldap_users'
+)  # pylint: disable-next=too-many-arguments, too-many-locals, too-many-statements
+def test_users_in_groups_sync(
+    sdk_client_fs, ldap_ad, two_adcm_users, two_adcm_groups, two_ldap_users, two_ldap_groups_with_users
+):
+    """
+    Test ADCM/LDAP users in groups manipulation and sync.
+    """
+    adcm_user_1, adcm_user_2 = two_adcm_users
+    adcm_user_names = {adcm_user_1.username, adcm_user_2.username, *DEFAULT_LOCAL_USERS}
+    adcm_group_1, adcm_group_2 = two_adcm_groups
+    group_info_1, user_info_1, group_info_2, user_info_2 = two_ldap_groups_with_users
+    user_info_3, _ = two_ldap_users
+
+    with allure.step('Add ADCM users to ADCM groups'):
+        adcm_group_1.add_user(adcm_user_1)
+        adcm_group_2.add_user(adcm_user_2)
+    with allure.step('Sync and add LDAP users to ADCM groups'):
+        _run_sync(sdk_client_fs)
+        check_existing_groups(
+            sdk_client_fs, {group_info_1['name'], group_info_2['name']}, {adcm_group_1.name, adcm_group_2.name}
+        )
+        check_existing_users(sdk_client_fs, {user_info_1['name'], user_info_2['name']}, adcm_user_names)
+        ldap_group_1 = get_ldap_group_from_adcm(sdk_client_fs, group_info_1['name'])
+        ldap_group_2 = get_ldap_group_from_adcm(sdk_client_fs, group_info_2['name'])
+        ldap_user_1 = get_ldap_user_from_adcm(sdk_client_fs, user_info_1['name'])
+        ldap_user_2 = get_ldap_user_from_adcm(sdk_client_fs, user_info_2['name'])
+        adcm_group_1.add_user(ldap_user_1)
+        adcm_group_2.add_user(ldap_user_2)
+        _check_users_in_group(ldap_group_1, ldap_user_1)
+        _check_users_in_group(adcm_group_1, adcm_user_1, ldap_user_1)
+        _check_users_in_group(ldap_group_2, ldap_user_2)
+        _check_users_in_group(adcm_group_2, adcm_user_2, ldap_user_2)
+    with allure.step('Check both LDAP users can login'):
+        for user_info in (user_info_1, user_info_2):
+            _check_login_succeed(sdk_client_fs, user_info)
+    with allure.step('Swap LDAP users in LDAP groups'):
+        ldap_ad.remove_user_from_group(user_info_1['dn'], group_info_1['dn'])
+        ldap_ad.add_user_to_group(user_info_1['dn'], group_info_2['dn'])
+        ldap_ad.remove_user_from_group(user_info_2['dn'], group_info_2['dn'])
+        ldap_ad.add_user_to_group(user_info_2['dn'], group_info_1['dn'])
+    with allure.step('Sync and check groups'):
+        _run_sync(sdk_client_fs)
+        check_existing_groups(
+            sdk_client_fs, {group_info_1['name'], group_info_2['name']}, {adcm_group_1.name, adcm_group_2.name}
+        )
+        check_existing_users(sdk_client_fs, {user_info_1['name'], user_info_2['name']}, adcm_user_names)
+        _check_users_in_group(ldap_group_1, ldap_user_2)
+        _check_users_in_group(adcm_group_1, adcm_user_1, ldap_user_1)
+        _check_users_in_group(ldap_group_2, ldap_user_1)
+        _check_users_in_group(adcm_group_2, adcm_user_2, ldap_user_2)
+    with allure.step('Check both LDAP users can login'):
+        for user_info in (user_info_1, user_info_2):
+            _check_login_succeed(sdk_client_fs, user_info)
+    with allure.step('Remove one LDAP user from one group'):
+        ldap_ad.remove_user_from_group(user_info_2['dn'], group_info_1['dn'])
+    with allure.step('Sync and check user state'):
+        _run_sync(sdk_client_fs)
+        check_existing_groups(
+            sdk_client_fs, {group_info_1['name'], group_info_2['name']}, {adcm_group_1.name, adcm_group_2.name}
+        )
+        check_existing_users(sdk_client_fs, {user_info_1['name']}, adcm_user_names)
+        # Uncomment after ADCM-2944
+        # check_existing_users(sdk_client_fs, {user_info_1['name'], user_info_2['name']}, adcm_user_names)
+        _check_users_in_group(ldap_group_1)
+        _check_users_in_group(adcm_group_1, adcm_user_1, ldap_user_1)
+        _check_users_in_group(ldap_group_2, ldap_user_1)
+        _check_users_in_group(adcm_group_2, adcm_user_2)
+        # Uncomment after ADCM-2944
+        # _check_users_in_group(adcm_group_2, adcm_user_2, ldap_user_2)
+    with allure.step('Check login permissions'):
+        _check_login_succeed(sdk_client_fs, user_info_1)
+        login_should_fail(
+            'login as user removed from ldap groups', sdk_client_fs, user_info_2['name'], user_info_2['password']
+        )
+    with allure.step('Add "free" LDAP user to a group, sync and check results'):
+        ldap_ad.add_user_to_group(user_info_3['dn'], group_info_2['dn'])
+        _run_sync(sdk_client_fs)
+        check_existing_users(sdk_client_fs, {user_info_1['name'], user_info_3['name']}, adcm_user_names)
+        # Uncomment after ADCM-2944
+        # check_existing_users(
+        #     sdk_client_fs, {user_info_1['name'], user_info_2['name'], user_info_3['name']}, adcm_user_names
+        # )
+        ldap_user_3 = get_ldap_user_from_adcm(sdk_client_fs, user_info_3['name'])
+        _check_users_in_group(ldap_group_1)
+        _check_users_in_group(ldap_group_2, ldap_user_1, ldap_user_3)
+        _check_login_succeed(sdk_client_fs, user_info_3)
+
+
+def _check_login_succeed(client: ADCMClient, user_info: dict):
+    login_should_succeed(f'login as {user_info["name"]}', client, user_info['name'], user_info['password'])
+
+
+def _run_sync(client: ADCMClient):
+    action = client.adcm().action(name=SYNC_ACTION_NAME)
+    wait_for_task_and_assert_result(action.run(), 'success')
+
+
+def _check_users_in_group(group: Group, *users: User):
+    assert {u.username for u in users} == _get_usernames_in_group(group), f'Incorrect user list in group {group.name}'
+
+
+def _get_usernames_in_group(group: Group) -> Set[str]:
+    group.reread()
+    return {u.username for u in group.user_list()}
diff --git a/tests/functional/ldap_auth/test_modification.py b/tests/functional/ldap_auth/test_modification.py
new file mode 100644
index 0000000000..cd01e86a36
--- /dev/null
+++ b/tests/functional/ldap_auth/test_modification.py
@@ -0,0 +1,118 @@
+# Licensed 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.
+
+"""Test modification of ldap-related entities"""
+
+from typing import Union
+
+import allure
+import pytest
+from adcm_client.objects import ADCMClient, User, Group
+from adcm_pytest_plugin.steps.actions import wait_for_task_and_assert_result
+
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.ldap_auth.utils import (
+    get_ldap_user_from_adcm,
+    SYNC_ACTION_NAME,
+    get_ldap_group_from_adcm,
+    TEST_CONNECTION_ACTION,
+)
+from tests.library.assertions import expect_api_error, expect_no_api_error
+from tests.library.errorcodes import USER_UPDATE_ERROR, GROUP_UPDATE_ERROR
+
+# pylint: disable=redefined-outer-name
+
+pytestmark = [only_clean_adcm, pytest.mark.usefixtures('configure_adcm_ldap_ad')]
+
+
+@pytest.fixture()
+def local_user(sdk_client_fs) -> User:
+    """Create ADCM user"""
+    return sdk_client_fs.user_create('Uma', 'Uma' * 12)
+
+
+@pytest.fixture()
+def local_group(sdk_client_fs) -> Group:
+    """Create ADCM group"""
+    return sdk_client_fs.group_create('Book club')
+
+
+def test_ldap_user_manual_modification_is_forbidden(sdk_client_fs, ldap_user_in_group):
+    """
+    Test that users came from LDAP can't be modified by ADCM local admins
+    """
+    with allure.step('Sync with LDAP and retrieve LDAP user from ADCM'):
+        _sync_with_ldap(sdk_client_fs)
+        user = get_ldap_user_from_adcm(sdk_client_fs, ldap_user_in_group['name'])
+
+    for attr in ('first_name', 'last_name', 'email', 'username'):
+        _check_change_is_forbidden(user, attr)
+
+    with allure.step('Check that changing password for LDAP user is forbidden'):
+        new_password = f'px-{ldap_user_in_group["password"]}'
+        expect_api_error('change password of a user', user.change_password, new_password, err_=USER_UPDATE_ERROR)
+        expect_api_error(
+            'login with "new" password', ADCMClient, url=sdk_client_fs.url, user=user.username, password=new_password
+        )
+        expect_no_api_error(
+            'login with "original LDAP" password',
+            ADCMClient,
+            url=sdk_client_fs.url,
+            user=user.username,
+            password=ldap_user_in_group['password'],
+        )
+
+
+@pytest.mark.usefixtures('ldap_user_in_group')
+def test_ldap_group_manual_modification_is_forbidden(sdk_client_fs, ldap_group):
+    """
+    Test that groups came from LDAP can't be modified by ADCM local admins
+    """
+    with allure.step('Sync with LDAP and retrieve LDAP group from ADCM'):
+        _sync_with_ldap(sdk_client_fs)
+        group = get_ldap_group_from_adcm(sdk_client_fs, ldap_group['name'])
+
+    for attr in ('name', 'description'):
+        _check_change_is_forbidden(group, attr)
+
+
+# pylint: disable-next=too-many-arguments
+def test_membership(sdk_client_fs, local_user, local_group, ldap_group, ldap_user_in_group, another_ldap_user_in_group):
+    """
+    Test that LDAP user can be added to local groups, but not to LDAP ones in ADCM.
+    And that no user can be added to an LDAP group in ADCM.
+    """
+    with allure.step('Sync with LDAP and retrieve user and group'):
+        _sync_with_ldap(sdk_client_fs)
+        user_from_ldap = get_ldap_user_from_adcm(sdk_client_fs, ldap_user_in_group['name'])
+        another_user_form_ldap = get_ldap_user_from_adcm(sdk_client_fs, another_ldap_user_in_group['name'])
+        group_from_ldap = get_ldap_group_from_adcm(sdk_client_fs, ldap_group['name'])
+
+    expect_no_api_error('add LDAP user to a local group', local_group.add_user, user=user_from_ldap)
+    expect_api_error('add local user to an LDAP group', group_from_ldap.add_user, user=local_user)
+    expect_api_error('add LDAP user to an LDAP group', group_from_ldap.add_user, user=another_user_form_ldap)
+
+
+def _sync_with_ldap(client: ADCMClient):
+    wait_for_task_and_assert_result(client.adcm().action(name=TEST_CONNECTION_ACTION).run(), 'success')
+    wait_for_task_and_assert_result(client.adcm().action(name=SYNC_ACTION_NAME).run(), 'success')
+
+
+def _check_change_is_forbidden(entity: Union[User, Group], attr: str):
+    entity_class = entity.__class__.__name__
+    with allure.step(f'Check that changing {attr} of {entity_class} is forbidden for LDAP {entity_class}'):
+        err = USER_UPDATE_ERROR if isinstance(entity, User) else GROUP_UPDATE_ERROR
+        original_value = getattr(entity, attr)
+        changed_value = f'px-{original_value}'
+        expect_api_error(f'change {attr} of a {entity_class}', entity.update, **{attr: changed_value}, err_=err)
+        entity.reread()
+        assert getattr(entity, attr) == original_value, 'Value should not be changed'
diff --git a/tests/functional/ldap_auth/test_relations.py b/tests/functional/ldap_auth/test_relations.py
new file mode 100644
index 0000000000..10e241a161
--- /dev/null
+++ b/tests/functional/ldap_auth/test_relations.py
@@ -0,0 +1,110 @@
+# Licensed 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.
+
+"""Test relations between LDAP objects """
+from typing import Tuple, Collection, Union
+
+import allure
+import pytest
+from adcm_client.objects import ADCMClient
+
+from tests.api.utils.tools import random_string
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.ldap_auth.utils import get_ldap_user_from_adcm, get_ldap_group_from_adcm
+from tests.library.ldap_interactions import LDAPEntityManager
+
+pytestmark = [only_clean_adcm, pytest.mark.usefixtures('configure_adcm_ldap_ad')]
+
+
+# pylint: disable-next=too-few-public-methods
+class TestLDAPEntitiesRelationsInADCM:
+    """Test that relations from LDAP are correctly integrated to ADCM"""
+
+    ldap: LDAPEntityManager
+    groups_ou_dn: str
+    users_ou_dn: str
+
+    pytestmark = [pytest.mark.usefixtures('_bind_ldap_related_values')]
+
+    @pytest.fixture()
+    def _bind_ldap_related_values(self, ldap_ad, ldap_basic_ous):
+        self.ldap = ldap_ad
+        groups_ou, users_ou = ldap_basic_ous
+        self.groups_ou_dn = groups_ou
+        self.users_ou_dn = users_ou
+
+    def test_user_group_relations(self, sdk_client_fs):
+        """
+        Test that users and groups from LDAP are correctly related in ADCM.
+        """
+        groups_at_start = len(sdk_client_fs.group_list())
+        # pylint: disable=unbalanced-tuple-unpacking
+        user_1, user_2 = self._create_users('first-user', 'second-user')
+        group_1, group_2, _ = self._create_groups('first-group', 'second-group', 'empty-group')
+        # pylint: enable=unbalanced-tuple-unpacking
+        self._add_users_to_groups((group_1, (user_1, user_2)), (group_2, [user_1]))
+        self._login_as_users(sdk_client_fs, user_2)
+        with allure.step('Check that correct group is retrieved from LDAP and it has correct user'):
+            expected_groups_amount = groups_at_start + 1
+            assert len(sdk_client_fs.group_list()) == expected_groups_amount, 'More than one group were created'
+            self._check_user_is_in_group(sdk_client_fs, user_2, group_1)
+        self._login_as_users(sdk_client_fs, user_1)
+        with allure.step('Check that one more group is retrieved from LDAP and group users are correct'):
+            expected_groups_amount += 1
+            assert len(sdk_client_fs.group_list()) == expected_groups_amount, 'More than one group were created'
+            self._check_user_is_in_group(sdk_client_fs, user_1, group_1)
+            self._check_user_is_in_group(sdk_client_fs, user_2, group_1)
+            self._check_user_is_in_group(sdk_client_fs, user_1, group_2)
+            self._check_user_is_not_in_group(sdk_client_fs, user_2, group_2)
+
+    def _create_users(self, *user_names: str, randomize: bool = True) -> Tuple[dict, ...]:
+        created = []
+        for name in user_names if not randomize else map(lambda name: f'{name}-{random_string(4)}', user_names):
+            password = random_string(12)
+            user_dn = self.ldap.create_user(name, password, custom_base_dn=self.users_ou_dn)
+            created.append({'dn': user_dn, 'name': name, 'password': password})
+        return tuple(created)
+
+    def _create_groups(self, *group_names: str) -> Tuple[dict, ...]:
+        created = []
+        for name in group_names:
+            group_dn = self.ldap.create_group(name, custom_base_dn=self.groups_ou_dn)
+            created.append({'dn': group_dn, 'name': name})
+        return tuple(created)
+
+    def _add_users_to_groups(self, *group_users: Tuple[dict, Collection[dict]]):
+        for group, users in group_users:
+            with allure.step(f'Adding to group {group["name"]} users: {", ".join(u["name"] for u in users)}'):
+                for user in users:
+                    self.ldap.add_user_to_group(user['dn'], group['dn'])
+
+    @allure.step('Check that user {user} is in group {group}')
+    def _check_user_is_in_group(self, client: ADCMClient, user: Union[str, dict], group: Union[str, dict]):
+        """Check if user is in group in ADCM"""
+        adcm_user = get_ldap_user_from_adcm(client, user['name'] if isinstance(user, dict) else user)
+        adcm_group = get_ldap_group_from_adcm(client, group['name'] if isinstance(group, dict) else group)
+        assert adcm_user.id in [
+            u.id for u in adcm_group.user_list()
+        ], f'User {adcm_user.username} not found in group {adcm_group.name}'
+
+    @allure.step('Check that user {user} is not in group {group}')
+    def _check_user_is_not_in_group(self, client: ADCMClient, user: Union[str, dict], group: Union[str, dict]):
+        adcm_user = get_ldap_user_from_adcm(client, user['name'] if isinstance(user, dict) else user)
+        adcm_group = get_ldap_group_from_adcm(client, group['name'] if isinstance(group, dict) else group)
+        assert adcm_user.id not in [
+            u.id for u in adcm_group.user_list()
+        ], f'User {adcm_user.username} should not be presented in group {adcm_group.name}'
+
+    def _login_as_users(self, client: ADCMClient, *users: dict):
+        for user in users:
+            with allure.step(f'Login as LDAP user {user["name"]} to ADCM'):
+                ADCMClient(url=client.url, user=user['name'], password=user['password'])
diff --git a/tests/functional/ldap_auth/test_sync.py b/tests/functional/ldap_auth/test_sync.py
new file mode 100644
index 0000000000..aaa92348bb
--- /dev/null
+++ b/tests/functional/ldap_auth/test_sync.py
@@ -0,0 +1,359 @@
+# Licensed 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.
+
+"""Test synchronization and test connection with LDAP"""
+import time
+from contextlib import contextmanager
+from typing import Optional, Tuple
+
+import allure
+import pytest
+import requests
+from adcm_client.objects import ADCMClient, Group, ADCM, User
+from adcm_pytest_plugin.steps.actions import wait_for_task_and_assert_result
+from adcm_pytest_plugin.utils import wait_until_step_succeeds, random_string
+from coreapi.exceptions import ErrorMessage
+
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.ldap_auth.utils import (
+    get_ldap_group_from_adcm,
+    get_ldap_user_from_adcm,
+    check_existing_groups,
+    check_existing_users,
+    DEFAULT_LOCAL_USERS,
+    TEST_CONNECTION_ACTION,
+    SYNC_ACTION_NAME,
+)
+from tests.functional.rbac.conftest import RbacRoles, BusinessRoles
+from tests.library.assertions import expect_no_api_error, expect_api_error
+from tests.library.ldap_interactions import LDAPTestConfig, configure_adcm_for_ldap
+
+# pylint: disable=redefined-outer-name
+
+pytestmark = [only_clean_adcm]
+
+
+@pytest.fixture()
+def adcm_user_client(sdk_client_fs) -> ADCMClient:
+    """Create simple user with ADCM User role"""
+    username, password = 'SimpleUser', 'MegaPassword'
+    user = sdk_client_fs.user_create(username, password)
+    sdk_client_fs.policy_create('Simple user', role=sdk_client_fs.role(name=RbacRoles.ADCMUser.value), user=[user])
+    return ADCMClient(url=sdk_client_fs.url, user=username, password=password)
+
+
+@pytest.fixture()
+def adcm_admin_client(sdk_client_fs) -> ADCMClient:
+    """Create ADCM admin user that is allowed to edit ADCM settings"""
+    username, password = 'SupaADCMAdmin', 'MegaPassword'
+    user = sdk_client_fs.user_create(username, password)
+    role = sdk_client_fs.role_create(
+        'ADCM admin role',
+        display_name='ADCM admin role',
+        child=[{'id': sdk_client_fs.role(name=BusinessRoles.EditADCMSettings.value.role_name).id}],
+    )
+    sdk_client_fs.policy_create('ADCM Admins', role=role, user=[user])
+    return ADCMClient(url=sdk_client_fs.url, user=username, password=password)
+
+
+@pytest.fixture()
+def adcm_superuser_client(sdk_client_fs) -> ADCMClient:
+    """Create another ADCM superuser"""
+    username, password = 'SupaDupaADCMAdmin', 'MegaPassword'
+    sdk_client_fs.user_create(username, password, is_superuser=True)
+    return ADCMClient(url=sdk_client_fs.url, user=username, password=password)
+
+
+class TestDisablingCause:
+    """Test LDAP-related ADCM actions have correct disabling cause"""
+
+    DISABLING_CAUSE = 'no_ldap_settings'
+
+    def test_ldap_connection_test_disabling_cause(self, sdk_client_fs, ad_config, ldap_basic_ous):
+        """Test that disabling cause is set right for "test_ldap_connection" action"""
+        adcm = sdk_client_fs.adcm()
+
+        with allure.step('Check that with default settings disabling cause is set'):
+            self._check_disabling_cause(adcm, self.DISABLING_CAUSE)
+        with allure.step('Set correct LDAP settings and check disabling cause is None'):
+            self._set_ldap_settings(sdk_client_fs, ad_config, ldap_basic_ous)
+            self._check_disabling_cause(adcm, None)
+        with allure.step('Disable LDAP settings and check disabling cause is set'):
+            adcm.config_set_diff({'attr': {'ldap_integration': {'active': False}}})
+            self._check_disabling_cause(adcm, self.DISABLING_CAUSE)
+
+    def _check_disabling_cause(self, adcm: ADCM, expected: Optional[str]):
+        # retrieve each time to avoid rereading
+        sync = adcm.action(name=SYNC_ACTION_NAME)
+        test_connection = adcm.action(name=TEST_CONNECTION_ACTION)
+        assert (
+            sync.disabling_cause == expected
+        ), f'Sync action has incorrect disabling cause: {sync.disabling_cause}.\nExpected: {expected}'
+        assert test_connection.disabling_cause == expected, (
+            f'Test connection action has incorrect disabling cause: {test_connection.disabling_cause}.\n'
+            f'Expected: {expected}'
+        )
+
+    def _set_ldap_settings(self, client: ADCMClient, config: LDAPTestConfig, ous: Tuple[str, str]):
+        groups_ou, users_ou = ous
+        configure_adcm_for_ldap(client, config, False, None, users_ou, groups_ou)
+
+
+class TestLDAPSyncAction:
+    """Test LDAP sync action"""
+
+    pytestmark = [pytest.mark.usefixtures('configure_adcm_ldap_ad')]
+
+    def test_ldap_simple_sync(self, sdk_client_fs, ldap_user_in_group, ldap_group):
+        """Test that LDAP sync action pulls users and groups from LDAP"""
+        self._simple_sync(sdk_client_fs, ldap_group, ldap_user_in_group, DEFAULT_LOCAL_USERS)
+
+    # pylint: disable-next=too-many-arguments
+    def test_access_to_tasks(
+        self, adcm_user_client, adcm_admin_client, adcm_superuser_client, sdk_client_fs, ldap_user_in_group, ldap_group
+    ):
+        """Test that only superusers can see LDAP-related tasks"""
+        superuser_name = adcm_superuser_client.me().username
+        self._simple_sync(
+            sdk_client_fs,
+            ldap_group,
+            ldap_user_in_group,
+            (*DEFAULT_LOCAL_USERS, adcm_user_client.me().username, adcm_admin_client.me().username, superuser_name),
+        )
+        wait_for_task_and_assert_result(sdk_client_fs.adcm().action(name=TEST_CONNECTION_ACTION).run(), 'success')
+        _check_task_logs_amount(adcm_user_client, 0)
+        _check_task_logs_amount(adcm_admin_client, 0)
+        _check_task_logs_amount(adcm_superuser_client, 2)
+        with allure.step('Make superuser a regular user and check available tasks'):
+            superuser: User = sdk_client_fs.user(username=superuser_name)
+            superuser.update(is_superuser=False)
+            _check_task_logs_amount(adcm_superuser_client, 0)
+
+    def test_sync_with_already_existing_group(self, sdk_client_fs, ldap_user_in_group, ldap_group):
+        """Test that when LDAP group have a local group with the same name, there won't be a conflict"""
+        with allure.step('Create group with the name of LDAP group'):
+            local_group: Group = sdk_client_fs.group_create(name=ldap_group['name'])
+        check_existing_users(sdk_client_fs)
+        check_existing_groups(sdk_client_fs, expected_local={local_group.name})
+        _run_sync(sdk_client_fs)
+        check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+        check_existing_groups(sdk_client_fs, {ldap_group['name']}, {local_group.name})
+        local_group.reread()
+        assert len(local_group.user_list()) == 0, 'There should not be any user in local group'
+        ldap_group_in_adcm = get_ldap_group_from_adcm(sdk_client_fs, ldap_group['name'])
+        assert ldap_user_in_group['name'] in [
+            u.username for u in ldap_group_in_adcm.user_list()
+        ], f'Group from LDAP should have user {ldap_user_in_group["name"]}'
+
+    def test_sync_with_already_existing_user(self, sdk_client_fs, ldap_group, ldap_user_in_group):
+        """
+        Test that during the sync existing users isn't overridden
+        """
+        group_name = 'Some group'
+        local_password = random_string(16)
+        expected_local_users = (*DEFAULT_LOCAL_USERS, ldap_user_in_group['name'])
+
+        with allure.step('Create user with the name of LDAP user'):
+            local_user: User = sdk_client_fs.user_create(ldap_user_in_group['name'], local_password)
+        with allure.step('Create group to add user to'):
+            group: Group = sdk_client_fs.group_create(group_name)
+            group.add_user(local_user)
+        check_existing_users(sdk_client_fs, expected_local=expected_local_users)
+        check_existing_groups(sdk_client_fs, expected_local=[group_name])
+        _run_sync(sdk_client_fs)
+        local_user.reread()
+        with allure.step('Check user type is local'):
+            assert local_user.type == 'local', 'User type should stay "local"'
+        with allure.step('Check user is still in group'):
+            assert local_user.username in [
+                u.username for u in group.user_list()
+            ], 'Local user should still be a part of the group'
+        check_existing_users(sdk_client_fs, expected_local=expected_local_users)
+        check_existing_groups(sdk_client_fs, expected_ldap=[ldap_group['name']], expected_local=[group_name])
+
+    # pylint: disable-next=too-many-arguments
+    def test_ldap_group_removed(self, sdk_client_fs, ldap_ad, ldap_group, ldap_user_in_group):
+        """Test LDAP group removed from ADCM after it's removed from LDAP"""
+        _run_sync(sdk_client_fs)
+        check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+        check_existing_groups(sdk_client_fs, {ldap_group['name']})
+        with allure.step('Delete group from LDAP'):
+            ldap_ad.delete(ldap_group['dn'])
+        _run_sync(sdk_client_fs)
+        check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+        check_existing_groups(sdk_client_fs)
+
+    # pylint: disable-next=too-many-arguments
+    def test_user_removed_from_group(self, sdk_client_fs, ldap_ad, ldap_group, ldap_user_in_group):
+        """Test that when user is removed from group in AD, it is also removed in ADCM's LDAP group"""
+        another_group: Group = sdk_client_fs.group_create('Another group')
+        _run_sync(sdk_client_fs)
+        with allure.step('Add LDAP users to the local group'):
+            user_in_group = get_ldap_user_from_adcm(sdk_client_fs, ldap_user_in_group['name'])
+            group = get_ldap_group_from_adcm(sdk_client_fs, ldap_group['name'])
+            another_group.add_user(user_in_group)
+        with allure.step('Remove user in AD from LDAP group and rerun sync'):
+            ldap_ad.remove_user_from_group(ldap_user_in_group['dn'], ldap_group['dn'])
+            _run_sync(sdk_client_fs)
+        with allure.step('Check user was removed only from LDAP group'):
+            check_existing_users(sdk_client_fs)
+            # Uncomment after ADCM-2944
+            # check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+            check_existing_groups(sdk_client_fs, {ldap_group['name']}, {another_group.name})
+            group.reread()
+            assert len(group.user_list()) == 0, 'Group from LDAP should be empty'
+            another_group.reread()
+            assert len(another_group.user_list()) == 0, 'Local group should not have deleted users in it'
+            # assert len(another_group.user_list()) == 1, 'Local group should still have deactivated users in it'
+
+    def test_user_deactivated(self, sdk_client_fs, ldap_ad, ldap_user_in_group):
+        """Test that user is deactivated in ADCM after it's deactivated in AD"""
+        ldap_user = ldap_user_in_group
+        credentials = {'user': ldap_user['name'], 'password': ldap_user['password'], 'url': sdk_client_fs.url}
+        with allure.step('Run sync and check that user is active and can log in'):
+            _run_sync(sdk_client_fs)
+            user = get_ldap_user_from_adcm(sdk_client_fs, ldap_user['name'])
+            assert user.is_active, 'User should be active'
+            expect_no_api_error('login as LDAP active user', ADCMClient, **credentials)
+        with allure.step('Deactivate user in LDAP and check it is deactivated after sync'):
+            with session_should_expire(**credentials):
+                ldap_ad.deactivate_user(ldap_user['dn'])
+                _run_sync(sdk_client_fs)
+                # TODO return after ADCM-ADCM-2944
+                # user.reread()
+                # assert not user.is_active, 'User should be deactivated'
+                expect_api_error('login as deactivated user', ADCMClient, **credentials)
+
+    def test_user_deleted(self, sdk_client_fs, ldap_ad, ldap_user_in_group):
+        """Test that user is deleted in ADCM after it's deleted in AD"""
+        credentials = {
+            'user': ldap_user_in_group['name'],
+            'password': ldap_user_in_group['password'],
+            'url': sdk_client_fs.url,
+        }
+        with allure.step('Run sync and check that user is active and can log in'):
+            _run_sync(sdk_client_fs)
+
+            check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+            expect_no_api_error('login as LDAP user', ADCMClient, **credentials)
+        with allure.step('Delete user in LDAP and check access denied'):
+            with session_should_expire(**credentials):
+                ldap_ad.delete(ldap_user_in_group['dn'])
+                _run_sync(sdk_client_fs)
+                check_existing_users(sdk_client_fs)
+                # Uncomment after ADCM-2944
+                # check_existing_users(sdk_client_fs, {ldap_user_in_group['name']})
+                # user = get_ldap_user_from_adcm(sdk_client_fs, ldap_user_in_group['name'])
+                # assert not user.is_active, 'User should be deactivated'
+                expect_api_error('login as deleted user', ADCMClient, **credentials)
+
+    def test_name_email_sync_from_ldap(self, sdk_client_fs, ldap_ad, ldap_user_in_group):
+        """Test that first/last name and email are synced with LDAP"""
+        new_user_info = {'first_name': 'Babaika', 'last_name': 'Labadaika', 'email': 'doesnt@ex.ist'}
+        _run_sync(sdk_client_fs)
+        user = get_ldap_user_from_adcm(sdk_client_fs, ldap_user_in_group['name'])
+        self._check_user_info(user, ldap_user_in_group)
+        ldap_ad.update_user(ldap_user_in_group['dn'], **new_user_info)
+        _run_sync(sdk_client_fs)
+        self._check_user_info(user, new_user_info)
+
+    def _check_user_info(self, user: User, user_ldap_info: dict):
+        user.reread()
+        for field_name in ('first_name', 'last_name', 'email'):
+            actual = getattr(user, field_name)
+            expected = user_ldap_info[field_name]
+            assert actual == expected, f'Field "{field_name}" is incorrect.\nExpected: {expected}\nActual: {actual}'
+
+    def _simple_sync(self, sdk_client_fs, ldap_group, ldap_user_in_group, expected_local_users):
+        check_existing_users(sdk_client_fs, expected_local=expected_local_users)
+        check_existing_groups(sdk_client_fs)
+        _run_sync(sdk_client_fs)
+        check_existing_users(sdk_client_fs, {ldap_user_in_group['name']}, expected_local=expected_local_users)
+        check_existing_groups(sdk_client_fs, {ldap_group['name']})
+
+
+class TestPeriodicSync:
+    """Test that periodic LDAP synchronization tasks are launched correctly"""
+
+    def test_sync_periodic_launch(self, sdk_client_fs, ad_config, ldap_basic_ous):
+        """Test that sync task is launched periodically with correct settings"""
+        groups_ou, users_ou = ldap_basic_ous
+
+        with allure.step('Turn ADCM LDAP config on'):
+            configure_adcm_for_ldap(sdk_client_fs, ad_config, False, None, users_ou, groups_ou, {'sync_interval': 1})
+
+        with allure.step('Check that 1 minute after the config has been saved the sync task was launched'):
+            assert len(sdk_client_fs.job_list()) == 0, 'There should not be any jobs right after config is saved'
+            wait_until_step_succeeds(self._check_sync_task_is_presented, timeout=70, period=5, client=sdk_client_fs)
+
+        with allure.step('Check that after 1 more minute the second sync task was launched'):
+            wait_until_step_succeeds(
+                self._check_sync_task_is_presented, timeout=70, period=5, client=sdk_client_fs, expected_amount=2
+            )
+
+        with allure.step('Disable sync in settings and check no new task was launched'):
+            configure_adcm_for_ldap(sdk_client_fs, ad_config, False, None, users_ou, groups_ou, {'sync_interval': 0})
+            # this won't check for an error like "we didn't take last non-zero value as a wrong interval",
+            # so if you'll have some more detailed check in mind, please use it
+            time.sleep(65)
+            self._check_sync_task_is_presented(sdk_client_fs, 2)
+
+    def _check_sync_task_is_presented(self, client: ADCMClient, expected_amount: int = 1):
+        with allure.step(f'Check {expected_amount} sync task(s) is presented among tasks'):
+            sync_tasks = [
+                task for task in (j.task() for j in client.job_list()) if task.action().name == SYNC_ACTION_NAME
+            ]
+            assert (
+                actual_amount := len(sync_tasks)
+            ) == expected_amount, f'Not enough sync tasks: {actual_amount}.\nExpected: {expected_amount}'
+
+
+@contextmanager
+def session_should_expire(user: str, password: str, url: str):
+    """Check that session expires"""
+    with allure.step('Login via API'):
+        client = ADCMClient(url=url, user=user, password=password)
+    with requests.Session() as session:
+        with allure.step('Get session cookies'):
+            response = session.post(f'{url}/api/v1/rbac/token/', json={'username': user, 'password': password})
+            assert response.status_code == 200, 'Clusters page should be available'
+        yield
+        with allure.step('Check session is "over"'):
+            response = session.get(f'{url}/api/v1/cluster')
+            assert response.status_code == 401, 'Request to ADCM should fail with 401 status'
+    with allure.step('Check call via client is considered unauthorized'):
+        with pytest.raises(ErrorMessage) as e:
+            client.cluster_list()
+        try:
+            assert '401 Unauthorized' in e.value.error.title, 'Operation should fail with 401 code'
+        except (KeyError, AttributeError) as err:
+            raise AssertionError(
+                f'Operation should fail as an unauthorized one\nBut check was failed due to {err}\n'
+            ) from err
+
+
+@allure.step('Run LDAP sync action')
+def _run_sync(client: ADCMClient):
+    action = client.adcm().action(name=SYNC_ACTION_NAME)
+    wait_for_task_and_assert_result(action.run(), 'success')
+
+
+@allure.step('Run successful test connection')
+def _test_connection(client: ADCMClient):
+    wait_for_task_and_assert_result(client.adcm().action(name=TEST_CONNECTION_ACTION).run(), 'success')
+
+
+def _check_task_logs_amount(client: ADCMClient, amount: int):
+    client.reread()
+    with allure.step(f'Check that user {client.me().username} can see {amount} tasks'):
+        tasks = tuple(j.task() for j in client.job_list())
+        assert len(tasks) == amount, f'Incorrect amount of tasks is available for user {client.me().username}'
diff --git a/tests/functional/ldap_auth/utils.py b/tests/functional/ldap_auth/utils.py
new file mode 100644
index 0000000000..afa30c7b69
--- /dev/null
+++ b/tests/functional/ldap_auth/utils.py
@@ -0,0 +1,104 @@
+# Licensed 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.
+
+"""Utilities for LDAP-related tests"""
+
+from typing import Collection
+
+import allure
+from adcm_client.base import ObjectNotFound
+from adcm_client.objects import ADCMClient, Group, User
+
+from tests.library.assertions import sets_are_equal, expect_api_error, expect_no_api_error
+
+SYNC_ACTION_NAME = 'run_ldap_sync'
+TEST_CONNECTION_ACTION = 'test_ldap_connection'
+DEFAULT_LOCAL_USERS = ('admin', 'status')
+
+
+def get_ldap_user_from_adcm(client: ADCMClient, name: str) -> User:
+    """
+    Get LDAP user from ADCM.
+    Name should be sAMAccount value.
+    :raises AssertionError: when there's no user presented in ADCM
+    """
+    username = name
+    try:
+        return client.user(username=username)
+    except ObjectNotFound as e:
+        raise AssertionError(f'LDAP user "{name}" should be available as ADCM "{username}" user') from e
+
+
+def get_ldap_group_from_adcm(client: ADCMClient, name: str) -> Group:
+    """
+    Get LDAP group from ADCM.
+    :raises AssertionError: when there's no group presented in ADCM
+    """
+    try:
+        return client.group(name=name, type='ldap')
+    except ObjectNotFound as e:
+        raise AssertionError(f'LDAP group "{name}" should be available as ADCM group "{name}"') from e
+
+
+@allure.step('Check users existing in ADCM')
+def check_existing_users(
+    client: ADCMClient, expected_ldap: Collection[str] = (), expected_local: Collection[str] = DEFAULT_LOCAL_USERS
+):
+    """Check that only provided users exists (both ldap and local)"""
+    expected_ldap = set(expected_ldap)
+    existing_ldap = {u.username for u in client.user_list() if u.type == 'ldap'}
+    expected_local = set(expected_local)
+    existing_local = {u.username for u in client.user_list() if u.type == 'local'}
+    with allure.step('Check users from LDAP'):
+        sets_are_equal(existing_ldap, expected_ldap, 'Set of users from LDAP is incorrect')
+    with allure.step('Check local users'):
+        sets_are_equal(existing_local, expected_local, 'Set of local ADCM users is incorrect')
+
+
+@allure.step('Check groups existing in ADCM')
+def check_existing_groups(
+    client: ADCMClient, expected_ldap: Collection[str] = (), expected_local: Collection[str] = ()
+):
+    """Check that only provided groups exists (both ldap and local)"""
+    expected_ldap = set(expected_ldap)
+    existing_ldap = {g.name for g in client.group_list() if g.type == 'ldap'}
+    expected_local = set(expected_local)
+    existing_local = {g.name for g in client.group_list() if g.type == 'local'}
+    with allure.step('Check groups from LDAP'):
+        sets_are_equal(existing_ldap, expected_ldap, message='Not all LDAP groups are presented in ADCM')
+    with allure.step('Check local groups'):
+        sets_are_equal(existing_local, expected_local, message='Not all local groups are presented in ADCM')
+
+
+def login_should_succeed(operation_name: str, client: ADCMClient, username: str, password: str):
+    """Check that given user can log in to ADCM without error"""
+    with allure.step(operation_name.capitalize()):
+        expect_no_api_error(
+            operation_name,
+            ADCMClient,
+            url=client.url,
+            user=username,
+            password=password,
+        )
+
+
+def login_should_fail(operation_name: str, client: ADCMClient, username: str, password: str, err=None):
+    """Check that an error is raised on login attempt with given credentials"""
+    with allure.step(operation_name.capitalize()):
+        expect_api_error(
+            operation_name,
+            ADCMClient,
+            err_=err,
+            url=client.url,
+            user=username,
+            password=password,
+        )
diff --git a/tests/functional/rbac/conftest.py b/tests/functional/rbac/conftest.py
index 1c289d1666..3d0034d329 100644
--- a/tests/functional/rbac/conftest.py
+++ b/tests/functional/rbac/conftest.py
@@ -498,7 +498,7 @@ def is_denied(
             else:
                 try:
                     role.method_call(base_object, *args, **kwargs)
-                except (AccessIsDenied, NoSuchEndpointOrAccessIsDenied):
+                except (AccessIsDenied, NoSuchEndpointOrAccessIsDenied, ObjectNotFound):
                     pass
                 else:
                     raise AssertionError(f"{role.role_name} on {object_represent} should not be allowed")
diff --git a/tests/functional/test_allow_to_terminate.py b/tests/functional/test_allow_to_terminate.py
index 16fa5a6749..ec52066b81 100644
--- a/tests/functional/test_allow_to_terminate.py
+++ b/tests/functional/test_allow_to_terminate.py
@@ -15,7 +15,7 @@
 import allure
 import pytest
 from adcm_client.objects import Cluster, Component
-from adcm_pytest_plugin.utils import get_data_dir, wait_until_step_succeeds
+from adcm_pytest_plugin.utils import wait_until_step_succeeds
 
 from tests.library.assertions import expect_no_api_error, expect_api_error
 
@@ -23,12 +23,13 @@
 
 CANCELLED_STATUS = 'aborted'
 
+pytestmark = [pytest.mark.parametrize('generic_bundle', ['action_termination_allowed'], indirect=True)]
+
 
 @pytest.fixture()
-def cluster(sdk_client_fs) -> Cluster:
+def cluster(generic_bundle) -> Cluster:
     """Create cluster from newly uploaded bundle"""
-    bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__))
-    return bundle.cluster_create('Test Cluster')
+    return generic_bundle.cluster_create('Test Cluster')
 
 
 @pytest.mark.parametrize('action_name', ['multi', 'simple'])
diff --git a/tests/functional/test_concerns.py b/tests/functional/test_concerns.py
index 225653726e..cd66e4f58b 100644
--- a/tests/functional/test_concerns.py
+++ b/tests/functional/test_concerns.py
@@ -77,6 +77,7 @@ def test_action_should_not_be_run_while_hostprovider_has_an_issue(
             provider.action(name="install").run()
 
 
+@pytest.mark.xfail(reason="https://tracker.yandex.ru/ADCM-3033")
 def test_when_cluster_has_issue_than_upgrade_locked(sdk_client_fs: ADCMClient):
     """Test upgrade should not be run while cluster has an issue"""
     with allure.step("Create cluster and upload new one bundle"):
@@ -92,6 +93,7 @@ def test_when_cluster_has_issue_than_upgrade_locked(sdk_client_fs: ADCMClient):
         UPGRADE_ERROR.equal(e, "cluster ", " has blocking concerns ")
 
 
+@pytest.mark.xfail(reason="https://tracker.yandex.ru/ADCM-3033")
 def test_when_hostprovider_has_issue_than_upgrade_locked(sdk_client_fs: ADCMClient):
     """Test upgrade should not be run while hostprovider has an issue"""
     with allure.step("Create hostprovider"):
diff --git a/tests/functional/test_secrets.py b/tests/functional/test_secrets.py
new file mode 100644
index 0000000000..0468d3c2ed
--- /dev/null
+++ b/tests/functional/test_secrets.py
@@ -0,0 +1,184 @@
+# Licensed 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.
+
+"""Test correct appearance of password/secrettext fields in ADCM logs, job logs, inventory"""
+
+import json
+from typing import Iterator
+
+import allure
+import pytest
+from adcm_client.objects import Cluster
+from adcm_pytest_plugin.steps.actions import run_cluster_action_and_assert_result
+from adcm_pytest_plugin.utils import get_data_dir
+from docker.models.containers import Container
+
+
+# pylint: disable=redefined-outer-name
+
+OLD_PASSWORD = 'simplePassword'
+NEW_PASSWORD = 'veryMuchStrongerstrPassword'
+OLD_SECRETTEXT = 'This text should be secret\nbut not for your eyes'
+NEW_SECRETTEXT = 'This is what I am going to tell you\nYou are the best'
+
+# we split secrettext to check if either part of it is in logs, because none should be
+SECRETS = [OLD_PASSWORD, NEW_PASSWORD, *OLD_SECRETTEXT.split('\n'), *NEW_SECRETTEXT.split('\n')]
+
+CHANGE_CONFIG_ACTION = 'change_secrets'
+CHECK_CONFIG_ACTION = 'check'
+
+ADCM_LOG_DIR = '/adcm/data/log'
+ADCM_RUN_DIR = '/adcm/data/run'
+
+
+@pytest.fixture()
+def configured_cluster(sdk_client_fs) -> Cluster:
+    """Upload bundle, create cluster and set password/secrettext"""
+    bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'cluster'))
+    cluster = bundle.cluster_create('Secretly Secret Cluster')
+    _set_cluster_secrets(cluster, OLD_PASSWORD, OLD_SECRETTEXT)
+    return cluster
+
+
+def test_secrets_change_via_adcm_config_plugin(adcm_fs, configured_cluster):
+    """
+    Test that secret config values aren't stored as plaintext in ADCM
+    when adcm_config is used
+    """
+    # run twice to ensure secrets changes doesn't "leak"
+    for password, secrettext in ((NEW_PASSWORD, NEW_SECRETTEXT), (OLD_PASSWORD, OLD_SECRETTEXT)):
+        job_id = _run_change_config(configured_cluster, password, secrettext).job_list()[0].id
+        _check_no_secrets_in_config(configured_cluster)
+        _check_no_secrets_in_job_dir(adcm_fs.container, job_id)
+    _check_no_secrets_in_adcm_logs(adcm_fs.container)
+
+
+def test_secrets_change_via_config(configured_cluster):
+    """
+    Test that changing secret fields in adcm doesn't lead to their reveal in object's config
+    """
+    _check_secrets_in_config_encrypted(configured_cluster)
+    _check_no_secrets_in_config(configured_cluster)
+    _set_cluster_secrets(configured_cluster, NEW_PASSWORD, NEW_SECRETTEXT)
+    _check_secrets_in_config_encrypted(configured_cluster)
+    _check_no_secrets_in_config(configured_cluster)
+
+
+def test_secrets_are_correct_in_job_when_changed_via_plugin(configured_cluster, generic_provider):
+    """
+    Test that correct secret values are available in job's context
+    when they were changed via adcm_config plugin
+    """
+    configured_cluster.host_add(generic_provider.host_create('some-fqdn'))
+    with allure.step('Check secrets set by config change'):
+        _run_check_config(configured_cluster, OLD_PASSWORD, OLD_SECRETTEXT)
+    with allure.step('Change secrets to new ones with plugin and check'):
+        _run_change_config(configured_cluster, NEW_PASSWORD, NEW_SECRETTEXT)
+        _run_check_config(configured_cluster, NEW_PASSWORD, NEW_SECRETTEXT)
+    with allure.step('Change secrets to old values with plugin and check'):
+        _run_change_config(configured_cluster, OLD_PASSWORD, OLD_SECRETTEXT)
+        _run_check_config(configured_cluster, OLD_PASSWORD, OLD_SECRETTEXT)
+
+
+# !===== Checks =====!
+
+
+def _check_no_secrets_in_config(cluster: Cluster):
+    with allure.step(f'Check there are no secrets in "{cluster.name}"'):
+        config = cluster.config()
+        text_config: str = json.dumps(config, indent=2)
+        found_secrets = [secret for secret in SECRETS if secret in text_config]
+        if not found_secrets:
+            return
+        allure.attach(text_config, name='Config with revealed secrets', attachment_type=allure.attachment_type.JSON)
+        raise AssertionError('\n'.join(('Some of secrets were found in config:', *found_secrets)))
+
+
+@allure.step('Check that secrets in config are vault-encrypted')
+def _check_secrets_in_config_encrypted(cluster: Cluster):
+    config = cluster.config()
+    secret_config_fields = (
+        config['password'],
+        config['secrettext'],
+        config['group']['password'],
+        config['group']['secrettext'],
+    )
+    assert all(
+        field.startswith('$ANSIBLE_VAULT') for field in secret_config_fields
+    ), f'Not all config fields are encrypted in config:\n{config}'
+
+
+@allure.step('Check there are no secrets in ADCM log files')
+def _check_no_secrets_in_adcm_logs(container: Container):
+    exit_code, output = container.exec_run(['ls', '-p', ADCM_LOG_DIR])
+    ls_output = output.decode('utf-8')
+    if exit_code != 0:
+        raise ValueError(f'Failed to get files from {ADCM_LOG_DIR}\nExit code {exit_code}\nOutput: {ls_output}')
+    _assert_files_in_dir_for_secrets_absence(container, ADCM_LOG_DIR, ls_output)
+
+
+@allure.step('Check there are no secrets in files of {job_id}')
+def _check_no_secrets_in_job_dir(container: Container, job_id: int):
+    # get contents of inventory and config json
+    job_dir = f'{ADCM_RUN_DIR}/{job_id}'
+    exit_code, output = container.exec_run(['ls', '-p', job_dir])
+    ls_output = output.decode('utf-8')
+    if exit_code != 0:
+        raise ValueError(f'Failed to get files from {job_dir}\nExit code {exit_code}\nOutput: {ls_output}')
+    _assert_files_in_dir_for_secrets_absence(container, job_dir, ls_output)
+
+
+# !===== Utils =====!
+
+
+def _assert_files_in_dir_for_secrets_absence(container: Container, directory: str, ls_out: str):
+    found_secrets = []
+    for file in _filtered_files(ls_out):
+        filename = f'{directory}/{file}'
+        exit_code, output = container.exec_run(['cat', filename])
+        file_content = output.decode('utf-8')
+        if exit_code != 0:
+            raise ValueError(f'Failed to get content of {filename}\nExit code {exit_code}\nOutput: {file_content}')
+        for found_secret in (s for s in SECRETS if s in file_content):
+            found_message = f'Secret "{found_secret}" found in {filename}'
+            found_secrets.append(found_message)
+            allure.attach(file_content, name=found_message)
+    if found_secrets:
+        raise AssertionError("\n".join(('Some of the secrets were found in ADCM log files:\n', *found_secrets)))
+
+
+def _filtered_files(ls_out: str) -> Iterator[str]:
+    """
+    Return filter object that returns only files from output of `ls -p` command converted to utf-8 string
+    """
+    return filter(lambda x: x != '' and x[-1] != '/', ls_out.split('\n'))
+
+
+def _set_cluster_secrets(cluster: Cluster, password: str, secrettext: str):
+    secrets = {'password': password, 'secrettext': secrettext}
+    return cluster.config_set({**secrets, 'group': {**secrets}})
+
+
+def _run_change_config(cluster: Cluster, new_password: str, new_secrettext: str):
+    return run_cluster_action_and_assert_result(
+        cluster,
+        CHANGE_CONFIG_ACTION,
+        config={'new_password': new_password, 'new_secrettext': new_secrettext},
+    )
+
+
+def _run_check_config(cluster: Cluster, password: str, secrettext: str):
+    return run_cluster_action_and_assert_result(
+        cluster,
+        CHECK_CONFIG_ACTION,
+        config={'password': password, 'secrettext': secrettext},
+    )
diff --git a/tests/functional/test_secrets_data/cluster/change.yaml b/tests/functional/test_secrets_data/cluster/change.yaml
new file mode 100644
index 0000000000..b29fbfd258
--- /dev/null
+++ b/tests/functional/test_secrets_data/cluster/change.yaml
@@ -0,0 +1,35 @@
+# Licensed 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.
+
+- name: "SeeMeInAction"
+  hosts: localhost
+  tasks:
+    - name: "Change password"
+      adcm_config:
+        type: "cluster"
+        key: "password"
+        value: "{{ job.config.new_password }}"
+    - name: "Change secrettext"
+      adcm_config:
+        type: "cluster"
+        key: "secrettext"
+        value: "{{ job.config.new_secrettext }}"
+    - name: "Change password in group"
+      adcm_config:
+        type: "cluster"
+        key: "group/password"
+        value: "{{ job.config.new_password }}"
+    - name: "Change secrettext in group"
+      adcm_config:
+        type: "cluster"
+        key: "group/secrettext"
+        value: "{{ job.config.new_secrettext }}"
diff --git a/tests/functional/test_secrets_data/cluster/check.yaml b/tests/functional/test_secrets_data/cluster/check.yaml
new file mode 100644
index 0000000000..198ae006e3
--- /dev/null
+++ b/tests/functional/test_secrets_data/cluster/check.yaml
@@ -0,0 +1,24 @@
+# Licensed 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.
+
+- name: "Check values"
+  hosts: all
+  gather_facts: false
+  tasks:
+    - name: "Check password"
+      fail:
+        msg: "Expected value is {{ job.config.password }}, actual value is {{ cluster.config.password }}"
+      when: "{{ job.config.password != cluster.config.password }}"
+    - name: "Check secrettext"
+      fail:
+        msg: "Expected value is {{ job.config.secrettext }}, actual value is {{ cluster.config.secrettext }}"
+      when: "{{ job.config.secrettext != cluster.config.secrettext }}"
diff --git a/tests/functional/test_secrets_data/cluster/config.yaml b/tests/functional/test_secrets_data/cluster/config.yaml
new file mode 100644
index 0000000000..20d59abdbc
--- /dev/null
+++ b/tests/functional/test_secrets_data/cluster/config.yaml
@@ -0,0 +1,53 @@
+# Licensed 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.
+
+- type: cluster
+  name: Secrets Cluster
+  version: 7.7
+
+  config:
+    - &pass
+      name: password
+      type: password
+    - &text
+      name: secrettext
+      type: secrettext
+    - name: group
+      type: group
+      subs:
+        - <<: *pass
+        - <<: *text
+
+  actions:
+    change_secrets:
+      type: job
+      script_type: ansible
+      script: ./change.yaml
+      config:
+        - name: new_password
+          type: password
+        - name: new_secrettext
+          type: secrettext
+      states:
+        available: any
+
+    check:
+      type: job
+      script_type: ansible
+      script: ./check.yaml
+      config:
+        - name: password
+          type: password
+        - name: secrettext
+          type: secrettext
+      states:
+        available: any
diff --git a/tests/functional/test_upgrade_actions.py b/tests/functional/test_upgrade_actions.py
index 4e9d4cd3c8..a55951e202 100644
--- a/tests/functional/test_upgrade_actions.py
+++ b/tests/functional/test_upgrade_actions.py
@@ -16,24 +16,34 @@
 
 import json
 import os
-from typing import Set
+from pathlib import Path
+from typing import Set, Tuple, Optional, Collection
 
 import allure
 import pytest
-from adcm_client.objects import Cluster, ADCMClient, Bundle
-from adcm_pytest_plugin.docker_utils import get_file_from_container
+import yaml
+from adcm_client.objects import Cluster, ADCMClient, Bundle, Host, Component
+from adcm_pytest_plugin.docker_utils import get_file_from_container, ADCM
 from adcm_pytest_plugin.steps.actions import run_cluster_action_and_assert_result
 from adcm_pytest_plugin.utils import get_data_dir, catch_failed, parametrize_by_data_subdirs, random_string
 from coreapi.exceptions import ErrorMessage
 
 from tests.functional.conftest import only_clean_adcm
-from tests.library.assertions import sets_are_equal
-from tests.library.errorcodes import INVALID_UPGRADE_DEFINITION, INVALID_OBJECT_DEFINITION
+from tests.functional.tools import build_hc_for_hc_acl_action, get_inventory_file
+from tests.library.assertions import sets_are_equal, expect_api_error
+from tests.library.errorcodes import (
+    INVALID_UPGRADE_DEFINITION,
+    INVALID_OBJECT_DEFINITION,
+    INVALID_ACTION_DEFINITION,
+    COMPONENT_CONSTRAINT_ERROR,
+)
 
 # pylint: disable=redefined-outer-name
 
 TEST_SERVICE_NAME = 'test_service'
 FAILURES_DIR = 'upgrade_failures'
+NEW_SERVICE = 'new_service'
+SERVICE_WILL_BE_REMOVED = 'will_be_removed'
 
 UPGRADE_EXTRA_ARGS = {'upgrade_with_config': {'config': {'parampampam': 'somestring'}}}
 
@@ -59,6 +69,16 @@ def old_cluster(request, sdk_client_fs) -> Cluster:
     return _create_old_cluster(sdk_client_fs, *request.param)
 
 
+@pytest.fixture()
+def two_hosts(sdk_client_fs, old_cluster) -> Tuple[Host, Host]:
+    """Two hosts created and added to an old cluster"""
+    provider_bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'provider'))
+    provider = provider_bundle.provider_create(name='Too Simple Provider')
+    return old_cluster.host_add(provider.host_create('first-host')), old_cluster.host_add(
+        provider.host_create('second-host')
+    )
+
+
 @allure.step('Check cluster state is equal to "{state}"')
 def check_state(cluster: Cluster, state: str):
     """Check state of a cluster"""
@@ -76,7 +96,7 @@ def check_prototype(cluster: Cluster, expected_prototype_id: int):
 
 
 def check_cluster_objects_configs_equal_bundle_default(
-    cluster: Cluster, bundle: Bundle, *, service_name: str = 'test_service'
+    cluster: Cluster, bundle: Bundle, *, service_names: Tuple[str, ...] = (TEST_SERVICE_NAME,)
 ):
     """
     Check that configurations of cluster, its services and components
@@ -87,7 +107,8 @@ def check_cluster_objects_configs_equal_bundle_default(
     ):
         actual_configs = _extract_configs(cluster)
         cluster_with_defaults = bundle.cluster_create(f'Cluster to take config from {random_string(4)}')
-        cluster_with_defaults.service_add(name=service_name)
+        for service in service_names:
+            cluster_with_defaults.service_add(name=service)
         expected_configs = _extract_configs(cluster_with_defaults)
 
         if actual_configs == expected_configs:
@@ -142,6 +163,47 @@ def _extract_configs(cluster: Cluster):
     }
 
 
+@allure.step('Check service "{service_name}" is in cluster')
+def _check_service_is_in_cluster(cluster: Cluster, service_name: str):
+    assert service_name in [
+        service.name for service in cluster.service_list()
+    ], f'Service "{service_name}" is not presented in cluster "{cluster.name}"'
+
+
+@allure.step('Check service "{service_name}" is not in cluster')
+def _check_service_is_not_in_cluster(cluster: Cluster, service_name: str):
+    assert service_name not in [
+        service.name for service in cluster.service_list()
+    ], f'Service "{service_name}" should not be presented in cluster "{cluster.name}"'
+
+
+def _get_hc_names(hc_map) -> Set[Tuple[str, str, str]]:
+    return set(map(lambda x: (x['host'], x['service_name'], x['component']), hc_map))
+
+
+def _get_component_prototype_id(bundle, service_name, component_name) -> int:
+    service_proto = bundle.service_prototype(name=service_name)
+    component = next((c for c in service_proto.components if c['name'] == component_name), None)
+    if component is None:
+        raise ValueError(f'Component "{component_name}" is not presented in prototype of service "{service_name}"')
+    return component['id']
+
+
+def _set_hc_and_prepare_new_hc_for_upgrade_action(old_cluster, new_bundle, host_1, host_2):
+    second_component_id = _get_component_prototype_id(new_bundle, TEST_SERVICE_NAME, 'second_component')
+    some_component_id = _get_component_prototype_id(new_bundle, NEW_SERVICE, 'some_component')
+    test_component = old_cluster.service().component(name='test_component')
+    willbegone_component = old_cluster.service_add(name=SERVICE_WILL_BE_REMOVED).component()
+
+    old_cluster.hostcomponent_set((host_1, willbegone_component))
+    return build_hc_for_hc_acl_action(
+        old_cluster,
+        add=((test_component, host_2),),
+        remove=(),
+        add_new_bundle_components=((some_component_id, host_1), (second_component_id, host_1)),
+    )
+
+
 # !===== TESTS =====!
 
 
@@ -164,6 +226,9 @@ def test_validation_succeed_on_upload(self, sdk_client_fs, path):
             ('bundle_switch_in_regular_actions', INVALID_OBJECT_DEFINITION),
             ('incorrect_internal_action', INVALID_UPGRADE_DEFINITION),
             ('no_bundle_switch', INVALID_UPGRADE_DEFINITION),
+            ('hc_acl_in_provider', INVALID_OBJECT_DEFINITION),
+            ('non_existent_service_in_regular_action', INVALID_ACTION_DEFINITION),
+            ('non_existent_component_in_regular_action', INVALID_ACTION_DEFINITION),
         ],
     )
     def test_validation_failed_on_upload(self, bundle_dir_name, expected_error, sdk_client_fs):
@@ -187,7 +252,7 @@ class TestSuccessfulUpgrade:
     def test_successful_upgrade(self, upgrade_name, old_cluster: Cluster, sdk_client_fs):
         """Test successful upgrade scenarios"""
         upgrade_config = UPGRADE_EXTRA_ARGS.get(upgrade_name, {})
-        self._run_successful_upgrade(sdk_client_fs, old_cluster, upgrade_name, upgrade_config)
+        self._upgrade_to_newly_uploaded_version(sdk_client_fs, old_cluster, upgrade_name, upgrade_config)
 
     def test_successful_upgrade_with_content_change(self, sdk_client_fs, old_cluster):
         """
@@ -196,7 +261,7 @@ def test_successful_upgrade_with_content_change(self, sdk_client_fs, old_cluster
         """
         upgrade_name = 'file_content_changed'
         expected_message = 'This message came from the new bundle!'
-        self._run_successful_upgrade(sdk_client_fs, old_cluster, upgrade_name, {})
+        self._upgrade_to_newly_uploaded_version(sdk_client_fs, old_cluster, upgrade_name, {})
         for job_name in ('before_switch', 'after_switch'):
             job = next(
                 filter(
@@ -212,26 +277,156 @@ def test_inventories(self, adcm_fs, sdk_client_fs, old_cluster):
         job_before_id = 1
         job_after_id = 3
 
-        self._run_successful_upgrade(sdk_client_fs, old_cluster, upgrade_name, {})
+        self._upgrade_to_newly_uploaded_version(sdk_client_fs, old_cluster, upgrade_name, {})
         with allure.step('Check inventory of job before the bundle_switch'):
             _compare_inventory_files(adcm_fs, job_before_id)
         with allure.step('Check inventory of job after the bundle_switch'):
             _compare_inventory_files(adcm_fs, job_after_id)
 
-    def _run_successful_upgrade(self, client, old_cluster, upgrade_name, upgrade_config):
+    @only_clean_adcm
+    def test_hc_acl(self, adcm_fs, sdk_client_fs, old_cluster, two_hosts):
+        """
+        Test successful upgrade with `hc_acl` section
+        """
+        host_1, host_2 = two_hosts
+        expected_hc_after_upgrade = {
+            (host_1.fqdn, TEST_SERVICE_NAME, 'second_component'),
+            (host_1.fqdn, NEW_SERVICE, 'some_component'),
+            (host_2.fqdn, TEST_SERVICE_NAME, 'test_component'),
+        }
+        new_bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'hc_acl'))
+
+        hc_after_upgrade = _set_hc_and_prepare_new_hc_for_upgrade_action(old_cluster, new_bundle, host_1, host_2)
+        self._run_successful_upgrade(
+            new_bundle,
+            old_cluster,
+            'successful',
+            {'hc': hc_after_upgrade},
+            service_names=(TEST_SERVICE_NAME, NEW_SERVICE),
+        )
+
+        _check_service_is_in_cluster(old_cluster, NEW_SERVICE)
+        _check_service_is_in_cluster(old_cluster, TEST_SERVICE_NAME)
+        _check_service_is_not_in_cluster(old_cluster, SERVICE_WILL_BE_REMOVED)
+
+        with allure.step('Check hostcomponent'):
+            expected_hc = _get_hc_names(old_cluster.hostcomponent())
+            sets_are_equal(
+                expected_hc,
+                expected_hc_after_upgrade,
+                'Hostcomponent is not the one that expected after an upgrade with hc_acl',
+            )
+
+        self._check_inventories_of_hc_acl_upgrade(adcm_fs, host_1, host_2)
+
+    @allure.step('Check inventory files')  # pylint: disable-next=too-many-locals
+    def _check_inventories_of_hc_acl_upgrade(self, adcm: ADCM, host_1, host_2):
+        test_component_group = f'{TEST_SERVICE_NAME}.test_component'
+        before_add_group = f'{test_component_group}.add'
+        willbegone_group = f'{SERVICE_WILL_BE_REMOVED}.willbegone'
+        before_remove_group = f'{willbegone_group}.remove'
+
+        second_component_group = f'{TEST_SERVICE_NAME}.second_component'
+        after_add_group_1 = f'{second_component_group}.add'
+        some_component_group = f'{NEW_SERVICE}.some_component'
+        after_add_group_2 = f'{some_component_group}.add'
+
+        with allure.step('Check inventory of the job before the bundle switch'):
+            before_switch_inventory = get_inventory_file(adcm, 1)
+            groups = before_switch_inventory['all']['children']
+            for group_name in (before_add_group, willbegone_group, test_component_group):
+                self._check_group_is_presented(group_name, groups)
+            for group_name in (before_remove_group, after_add_group_1, after_add_group_2):
+                self._check_group_is_absent(group_name, groups)
+            self._check_host_is_in_group(host_2, before_add_group, groups)
+            self._check_host_is_in_group(host_2, test_component_group, groups)
+            self._check_host_is_in_group(host_1, willbegone_group, groups)
+
+        with allure.step('Check inventory of the job after the bundle switch'):
+            after_switch_inventory = get_inventory_file(adcm, 4)
+            groups = after_switch_inventory['all']['children']
+            self._check_group_is_presented(test_component_group, groups)
+            self._check_group_is_presented(after_add_group_1, groups)
+            self._check_group_is_presented(second_component_group, groups)
+            self._check_group_is_presented(after_add_group_2, groups)
+            self._check_group_is_presented(some_component_group, groups)
+            self._check_group_is_absent(before_add_group, groups)
+            self._check_group_is_absent(before_remove_group, groups)
+            self._check_group_is_absent(willbegone_group, groups)
+            self._check_host_is_in_group(host_1, after_add_group_1, groups)
+            self._check_host_is_in_group(host_1, after_add_group_2, groups)
+            self._check_host_is_in_group(host_1, after_add_group_1, groups)
+            self._check_host_is_in_group(host_1, second_component_group, groups)
+            self._check_host_is_in_group(host_1, some_component_group, groups)
+
+    # pylint: disable=too-many-arguments
+    def _upgrade_to_newly_uploaded_version(
+        self, client, old_cluster, upgrade_name, upgrade_config, new_bundle_dirs=('successful', 'new')
+    ):
         with allure.step('Upload new version of cluster bundle'):
-            new_bundle = client.upload_from_fs(get_data_dir(__file__, 'successful', 'new'))
+            new_bundle = client.upload_from_fs(get_data_dir(__file__, *new_bundle_dirs))
+        self._run_successful_upgrade(new_bundle, old_cluster, upgrade_name, upgrade_config)
+
+    def _run_successful_upgrade(self, new_bundle, old_cluster, upgrade_name, upgrade_config, **check_kwargs):
         with allure.step('Run upgrade and expect it to be successful'):
             upgrade_task = old_cluster.upgrade(name=upgrade_name).do(**upgrade_config)
             assert upgrade_task.wait() == 'success', f'Upgrade {upgrade_name} failed unexpectedly'
             check_state(old_cluster, 'ready_to_upgrade')
         with allure.step('Check that prototype was upgraded successfully'):
             check_prototype(old_cluster, new_bundle.cluster_prototype().id)
-            check_cluster_objects_configs_equal_bundle_default(old_cluster, new_bundle)
+            check_cluster_objects_configs_equal_bundle_default(old_cluster, new_bundle, **check_kwargs)
+
+    @staticmethod
+    def _check_group_is_presented(group_name, groups):
+        assert group_name in groups, f'Group {group_name} should be in {groups.keys()}'
+
+    @staticmethod
+    def _check_group_is_absent(group_name, groups):
+        assert group_name not in groups, f'Group {group_name} should not be in {groups.keys()}'
+
+    @staticmethod
+    def _check_host_is_in_group(host, group_name, groups):
+        assert host.fqdn in (
+            hosts := groups[group_name]['hosts']
+        ), f'Host {host.fqdn} should be in group {group_name}, but not found in: {hosts}'
+
+
+# pylint: disable-next=too-few-public-methods
+class FailedUploadMixin:
+    """Useful methods for upload failures tests"""
+
+    @allure.step('Upload new version of cluster bundle')
+    def _upload_new_version(self, client: ADCMClient, name: str, directories: tuple = (FAILURES_DIR,)) -> Bundle:
+        """Upload new version of bundle based on the given bundle file_name"""
+        return client.upload_from_fs(get_data_dir(__file__, *directories, name))
+
+    @allure.step('Upgrade cluster and expect it to enter the "{state}" state')
+    def _upgrade_and_expect_state(self, cluster: Cluster, state: str, name: Optional[str] = None, **kwargs):
+        """
+        Upgrade cluster to a new version (expect upgrade to fail)
+        and check if it's state is correct
+        """
+        task = (cluster.upgrade(name=name) if name is not None else cluster.upgrade()).do(**kwargs)
+        assert task.wait() == 'failed', 'Upgrade action should have failed'
+        check_state(cluster, state)
+
+    @allure.step('Check that cluster have "before_upgrade" equal to {state}')
+    def _check_before_upgrade_state(self, cluster: Cluster, state: str):
+        cluster.reread()
+        assert (
+            actual_state := cluster.before_upgrade['state']
+        ) == state, f'"before_upgrade" should be {state}, not {actual_state}'
+
+    @allure.step('Check list of available actions on cluster')
+    def _check_action_list(self, cluster: Cluster, action_names: Set[str]):
+        """Check that action list is equal to given one (by names)"""
+        cluster.reread()
+        presented_action_names = {a.name for a in cluster.action_list()}
+        sets_are_equal(presented_action_names, action_names, message='Incorrect action list')
 
 
 @pytest.mark.parametrize('old_cluster', [(FAILURES_DIR, 'old')], indirect=True, ids=['failures_old_bundle'])
-class TestFailedUpgradeAction:
+class TestFailedUpgradeAction(FailedUploadMixin):
     """Test cases when upgrade action is failed during execution"""
 
     def test_fail_before_switch(self, sdk_client_fs, old_cluster):
@@ -298,36 +493,54 @@ def test_fail_with_both_action_states_set(self, upgrade_name: str, sdk_client_fs
         self._upload_new_version(sdk_client_fs, 'upgrade_action_has_on_fail')
         self._upgrade_and_expect_state(old_cluster, 'something_failed', name=upgrade_name)
 
-    @allure.step('Upload new version of cluster bundle')
-    def _upload_new_version(self, client: ADCMClient, name: str) -> Bundle:
-        """Upload new version of bundle based on the given bundle file_name"""
-        return client.upload_from_fs(get_data_dir(__file__, FAILURES_DIR, name))
 
-    @allure.step('Upgrade cluster and expect it to enter the "{state}" state')
-    def _upgrade_and_expect_state(self, cluster: Cluster, state: str, **kwargs):
+@create_cluster_from_old_bundle
+class TestUpgradeWithHCFailures(FailedUploadMixin):
+    """Test upgrades failures with `hc_acl` in upgrade"""
+
+    @pytest.mark.parametrize(
+        'upgrade_name', ['fail after switch', 'fail on first action after switch'], ids=lambda x: x.replace(' ', '_')
+    )
+    def test_hc_acl_fail_after_switch(self, upgrade_name: str, sdk_client_fs, old_cluster, two_hosts):
         """
-        Upgrade cluster to a new version (expect upgrade to fail)
-        and check if it's state is correct
+        Test an upgrade with `hc_acl` failed after the bundle switch
         """
-        task = cluster.upgrade(**kwargs).do()
-        assert task.wait() == 'failed', 'Upgrade action should have failed'
-        check_state(cluster, state)
+        host_1, host_2 = two_hosts
+        expected_hc = {(host_2.fqdn, TEST_SERVICE_NAME, 'test_component')}
 
-    @allure.step('Check that cluster have "before_upgrade" equal to {state}')
-    def _check_before_upgrade_state(self, cluster: Cluster, state: str):
-        cluster.reread()
-        assert (
-            actual_state := cluster.before_upgrade['state']
-        ) == state, f'"before_upgrade" should be {state}, not {actual_state}'
+        new_bundle = self._upload_new_version(sdk_client_fs, 'hc_acl', ())
+        hc_argument = _set_hc_and_prepare_new_hc_for_upgrade_action(old_cluster, new_bundle, host_1, host_2)
 
-    @allure.step('Check list of available actions on cluster')
-    def _check_action_list(self, cluster: Cluster, action_names: Set[str]):
-        """Check that action list is equal to given one (by names)"""
-        cluster.reread()
-        presented_action_names = {a.name for a in cluster.action_list()}
-        sets_are_equal(presented_action_names, action_names, message='Incorrect action list')
+        self._upgrade_and_expect_state(old_cluster, 'created', name=upgrade_name, hc=hc_argument)
+
+        _check_service_is_in_cluster(old_cluster, TEST_SERVICE_NAME)
+        _check_service_is_in_cluster(old_cluster, NEW_SERVICE)
+        _check_service_is_not_in_cluster(old_cluster, SERVICE_WILL_BE_REMOVED)
+
+        actual_hc = _get_hc_names(old_cluster.hostcomponent())
+        sets_are_equal(actual_hc, expected_hc, 'The hostcomponent from hc argument for an upgrade')
+
+    def test_hc_acl_fail_before_switch(self, sdk_client_fs, old_cluster, two_hosts):
+        """
+        Test an upgrade with `hc_acl` failed before the bundle switch
+        """
+        host_1, host_2 = two_hosts
+
+        new_bundle = self._upload_new_version(sdk_client_fs, 'hc_acl', ())
+        hc_argument = _set_hc_and_prepare_new_hc_for_upgrade_action(old_cluster, new_bundle, host_1, host_2)
+        expected_hc = _get_hc_names(old_cluster.hostcomponent())
+
+        self._upgrade_and_expect_state(old_cluster, 'created', name='fail before switch', hc=hc_argument)
+
+        _check_service_is_in_cluster(old_cluster, TEST_SERVICE_NAME)
+        _check_service_is_in_cluster(old_cluster, SERVICE_WILL_BE_REMOVED)
+        _check_service_is_not_in_cluster(old_cluster, NEW_SERVICE)
+
+        actual_hc = _get_hc_names(old_cluster.hostcomponent())
+        sets_are_equal(actual_hc, expected_hc, 'The hostcomponent from before the upgrade was expected')
 
 
+# pylint: disable-next=too-few-public-methods
 class TestUpgradeActionRelations:
     """Test cases when upgrade action"""
 
@@ -372,3 +585,171 @@ def test_check_upgrade_actions_relations(self, sdk_client_fs, folder_dir, file_d
             assert {action.display_name for action in actions_after} == (
                 {'dummy_action', 'restore'} if "success" in folder_dir else {'dummy_action'}
             ), "Not all actions avaliable"
+
+
+def _set_ids_for_upload_bundles_set_hc(item):
+    # is set_hc
+    if isinstance(item, int):
+        return f'component_on_all_{item}_hosts'
+    if isinstance(item, Collection):
+        # is upload_bundles
+        if isinstance(item[0], list):
+            old_constraint = str(item[0]).replace(' ', '').replace("'", '')
+            new_constraint = str(item[1]).replace(' ', '').replace("'", '')
+            return f'change_constraint_from_{old_constraint}_to_{new_constraint}'
+        # is set_hc with 2 args
+        return f'component_on_{item[0]}_hosts_out_of_{item[1]}'
+    return str(item)
+
+
+class TestConstraintsChangeAfterUpgrade:
+    """
+        Test upgrade when constraints are changed in new version
+    n"""
+
+    pytestmark = [only_clean_adcm]
+
+    OLD_CLUSTER = {'type': 'cluster', 'name': 'cluster_with_constraints', 'version': '1.0'}
+    NEW_CLUSTER = {**OLD_CLUSTER, 'version': '2.0'}
+    SERVICE_DESC = {'type': 'service', 'name': 'service_with_constraints', 'version': '1.0'}
+    COMPONENT_NAME = 'component'
+    DUMMY_COMPONENT_NAME = 'dummy'
+
+    NO_HC_ACL_UPGRADE_SECTION = {
+        'name': 'Simple upgrade',
+        'versions': {'min': 0.5, 'max': 1.9},
+        'states': {'available': 'any'},
+        'scripts': [
+            {'name': 'before', 'script': 'succeed.yaml', 'script_type': 'ansible'},
+            {'name': 'switch', 'script': 'bundle_switch', 'script_type': 'internal'},
+            {'name': 'after', 'script': 'succeed.yaml', 'script_type': 'ansible'},
+        ],
+    }
+
+    @pytest.fixture()
+    def with_hc_in_upgrade(self, request) -> bool:
+        """Flag to prepare `hc_acl` in bundle"""
+        return bool(request.param)
+
+    @pytest.fixture()
+    def upload_bundles(self, request, sdk_client_fs, tmpdir, with_hc_in_upgrade) -> Tuple[Bundle, Bundle]:
+        """Upload bundles created dynamically based on the given constraints"""
+        # request.param should be like ([0,1], ['+'])
+        old_constraint, new_constraint = request.param
+        with allure.step(f'Preparing bundles: constraint in old {old_constraint}, constraint in new {new_constraint}'):
+            old_bundle_config = [
+                self.OLD_CLUSTER,
+                {
+                    **self.SERVICE_DESC,
+                    'components': {self.COMPONENT_NAME: {'constraint': old_constraint}, self.DUMMY_COMPONENT_NAME: {}},
+                },
+            ]
+            new_bundle_config = [
+                {
+                    **self.NEW_CLUSTER,
+                    'upgrade': [
+                        {
+                            **self.NO_HC_ACL_UPGRADE_SECTION,
+                            **(
+                                {
+                                    'hc_acl': [
+                                        {
+                                            'service': self.SERVICE_DESC['name'],
+                                            'component': self.COMPONENT_NAME,
+                                            'action': 'add',
+                                        }
+                                    ]
+                                }
+                                if with_hc_in_upgrade
+                                else {}
+                            ),
+                        }
+                    ],
+                },
+                {
+                    **self.SERVICE_DESC,
+                    'components': {self.COMPONENT_NAME: {'constraint': new_constraint}, self.DUMMY_COMPONENT_NAME: {}},
+                },
+            ]
+            tdir = Path(tmpdir)
+            old_path = tdir / 'old'
+            old_path.mkdir()
+            with (old_path / 'config.yaml').open('w') as f:
+                yaml.safe_dump(old_bundle_config, f)
+            new_path = tdir / 'new'
+            new_path.mkdir()
+            with (new_path / 'config.yaml').open('w') as f:
+                yaml.safe_dump(new_bundle_config, f)
+        with allure.step('Upload old and new bundles'):
+            return sdk_client_fs.upload_from_fs(old_path), sdk_client_fs.upload_from_fs(new_path)
+
+    @allure.step('Create cluster from old bundle and add service')
+    @pytest.fixture()
+    def cluster_with_component(self, upload_bundles) -> Tuple[Cluster, Component]:
+        """Create cluster and add service"""
+        old_bundle, _ = upload_bundles
+        cluster = old_bundle.cluster_create('Cluster with constraints')
+        service = cluster.service_add(name=self.SERVICE_DESC['name'])
+        return cluster, service.component(name=self.COMPONENT_NAME)
+
+    @allure.title('Create hosts and set hostcomponent')
+    @pytest.fixture()
+    def set_hc(self, request, cluster_with_component, generic_provider) -> None:
+        """Set hostcomponent based on given component on host / hosts in cluster amount"""
+        # total amount of hosts shouldn't be 0, it'll conflict with dummy component
+        if isinstance(request.param, int):
+            hosts_per_component = hosts_in_cluster = request.param
+        elif isinstance(request.param, tuple) and all(isinstance(i, int) for i in request.param):
+            hosts_per_component, hosts_in_cluster = request.param
+        else:
+            raise ValueError('Check `set_hc` and give correct param values IDHTFCE')
+        cluster, component = cluster_with_component
+        with allure.step(f'Create {hosts_in_cluster} and add all of them to a cluster'):
+            hosts = [cluster.host_add(generic_provider.host_create(f'host-{i}')) for i in range(hosts_in_cluster)]
+        with allure.step(f'Map component to {hosts_per_component} hosts and add dummy component on one of the hosts'):
+            cluster.hostcomponent_set(
+                (hosts[0], cluster.service().component(name=self.DUMMY_COMPONENT_NAME)),
+                *[(h, component) for h in hosts[:hosts_per_component]],
+            )
+
+    # wrap it in something readable
+    @pytest.mark.parametrize('with_hc_in_upgrade', [True, False], indirect=True, ids=lambda i: f'with_hc_acl_{i}')
+    @pytest.mark.parametrize(
+        ('upload_bundles', 'set_hc'),
+        [
+            # from many hosts to 1
+            ((['+'], [1]), 2),
+            (([1, '+'], [1]), 2),
+            (([1, 2], [1]), 2),
+            (([1, 'odd'], [1]), 3),
+            # from fewer to greater
+            ((['+'], ['odd']), 2),
+            (([1, '+'], ['+']), (2, 3)),
+            (([0, 1], [1]), (0, 1)),
+            (([0, 1], [1, '+']), (0, 1)),
+            (([0, 1], ['+']), (0, 1)),
+            (([0, '+'], ['+']), (0, 2)),
+        ],
+        indirect=True,
+        ids=_set_ids_for_upload_bundles_set_hc,
+    )
+    @pytest.mark.usefixtures('set_hc', 'upload_bundles')
+    def test_incorrect_hc_without_hc_acl_in_upgrade(self, sdk_client_fs, cluster_with_component, with_hc_in_upgrade):
+        """
+        Test that when incorrect for new constraints HC is set,
+        upgrade with actions won't start
+        """
+        cluster, _ = cluster_with_component
+        hc_or_not = {'hc': build_hc_for_hc_acl_action(cluster)} if with_hc_in_upgrade else {}
+        expect_api_error(
+            'upgrade cluster with hc not suitable for new bundle version',
+            cluster.upgrade().do,
+            **hc_or_not,
+            err_=COMPONENT_CONSTRAINT_ERROR,
+            err_args_=[
+                'component "component"',
+                f'in host component list for service {self.SERVICE_DESC["name"]}',
+            ],
+        )
+        with allure.step('Check no actions were launched'):
+            assert len(sdk_client_fs.job_list()) == 0, 'At least one action has been launched. None should.'
diff --git a/tests/functional/test_upgrade_actions_data/hc_acl/config.yaml b/tests/functional/test_upgrade_actions_data/hc_acl/config.yaml
new file mode 100644
index 0000000000..89ceb60448
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/hc_acl/config.yaml
@@ -0,0 +1,120 @@
+# Licensed 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.
+
+
+- type: cluster
+  version: 1.4
+  name: test_cluster
+
+  upgrade:
+    - &upgrade
+      name: successful
+      versions:
+        min: 0.1
+        max: 1.3
+      states:
+        available: any
+        on_success: ready_to_upgrade
+      scripts:
+        - &action
+          name: first action before switch
+          script: ./succeed.yaml
+          script_type: ansible
+        - <<: *action
+          name: second action before switch
+        - &bundle_switch
+          name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+        - <<: *action
+          name: second action after switch
+      hc_acl: &hc_acl
+        - service: test_service
+          component: test_component
+          action: add
+        - service: test_service
+          component: second_component
+          action: add
+        - service: new_service
+          component: some_component
+          action: add
+
+    - <<: *upgrade
+      name: fail before switch
+      scripts:
+        - <<: *action
+        - &fail_action
+          <<: *action
+          name: second action before switch
+          script: ./fail.yaml
+        - <<: *bundle_switch
+        - <<: *action
+          name: first action after switch
+        - <<: *action
+          name: second action after switch
+
+    - <<: *upgrade
+      name: fail after switch
+      scripts:
+        - *action
+        - <<: *action
+          name: second action before switch
+        - <<: *bundle_switch
+        - <<: *action
+          name: first action after switch
+        - <<: *fail_action
+          name: second action after switch
+
+    - <<: *upgrade
+      name: fail on first action after switch
+      scripts:
+        - *action
+        - <<: *action
+          name: second action before switch
+        - <<: *bundle_switch
+        - <<: *fail_action
+          name: first action after switch
+        - <<: *action
+          name: second action after switch
+
+  config: &config
+    - name: somestring
+      type: string
+      required: false
+    - name: someint
+      type: integer
+      default: 12
+
+- type: service
+  version: 2.3
+  name: test_service
+
+  config: *config
+
+  components:
+    test_component:
+      config: *config
+
+    second_component:
+      config: *config
+
+- type: service
+  version: 2.34
+  name: new_service
+
+  config: *config
+
+  components:
+    some_component:
+      config: *config
diff --git a/tests/functional/test_upgrade_actions_data/hc_acl/fail.yaml b/tests/functional/test_upgrade_actions_data/hc_acl/fail.yaml
new file mode 100644
index 0000000000..acd9ebefb5
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/hc_acl/fail.yaml
@@ -0,0 +1,22 @@
+# Licensed 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.
+
+- name: Fail action
+  hosts: localhost
+  connection: local
+  gather_facts: no
+
+  tasks:
+    - name: fail
+      fail:
+        msg: ""
+      when: "{{ 1 == 1 }}"
diff --git a/tests/functional/test_upgrade_actions_data/hc_acl/succeed.yaml b/tests/functional/test_upgrade_actions_data/hc_acl/succeed.yaml
new file mode 100644
index 0000000000..3f4b78d31d
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/hc_acl/succeed.yaml
@@ -0,0 +1,22 @@
+# Licensed 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.
+
+- name: Succeed action
+  hosts: localhost
+  connection: local
+  gather_facts: no
+
+  tasks:
+    - name: dummy action
+      debug:
+        msg: "Some message"
+
diff --git a/tests/functional/test_upgrade_actions_data/provider/config.yaml b/tests/functional/test_upgrade_actions_data/provider/config.yaml
new file mode 100644
index 0000000000..96bc9451e5
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/provider/config.yaml
@@ -0,0 +1,19 @@
+# Licensed 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.
+
+- type: provider
+  version: 3.2
+  name: test_provider
+
+- type: host
+  version: 1.2
+  name: test_host
diff --git a/tests/functional/test_upgrade_actions_data/successful/old/config.yaml b/tests/functional/test_upgrade_actions_data/successful/old/config.yaml
index 016d4a8d0e..3100eddc0b 100644
--- a/tests/functional/test_upgrade_actions_data/successful/old/config.yaml
+++ b/tests/functional/test_upgrade_actions_data/successful/old/config.yaml
@@ -31,3 +31,17 @@
   components:
     test_component:
       config: *config
+
+- type: service
+  version: 1.2
+  name: will_be_removed
+
+  config: &cfg
+    - name: param
+      type: string
+      default: pampam
+
+  components:
+    willbegone:
+      config: *cfg
+
diff --git a/tests/functional/test_upgrade_actions_data/upgrade_failures/before_switch/config.yaml b/tests/functional/test_upgrade_actions_data/upgrade_failures/before_switch/config.yaml
index ba180b7cd5..631134ff2f 100644
--- a/tests/functional/test_upgrade_actions_data/upgrade_failures/before_switch/config.yaml
+++ b/tests/functional/test_upgrade_actions_data/upgrade_failures/before_switch/config.yaml
@@ -15,7 +15,7 @@
   name: test_cluster
 
   upgrade:
-    - name: fail_after_bundle_switch
+    - name: fail_before_bundle_switch
       versions:
         min: 0.1
         max: 1.3
diff --git a/tests/functional/test_upgrade_actions_data/validation/invalid/hc_acl_in_provider/config.yaml b/tests/functional/test_upgrade_actions_data/validation/invalid/hc_acl_in_provider/config.yaml
new file mode 100644
index 0000000000..f7b4dd769e
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/validation/invalid/hc_acl_in_provider/config.yaml
@@ -0,0 +1,33 @@
+# Licensed 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.
+
+- type: provider
+  version: 1.3
+  name: test_provider
+
+  upgrade:
+    - name: hc_acl_in_provider
+      versions:
+        min: 0.1
+        max: 1.3
+      scripts:
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+
+- type: host
+  version: 0.3
+  name: test_host
diff --git a/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_component_in_regular_action/config.yaml b/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_component_in_regular_action/config.yaml
new file mode 100644
index 0000000000..144d1d4737
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_component_in_regular_action/config.yaml
@@ -0,0 +1,32 @@
+# Licensed 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.
+
+- type: cluster
+  version: 1.3
+  name: test_cluster
+
+  actions:
+    start:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      hc_acl:
+        - service: test_service
+          component: another_component
+          action: add
+
+- type: service
+  version: 3.4
+  name: test_service
+
+  components:
+    test_component:
diff --git a/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_service_in_regular_action/config.yaml b/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_service_in_regular_action/config.yaml
new file mode 100644
index 0000000000..edc0c737eb
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/validation/invalid/non_existent_service_in_regular_action/config.yaml
@@ -0,0 +1,32 @@
+# Licensed 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.
+
+- type: cluster
+  version: 1.3
+  name: test_cluster
+
+  actions:
+    start:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      hc_acl:
+        - service: another_service
+          component: test_component
+          action: add
+
+- type: service
+  version: 3.4
+  name: test_service
+
+  components:
+    test_component:
diff --git a/tests/functional/test_upgrade_actions_data/validation/valid/hc_acl_in_cluster/config.yaml b/tests/functional/test_upgrade_actions_data/validation/valid/hc_acl_in_cluster/config.yaml
new file mode 100644
index 0000000000..f6c54248c3
--- /dev/null
+++ b/tests/functional/test_upgrade_actions_data/validation/valid/hc_acl_in_cluster/config.yaml
@@ -0,0 +1,47 @@
+# Licensed 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.
+
+- type: cluster
+  version: 1.3
+  name: test_cluster
+
+  upgrade:
+    - name: hc_acl_in_cluster
+      versions:
+        min: 0.1
+        max: 1.3
+      scripts:
+        - &action
+          name: first_action
+          script: ./upgrade_actions.yaml
+          script_type: ansible
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: action after switch
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+        - service: test_service
+          component: old_component
+          action: remove
+
+- type: service
+  name: test_service
+  version: 0.4
+
+  components:
+    test_component:
+
+    old_component:
diff --git a/tests/functional/test_upgrade_cluster.py b/tests/functional/test_upgrade_cluster.py
index 04686bd8a0..04183c9078 100644
--- a/tests/functional/test_upgrade_cluster.py
+++ b/tests/functional/test_upgrade_cluster.py
@@ -169,6 +169,7 @@ def test_upgrade_cluster_with_config_groups(sdk_client_fs):
         )
 
 
+@pytest.mark.xfail(reason="https://tracker.yandex.ru/ADCM-3033")
 def test_cannot_upgrade_with_state(sdk_client_fs: ADCMClient, old_bundle):
     """Test upgrade should not be available ant stater"""
     with allure.step('Create upgradable cluster with unsupported state'):
diff --git a/tests/functional/test_upgrade_hostprovider.py b/tests/functional/test_upgrade_hostprovider.py
index 57c756856d..3651f9b809 100644
--- a/tests/functional/test_upgrade_hostprovider.py
+++ b/tests/functional/test_upgrade_hostprovider.py
@@ -161,6 +161,7 @@ def test_change_config(sdk_client_fs: ADCMClient):
             assert host_config_before[key] == host_config_after[key]
 
 
+@pytest.mark.xfail(reason="https://tracker.yandex.ru/ADCM-3033")
 def test_cannot_upgrade_with_state(sdk_client_fs: ADCMClient):
     """Upgrade hostprovider from unsupported state"""
     with allure.step('Create hostprovider with unsupported state'):
diff --git a/tests/functional/tools.py b/tests/functional/tools.py
index ffedbe4291..71c1321eb7 100644
--- a/tests/functional/tools.py
+++ b/tests/functional/tools.py
@@ -140,7 +140,10 @@ def get_inventory_file(adcm_fs: ADCM, task_id: int) -> dict:
 
 
 def build_hc_for_hc_acl_action(
-    cluster: Cluster, add: Collection[Tuple[Component, Host]] = (), remove: Collection[Tuple[Component, Host]] = ()
+    cluster: Cluster,
+    add: Collection[Tuple[Component, Host]] = (),
+    remove: Collection[Tuple[Component, Host]] = (),
+    add_new_bundle_components: Collection[Tuple[int, Host]] = (),
 ) -> List[Dict[str, int]]:
     """
     Build a `hc` argument for a `hc_acl` action run based on cluster's hostcomponent and add/remove "directives".
@@ -151,6 +154,12 @@ def build_hc_for_hc_acl_action(
     hostcomponent.difference_update(to_remove)
     to_add = {(component.service_id, component.id, to_host.id) for component, to_host in add}
     return [
-        {'service_id': service_id, 'component_id': component_id, 'host_id': host_id}
-        for service_id, component_id, host_id in (hostcomponent | to_add)
+        *[
+            {'service_id': service_id, 'component_id': component_id, 'host_id': host_id}
+            for service_id, component_id, host_id in (hostcomponent | to_add)
+        ],
+        *[
+            {'component_prototype_id': component_proto_id, 'host_id': host.id}
+            for component_proto_id, host in add_new_bundle_components
+        ],
     ]
diff --git a/tests/functional/test_allow_to_terminate_data/actions.yaml b/tests/generic_bundles/action_termination_allowed/actions.yaml
similarity index 100%
rename from tests/functional/test_allow_to_terminate_data/actions.yaml
rename to tests/generic_bundles/action_termination_allowed/actions.yaml
diff --git a/tests/functional/test_allow_to_terminate_data/config.yaml b/tests/generic_bundles/action_termination_allowed/config.yaml
similarity index 100%
rename from tests/functional/test_allow_to_terminate_data/config.yaml
rename to tests/generic_bundles/action_termination_allowed/config.yaml
diff --git a/tests/library/assertions.py b/tests/library/assertions.py
index 9b01f97c62..fc5e0213c0 100644
--- a/tests/library/assertions.py
+++ b/tests/library/assertions.py
@@ -16,7 +16,7 @@
 from typing import Callable, Union, Collection, TypeVar, Optional
 
 import allure
-import pytest
+from adcm_client.wrappers.api import ADCMApiError
 from adcm_pytest_plugin.utils import catch_failed
 from coreapi.exceptions import ErrorMessage
 
@@ -144,19 +144,30 @@ def dicts_are_not_equal(first: dict, second: dict, message: Union[str, Callable]
     message = message if not callable(message) else message(**kwargs)
     if not message:
         message = "Two dictionaries are equal, which wasn't expected.\nCheck step attachments for more details."
+    raise AssertionError(message)
 
 
-def expect_api_error(operation_name: str, operation: Callable, *args, err_: Optional[ADCMError] = None, **kwargs):
+def expect_api_error(
+    operation_name: str,
+    operation: Callable,
+    *args,
+    err_: Optional[ADCMError] = None,
+    err_args_: Optional[list] = None,
+    **kwargs,
+):
     """
     Perform "operation" and expect it to raise an API error.
 
     If `err_` is provided, raised exception will be checked against it by calling `.equal`
     """
     with allure.step(f'Execute "{operation_name}" and expect it to raise API error "{err_}"'):
-        with pytest.raises(ErrorMessage) as e:
+        try:
             operation(*args, **kwargs)
-        if err_:
-            err_.equal(e)
+        except (ErrorMessage, ADCMApiError) as e:
+            if err_:
+                err_.equal(e, *(err_args_ or []))
+        else:
+            raise AssertionError('An API error should be raised')
 
 
 def expect_no_api_error(operation_name: str, operation: Callable, *args, **kwargs):
diff --git a/tests/library/conditional_retriever.py b/tests/library/conditional_retriever.py
new file mode 100644
index 0000000000..7fea7b94b9
--- /dev/null
+++ b/tests/library/conditional_retriever.py
@@ -0,0 +1,110 @@
+# Licensed 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.
+
+"""
+Retrieve data in more than one way in cases when source is unstable
+(e.g. data source is at different layers for the same info)
+"""
+import sys
+import traceback
+from typing import NamedTuple, Callable, Collection, Any, Dict, Optional, Type, List, Tuple, TypeVar
+
+import allure
+
+_Result = TypeVar('_Result')
+
+
+class DataSource(NamedTuple):
+    """Wrapper for a function to be called to get the data"""
+
+    getter: Callable
+    args: Collection[Any] = ()
+    kwargs: Optional[Dict[str, Any]] = None
+
+    @property
+    def name(self):
+        """Get "source" name alongside with the args kwargs (AS IS)"""
+        return f'{getattr(self.getter, "__name__", repr(self.getter))} with {self.args=}, {self.kwargs=}'
+
+    def get(self) -> _Result:
+        """Call `getter` with args and kwargs"""
+        kwargs = self.kwargs or {}
+        return self.getter(*self.args, **kwargs)
+
+
+# to auto-register silencers look into `__init_subclass__`,
+# but for now I don't see another silencer than this one
+class _ExceptionSilencer:
+    __slots__ = ('_type', '_failures')
+
+    _failures: List[Tuple[str, str]]
+
+    @property
+    def failures(self) -> Tuple[Tuple[str, str], ...]:
+        """Get failures as tuple with items like (name, traceback)"""
+        return tuple(self._failures)
+
+    def __init__(self, ex_type: Type[Exception]):
+        self._type = ex_type
+        self._failures = []
+
+    def get(self, source: DataSource) -> Tuple[_Result, bool]:
+        """Get value and success flag"""
+        try:
+            return source.get(), True
+        except self._type:
+            self._failures.append((source.name, traceback.format_exception(*sys.exc_info())))
+            return None, False
+
+
+class FromOneOf:
+    """
+    Get value from one of the given sources
+    and ignore errors that may be raised during fetching the data.
+
+    Try to use it only in cases, when fetching some data/values from UI/API for similar cases
+    can't be encapsulated easily and might need unclean workaround.
+
+    e.g.
+    there's a row in UI table fields of which may change rarely,
+    so to keep Page API clean, you might use `FromOneOf`
+    instead of providing yet another conditional flag with extra `if` in method's body.
+    """
+
+    _sources: Tuple[DataSource, ...]
+    _silencer: _ExceptionSilencer
+
+    def __init__(self, data_sources: Collection[DataSource], ignore=Exception):
+        if len(data_sources) < 2:
+            raise ValueError('There should be at least 2 data sources')
+        self._sources = tuple(data_sources)
+        self._check_ignore_value_is_correct(ignore)
+        self._silencer = _ExceptionSilencer(ignore)
+
+    def __call__(self) -> _Result:
+        for source in self._sources:
+            result, ok = self._silencer.get(source)
+            if ok:
+                return result
+        with allure.step('Attach failures'):
+            for source_name, failure in self._silencer.failures:
+                allure.attach(failure, name=f'Failure from source: {source_name}')
+        raise AssertionError('None of the sources returned valuable result')
+
+    def _check_ignore_value_is_correct(self, ignore):
+        if isinstance(ignore, type) and issubclass(ignore, BaseException):
+            return
+        if isinstance(ignore, tuple) and all(issubclass(i, BaseException) for i in ignore):
+            return
+        raise ValueError(
+            'Sorry, but only Exceptions can be ignored with `FromOneOf`.\nFeel free to create your own Silencer.'
+        )
diff --git a/tests/library/errorcodes.py b/tests/library/errorcodes.py
index 9bf5f95647..eeebadeac6 100644
--- a/tests/library/errorcodes.py
+++ b/tests/library/errorcodes.py
@@ -15,6 +15,8 @@
 from typing import List, Iterable
 
 import pytest_check as check
+from adcm_client.wrappers.api import ADCMApiError
+from coreapi.exceptions import ErrorMessage
 from pytest_check.check_methods import get_failures
 
 
@@ -27,9 +29,17 @@ class ADCMError:
     def __init__(self, title, code):
         self.title = title
         self.code = code
+        self._special_comparators = {ADCMApiError: self._compare_adcm_api_error}
 
     def equal(self, e, *args):
         """Assert error properties"""
+        for err_class, comparator in self._special_comparators.items():
+            if e.__class__ is err_class or issubclass(e.__class__, err_class):
+                comparator(e, *args)
+                return
+        self._compare_error_message(e, *args)
+
+    def _compare_error_message(self, e: ErrorMessage, *args):
         error = e.value.error if hasattr(e, 'value') else e.error
         title = error.title
         code = error.get("code", "")
@@ -38,12 +48,22 @@ def equal(self, e, *args):
         check.equal(title, self.title, f'Expected title is "{self.title}", actual is "{title}"')
         check.equal(code, self.code, f'Expected error code is "{self.code}", actual is "{code}"')
         for i in args:
+            err_msg = 'Unknown'
             check.is_true(
-                i in desc or i in error_args or i in self._get_data_err_messages(error),
-                f"Text '{i}' should be present in error message",
+                i in desc or i in error_args or i in (err_msg := self._get_data_err_messages(error)),
+                (
+                    f"Text '{i}' should be present in error message. Either in:\n"
+                    f'Description: {desc}\n'
+                    f'Error arguments: {error_args}\n'
+                    f'Or message: {err_msg}'
+                ),
             )
         assert not get_failures(), "All assertions should passed"
 
+    def _compare_adcm_api_error(self, e: ADCMApiError, *_):
+        code, *_ = e.args
+        assert self.code == code, f"Error expected to be {self.code}, not {code}"
+
     def _get_data_err_messages(self, error) -> List[str]:
         """Extract all messages from _data attribute or an error if it is presented"""
         data = getattr(error, '_data', None)
@@ -260,3 +280,15 @@ def __str__(self):
     '409 Conflict',
     'INVALID_HC_HOST_IN_MM',
 )
+
+USER_UPDATE_ERROR = ADCMError('400 Bad Request', 'USER_UPDATE_ERROR')
+
+GROUP_UPDATE_ERROR = ADCMError('400 Bad Request', 'GROUP_UPDATE_ERROR')
+
+# ADCMApiError
+AUTH_ERROR = ADCMError('400 Bad Request', 'AUTH_ERROR')
+
+COMPONENT_CONSTRAINT_ERROR = ADCMError(
+    '409 Conflict',
+    'COMPONENT_CONSTRAINT_ERROR',
+)
diff --git a/tests/library/ldap_interactions.py b/tests/library/ldap_interactions.py
new file mode 100644
index 0000000000..880cf6c86a
--- /dev/null
+++ b/tests/library/ldap_interactions.py
@@ -0,0 +1,247 @@
+# Licensed 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.
+
+"""Simple working with LDAP for tests purposes"""
+
+import uuid
+from pathlib import Path
+from typing import NamedTuple, List, Optional, Tuple
+from zlib import crc32
+
+import allure
+import ldap
+from adcm_client.objects import ADCMClient
+from adcm_pytest_plugin.custom_types import SecureString
+from ldap.ldapobject import SimpleLDAPObject
+
+from tests.library.utils import ConfigError
+
+LDAP_PREFIX = 'ldap://'
+LDAPS_PREFIX = 'ldaps://'
+
+
+class LDAPTestConfig(NamedTuple):
+    """Storage for required LDAP config"""
+
+    uri: str
+    admin_dn: str
+    admin_pass: SecureString
+    base_ou_dn: str
+    cert: Optional[SecureString] = None
+
+
+class LDAPEntityManager:
+    """Basic manipulations with LDAP for test purposes"""
+
+    conn: SimpleLDAPObject
+    test_dn: str
+
+    _config: LDAPTestConfig
+    _created_records: List[str]
+    # unique name identifier
+    # it will be added to user/group names
+    _uni: str
+
+    _BASE_OU_MODLIST = [('objectClass', [b'top', b'organizationalUnit'])]
+    _BASE_GROUP_MODLIST = [('objectClass', [b'top', b'group'])]
+    _BASE_USER_MODLIST = [('objectClass', [b'top', b'person', b'organizationalPerson', b'user'])]
+
+    _ACTIVE_USER_UAC = b'512'  # regular user
+    _INACTIVE_USER_UAC = b'514'  # user, inactive
+    _DEFAULT_USER_UAC = b'546'  # user, inactive, password not required
+
+    _ATTR_MAP = {
+        'first_name': 'givenName',
+        'last_name': 'sn',
+        'email': 'mail',
+    }
+
+    def __init__(self, config: LDAPTestConfig, test_name: str):
+        ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)  # pylint: disable=no-member
+        self.conn = ldap.initialize(config.uri)  # pylint: disable=no-member
+        self.conn.simple_bind_s(config.admin_dn, config.admin_pass)
+
+        self._created_records = []
+        self._config = config
+        self._uni = str(crc32(f'{test_name}{uuid.uuid4()}'.encode('utf-8')))
+        # for the cleanup we'll need the CN with uppercase node names, I think
+        corrected_base_ou_dn = ','.join(
+            map(
+                lambda x: ((parts := x.split('=')) and f'{parts[0].upper()}={parts[1]}'),
+                self._config.base_ou_dn.split(','),
+            )
+        )
+        self.test_dn = self.create_ou(self._uni, corrected_base_ou_dn)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.clean_test_ou()
+        self.conn.unbind()
+
+    @allure.step('Create OU {name}')
+    def create_ou(self, name: str, custom_base_dn: str = None) -> str:
+        """Create OU (use for 'isolation' in tests)"""
+        base_dn = custom_base_dn or self.test_dn
+        new_dn = f'OU={name},{base_dn}'
+        self.conn.add_s(new_dn, self._BASE_OU_MODLIST)
+        self._created_records.append(new_dn)
+        return new_dn
+
+    @allure.step('Create group {name}')
+    def create_group(self, name: str, custom_base_dn: str = None) -> str:
+        """Create group (CN) that can include users"""
+        base_dn = custom_base_dn or self.test_dn
+        new_dn = f'CN={name},{base_dn}'
+        self.conn.add_s(new_dn, self._BASE_GROUP_MODLIST)
+        self._created_records.append(new_dn)
+        return new_dn
+
+    @allure.step('Create user {name} with password {password}')
+    def create_user(
+        self,
+        name: str,
+        password: str,
+        custom_base_dn: str = None,
+        extra_modlist: Optional[List[Tuple[str, List[bytes]]]] = None,
+    ) -> str:
+        """
+        Create user (CN) and activate it.
+        `name` is used both in CN and as sAMAccountName,
+        so it should be unique not only in parent OU.
+        """
+        extra_modlist = extra_modlist or []
+        base_dn = custom_base_dn or self.test_dn
+        new_dn = f'CN={name},{base_dn}'
+        self.conn.add_s(new_dn, self._BASE_USER_MODLIST + [('sAMAccountName', name.encode('utf-8'))] + extra_modlist)
+        self._created_records.append(new_dn)
+        self.set_user_password(new_dn, password)
+        self.activate_user(new_dn)
+        return new_dn
+
+    def delete(self, dn: str) -> None:
+        """Delete record from LDAP"""
+        self.conn.delete_s(dn)
+        self._created_records.remove(dn)
+
+    @allure.step('Activate user {user_dn}')
+    def activate_user(self, user_dn: str, uac: bytes = _ACTIVE_USER_UAC) -> None:
+        """Activate user"""
+        self.conn.modify_s(user_dn, [(ldap.MOD_REPLACE, 'userAccountControl', uac)])  # pylint: disable=no-member
+
+    @allure.step('Deactivate user {user_dn}')
+    def deactivate_user(self, user_dn: str, uac: bytes = _INACTIVE_USER_UAC) -> None:
+        """Deactivate user"""
+        self.conn.modify_s(user_dn, [(ldap.MOD_REPLACE, 'userAccountControl', uac)])  # pylint: disable=no-member
+
+    @allure.step('Set password "{password}" for user {user_dn}')
+    def set_user_password(self, user_dn: str, password: str) -> None:
+        """Set password for an existing user"""
+        pass_utf16 = f'"{password}"'.encode('utf-16-le')
+        self.conn.modify_s(user_dn, [(ldap.MOD_REPLACE, 'unicodePwd', [pass_utf16])])  # pylint: disable=no-member
+
+    @allure.step('Update user in LDAP')
+    def update_user(self, user_dn: str, **fields: str):
+        """Update user record"""
+        try:
+            self.conn.modify_s(
+                user_dn, [(ldap.MOD_REPLACE, self._ATTR_MAP[k], [v.encode('utf-8')]) for k, v in fields.items()]
+            )
+        except KeyError as e:
+            unknown_fields = {k for k in fields if k in self._ATTR_MAP}
+            raise ValueError(
+                f'You can update only those fields: {", ".join(self._ATTR_MAP.keys())}\n'
+                f'Input was: {", ".join(unknown_fields)}'
+            ) from e
+
+    @allure.step('Add user {user_dn} to {group_dn}')
+    def add_user_to_group(self, user_dn: str, group_dn: str) -> None:
+        """Add user to a group"""
+        mod_group = [(ldap.MOD_ADD, 'member', [user_dn.encode('utf-8')])]  # pylint: disable=no-member
+        self.conn.modify_s(group_dn, mod_group)
+
+    @allure.step('Remove user {user_dn} from {group_dn}')
+    def remove_user_from_group(self, user_dn: str, group_dn: str) -> None:
+        """Remove user from group"""
+        mod_group = [(ldap.MOD_DELETE, 'member', [user_dn.encode('utf-8')])]  # pylint: disable=no-member
+        self.conn.modify_s(group_dn, mod_group)
+
+    @allure.step('Cleat test OU')
+    def clean_test_ou(self):
+        """Remove every object in test OU"""
+        if self.test_dn is None:
+            return
+        # not everything is deleted on first round
+        for _ in range(10):
+            entities = self._get_entities_from_test_ou()
+            if len(entities) == 0:
+                break
+            for dn in entities:  # pylint: disable=invalid-name
+                self.conn.delete(dn)
+        else:
+            raise RuntimeError(f'Not all entities in test OU were deleted: {entities}')
+        self._created_records = []
+        self.test_dn = None
+
+    def _get_entities_from_test_ou(self):
+        try:
+            return sorted(
+                (dn for dn, _ in self.conn.search_s(self.test_dn, ldap.SCOPE_SUBTREE)),  # pylint: disable=no-member
+                key=len,
+                reverse=True,
+            )
+        except ldap.NO_SUCH_OBJECT:  # pylint: disable=no-member
+            return []
+
+
+# pylint: disable-next=too-many-arguments
+def configure_adcm_for_ldap(
+    client: ADCMClient,
+    config: LDAPTestConfig,
+    ssl_on: bool,
+    ssl_cert: Optional[Path],
+    user_base: str,
+    group_base: str,
+    extra_config: Optional[dict] = None,
+):
+    """Set ADCM settings to work with LDAP (for tests only)"""
+    extra_config = extra_config or {}
+    ssl_extra_config = {}
+    uri = config.uri
+    # we suggest that configuration is right
+    if ssl_on:
+        if config.uri.startswith(LDAP_PREFIX):
+            uri = uri.replace(LDAP_PREFIX, LDAPS_PREFIX)
+        if ssl_cert is None:
+            raise ConfigError('AD SSL cert should be uploaded to ADCM')
+        ssl_extra_config['tls_ca_cert_file'] = str(ssl_cert)
+    elif not ssl_on and config.uri.startswith(LDAPS_PREFIX):
+        uri = uri.replace(LDAPS_PREFIX, LDAP_PREFIX)
+
+    adcm = client.adcm()
+    adcm.config_set_diff(
+        {
+            'attr': {'ldap_integration': {'active': True}},
+            'config': {
+                'ldap_integration': {
+                    'ldap_uri': uri,
+                    'ldap_user': config.admin_dn,
+                    'ldap_password': config.admin_pass,
+                    'user_search_base': user_base,
+                    'group_search_base': group_base,
+                    **ssl_extra_config,
+                    **extra_config,
+                }
+            },
+        }
+    )
diff --git a/tests/library/utils.py b/tests/library/utils.py
index 9455350479..b3ff93c4c9 100644
--- a/tests/library/utils.py
+++ b/tests/library/utils.py
@@ -22,6 +22,10 @@
 from adcm_pytest_plugin.plugin import parametrized_by_adcm_version
 
 
+class ConfigError(Exception):
+    """Tests are configured incorrectly"""
+
+
 class RequestFailedException(Exception):
     """Request to ADCM API has status code >= 400"""
 
diff --git a/tests/ui_tests/app/page/admin/locators.py b/tests/ui_tests/app/page/admin/locators.py
index ab0f350456..7f007475f4 100644
--- a/tests/ui_tests/app/page/admin/locators.py
+++ b/tests/ui_tests/app/page/admin/locators.py
@@ -75,9 +75,13 @@ class AddUserPopup(CommonAdminPagesLocators):
         password_confirm = Locator(
             By.CSS_SELECTOR, "input[data-placeholder='Confirm password']", "New user password confirmation"
         )
+        adcm_admin_chbx = Locator(
+            By.CSS_SELECTOR, "mat-checkbox[formcontrolname='is_superuser']", "Checkbox ADCM Administrator"
+        )
         first_name = Locator(By.NAME, "first_name", "New user first name")
         last_name = Locator(By.NAME, "last_name", "New user last name")
         email = Locator(By.NAME, "email", "New user email")
+        select_groups = Locator(By.CSS_SELECTOR, "adwp-input-select[controlname='group']", "Select groups")
 
 
 class AdminGroupsLocators(CommonAdminPagesLocators):
@@ -95,7 +99,7 @@ class AddGroupPopup:
         """Locators for creating groups popup"""
 
         block = Locator(By.CSS_SELECTOR, "app-rbac-group-form", "Add group popup block")
-
+        title = Locator(By.CSS_SELECTOR, "mat-dialog-container h3", "Popup title")
         name_input = Locator(By.CSS_SELECTOR, "adwp-input[label='Group name'] input", "Input name")
         description_input = Locator(By.CSS_SELECTOR, "adwp-input[label='Description'] input", "Input description")
         users_select = Locator(By.CSS_SELECTOR, "adwp-input-select[label='Select users'] adwp-select", "select users")
diff --git a/tests/ui_tests/app/page/admin/page.py b/tests/ui_tests/app/page/admin/page.py
index 0aea4274e6..ad1b7b0c7c 100644
--- a/tests/ui_tests/app/page/admin/page.py
+++ b/tests/ui_tests/app/page/admin/page.py
@@ -20,6 +20,9 @@
 
 import allure
 from adcm_pytest_plugin.utils import wait_until_step_succeeds
+from selenium.common.exceptions import (
+    StaleElementReferenceException,
+)
 from selenium.common.exceptions import TimeoutException
 from selenium.webdriver.remote.webelement import WebElement
 
@@ -280,6 +283,32 @@ def change_user_password(self, username: str, password: str):
         self.find_and_click(AdminUsersLocators.AddUserPopup.save_btn)
         self.wait_element_hide(AdminUsersLocators.AddUserPopup.block)
 
+    @allure.step('Check that changing ldap user is prohibited')
+    def check_ldap_user(self, username: str):
+        """Check that changing ldap user is prohibited"""
+
+        def is_disabled(locators: [Locator]):
+            for loc in locators:
+                assert self.find_element(loc).get_attribute("disabled") == 'true', "Ldap user fields should be disabled"
+
+        user_row = self.get_user_row_by_username(username)
+        self.find_child(user_row, AdminUsersLocators.Row.username).click()
+        self.wait_element_visible(AdminUsersLocators.AddUserPopup.block)
+        is_disabled(
+            [
+                AdminUsersLocators.AddUserPopup.username,
+                AdminUsersLocators.AddUserPopup.password,
+                AdminUsersLocators.AddUserPopup.password_confirm,
+                AdminUsersLocators.AddUserPopup.first_name,
+                AdminUsersLocators.AddUserPopup.last_name,
+                AdminUsersLocators.AddUserPopup.email,
+                AdminUsersLocators.AddUserPopup.save_btn,
+            ]
+        )
+        assert (
+            self.find_element(AdminUsersLocators.AddUserPopup.select_groups).get_attribute("disabled") is None
+        ), "Ldap user group should not be disabled"
+
     @allure.step('Delete user {username}')
     def delete_user(self, username: str):
         """Delete existing user"""
@@ -332,6 +361,30 @@ def create_custom_group(self, name: str, description: Optional[str], users: Opti
         self.find_and_click(AdminGroupsLocators.save_btn)
         self.wait_element_hide(AdminGroupsLocators.AddGroupPopup.block)
 
+    @allure.step('Update group {name}')
+    def update_group(
+        self, name: str, new_name: Optional[str] = None, description: Optional[str] = None, users: Optional[str] = None
+    ):
+        self.get_group_by_name(name).click()
+        if new_name:
+            self.send_text_to_element(AdminGroupsLocators.AddGroupPopup.name_input, description, clean_input=True)
+        if description:
+            self.send_text_to_element(
+                AdminGroupsLocators.AddGroupPopup.description_input, description, clean_input=True
+            )
+        if users:
+            self.find_and_click(AdminGroupsLocators.AddGroupPopup.users_select)
+            self.wait_element_visible(AdminGroupsLocators.item)
+            for user in users.split(", "):
+                for user_item in self.find_elements(AdminGroupsLocators.item):
+                    if user_item.text == user:
+                        user_chbx = self.find_child(user_item, AdminGroupsLocators.AddGroupPopup.UserRow.checkbox)
+                        self.hover_element(user_chbx)
+                        user_chbx.click()
+            self.find_and_click(AdminGroupsLocators.AddGroupPopup.users_select)
+        self.find_and_click(AdminGroupsLocators.save_btn)
+        self.wait_element_hide(AdminGroupsLocators.AddGroupPopup.block)
+
     def get_all_groups(self) -> [AdminGroupInfo]:
         """Get all groups info."""
 
@@ -355,6 +408,39 @@ def click_delete_button(self):
         self.find_and_click(DeleteDialog.yes)
         self.wait_element_hide(DeleteDialog.body)
 
+    @allure.step('Get group {group_name}')
+    def get_group_by_name(self, group_name: str):
+        """Get group by name"""
+        for group in self.table.get_all_rows():
+            if group_name in group.text:
+                return group
+        raise AssertionError(f'Group {group_name} was not found')
+
+    @allure.step('Check that changing ldap group is prohibited')
+    def check_ldap_group(self, group_name: str):
+        """Check that changing ldap group is prohibited"""
+
+        def is_disabled(locators: [Locator]):
+            for loc in locators:
+                assert (
+                    self.find_element(loc).get_attribute("disabled") == 'true'
+                ), "Ldap group fields should be disabled"
+
+        group_row = self.get_group_by_name(group_name)
+        self.find_child(group_row, AdminGroupsLocators.GroupRow.name).click()
+        self.wait_element_visible(AdminGroupsLocators.AddGroupPopup.block)
+        is_disabled(
+            [
+                AdminGroupsLocators.AddGroupPopup.name_input,
+                AdminGroupsLocators.AddGroupPopup.description_input,
+                AdminGroupsLocators.save_btn,
+            ]
+        )
+        assert "disabled" in self.find_element(AdminGroupsLocators.AddGroupPopup.users_select).get_attribute(
+            "class"
+        ), "Select users should be disabled"
+        assert self.find_element(AdminGroupsLocators.AddGroupPopup.title).text == "Group Info", "Wrong title in popup"
+
 
 class AdminRolesPage(GeneralAdminPage):
     """Admin Roles Page class"""
@@ -567,9 +653,14 @@ def fill_select_in_policy_popup(self, items, available_items_locator):
         for item in items.split(", "):
             self.wait_element_visible(available_items_locator)
             for count, available_item in enumerate(self.find_elements(available_items_locator)):
-                if available_item.text == item:
-                    available_item.click()
-                    break
+                try:
+                    if available_item.text == item:
+                        available_item.click()
+                        break
+                except StaleElementReferenceException:
+                    if self.find_elements(available_items_locator)[count].text == item:
+                        self.find_elements(available_items_locator)[count].click()
+                        break
             else:
                 raise AssertionError(f"There are no item {item} in select popup")
 
diff --git a/tests/ui_tests/app/page/cluster/locators.py b/tests/ui_tests/app/page/cluster/locators.py
index 49e0cf4d48..c4e814183c 100644
--- a/tests/ui_tests/app/page/cluster/locators.py
+++ b/tests/ui_tests/app/page/cluster/locators.py
@@ -17,6 +17,7 @@
 from tests.ui_tests.app.helpers.locator import (
     Locator,
 )
+from tests.ui_tests.app.page.common.host_components.locators import HostComponentsLocators
 from tests.ui_tests.app.page.host_list.locators import HostListLocators
 
 
@@ -69,38 +70,10 @@ class HostTable(HostListLocators.HostTable):
         ...
 
 
-class ClusterComponentsLocators:
+class ClusterComponentsLocators(HostComponentsLocators):
     """Cluster components page elements locators"""
 
-    restore_btn = Locator(By.XPATH, "//button[./span[contains(text(), 'Restore')]]", "Restore button")
-    save_btn = Locator(By.XPATH, "//button[./span[contains(text(), 'Save')]]", "Save button")
-
-    components_title = Locator(By.XPATH, "//h3[./span[contains(text(), 'Components')]]", "Title for Components block")
-    service_page_link = Locator(By.CSS_SELECTOR, "mat-card-content a[href*='service']", "Link to service page")
-
-    hosts_title = Locator(By.XPATH, "//h3[./span[contains(text(), 'Hosts')]]", "Title for Hosts block")
-    hosts_page_link = Locator(By.CSS_SELECTOR, "mat-card-content a[href*='host']", "Link to hosts page")
-    create_hosts_btn = Locator(By.CSS_SELECTOR, "button[adcm_test='create-btn']", "Create hosts button")
-
-    host_row = Locator(By.XPATH, "//div[./h3/span[contains(text(), 'Host')]]//app-much-2-many", "Host row")
-    component_row = Locator(
-        By.XPATH,
-        "//div[./h3/span[contains(text(), 'Components')]]//app-much-2-many",
-        "Component row",
-    )
-
-    class Row:
-        """Cluster components page row elements locators"""
-
-        name = Locator(By.XPATH, ".//button[@mat-button]/span/span[not(contains(@class, 'warn'))]", "Item name")
-        number = Locator(By.CSS_SELECTOR, "button[mat-raised-button] span:first-of-type", "Amount of links")
-        relations_row = Locator(By.CSS_SELECTOR, "div[class*='relations-list']", "Row with relations")
-
-        class RelationsRow:
-            """Cluster components page relations row elements locators"""
-
-            name = Locator(By.CSS_SELECTOR, "div>span", "Related item name")
-            delete_btn = Locator(By.CSS_SELECTOR, "button", "Delete item button")
+    pass
 
 
 class ClusterActionLocators:
diff --git a/tests/ui_tests/app/page/cluster/page.py b/tests/ui_tests/app/page/cluster/page.py
index a660bf71ec..c046f1557c 100644
--- a/tests/ui_tests/app/page/cluster/page.py
+++ b/tests/ui_tests/app/page/cluster/page.py
@@ -36,6 +36,7 @@
 from tests.ui_tests.app.page.common.group_config.page import CommonGroupConfigMenu
 from tests.ui_tests.app.page.common.group_config_list.locators import GroupConfigListLocators
 from tests.ui_tests.app.page.common.group_config_list.page import GroupConfigList
+from tests.ui_tests.app.page.common.host_components.page import HostComponentsPage
 from tests.ui_tests.app.page.common.import_page.locators import ImportLocators
 from tests.ui_tests.app.page.common.import_page.page import ImportPage
 from tests.ui_tests.app.page.common.popups.locator import HostAddPopupLocators
@@ -49,14 +50,6 @@
 from tests.ui_tests.app.page.host_list.page import HostRowInfo
 
 
-@dataclass
-class ComponentsHostRowInfo:
-    """Information from host row about host on Components page"""
-
-    name: str
-    components: str
-
-
 @dataclass
 class StatusGroupInfo:
     """Information from group on Status page"""
@@ -493,7 +486,7 @@ def _check_mm_state(page: ClusterHostPage, row: WebElement):
         wait_until_step_succeeds(_check_mm_state, timeout=4, period=0.5, page=self, row=host_row)
 
 
-class ClusterComponentsPage(ClusterPageMixin):
+class ClusterComponentsPage(ClusterPageMixin, HostComponentsPage):
     """Cluster page components menu"""
 
     MENU_SUFFIX = 'host_component'
@@ -508,90 +501,6 @@ class ClusterComponentsPage(ClusterPageMixin):
         ClusterComponentsLocators.hosts_page_link,
     ]
 
-    def click_service_page_link(self):
-        """Click on Service page link"""
-        self.find_and_click(ClusterComponentsLocators.service_page_link)
-
-    def click_hosts_page_link(self):
-        """Click on Hosts page link"""
-        self.find_and_click(ClusterComponentsLocators.hosts_page_link)
-
-    def click_add_host_btn(self):
-        """Click on Add Host button"""
-        self.find_and_click(ClusterComponentsLocators.create_hosts_btn)
-        self.wait_element_visible(HostCreationLocators.block)
-
-    def get_host_rows(self):
-        """Get all hosts rows"""
-        return self.find_elements(ClusterComponentsLocators.host_row)
-
-    def get_components_rows(self):
-        """Get all components rows"""
-        return self.find_elements(ClusterComponentsLocators.component_row)
-
-    def get_row_info(self, row: WebElement):
-        """Get components row info"""
-        return ComponentsHostRowInfo(
-            name=self.find_child(row, ClusterComponentsLocators.Row.name).text,
-            components=self.find_child(row, ClusterComponentsLocators.Row.number).text,
-        )
-
-    def find_host_row_by_name(self, host_name: str):
-        """Find Host row by name"""
-        for host_row in self.get_host_rows():
-            host_name_element = self.find_child(host_row, ClusterComponentsLocators.Row.name)
-            if host_name_element.text == host_name:
-                return host_row
-        raise AssertionError(f"There are no host with name '{host_name}'")
-
-    def find_component_row_by_name(self, component_name: str):
-        """Find Component row by name"""
-        for component_row in self.get_components_rows():
-            component_name_element = self.find_child(component_row, ClusterComponentsLocators.Row.name)
-            if component_name_element.text == component_name:
-                return component_row
-        raise AssertionError(f"There are no component with name '{component_name}'")
-
-    @allure.step("Click on host row")
-    def click_host(self, host_row: WebElement):
-        """Click on Host row"""
-        self.find_child(host_row, ClusterComponentsLocators.Row.name).click()
-
-    @allure.step("Click on component row")
-    def click_component(self, component_row: WebElement):
-        """Click on Component row"""
-        self.find_child(component_row, ClusterComponentsLocators.Row.name).click()
-
-    @allure.step("Click on row number in component row")
-    def click_number_in_component(self, component_row: WebElement):
-        """Click on Component row number"""
-        self.find_child(component_row, ClusterComponentsLocators.Row.number).click()
-
-    @allure.step("Click on save button")
-    def click_save_btn(self):
-        """Click on Save button"""
-        self.find_and_click(ClusterComponentsLocators.save_btn)
-
-    @allure.step("Click on restore button")
-    def click_restore_btn(self):
-        """Click on Restore button"""
-        self.find_and_click(ClusterComponentsLocators.restore_btn)
-
-    @allure.step("Delete item {item_name} from row")
-    def delete_related_item_in_row_by_name(self, row: WebElement, item_name: str):
-        """Delete related item by button from row"""
-        self.wait_element_visible(ClusterComponentsLocators.Row.relations_row)
-        for item_row in self.find_children(row, ClusterComponentsLocators.Row.relations_row):
-            item_name_element = self.find_child(item_row, ClusterComponentsLocators.Row.RelationsRow.name)
-            if item_name_element.text == item_name:
-                self.find_child(item_row, ClusterComponentsLocators.Row.RelationsRow.delete_btn).click()
-                return
-        raise AssertionError(f"There are no item with name '{item_name}'")
-
-    def check_that_save_btn_disabled(self):
-        """Get Save button available state"""
-        return self.find_element(ClusterComponentsLocators.save_btn).get_attribute("disabled") == "true"
-
 
 class ClusterStatusPage(ClusterPageMixin, StatusPage):
     """Cluster page status menu"""
diff --git a/tests/ui_tests/app/page/cluster_list/page.py b/tests/ui_tests/app/page/cluster_list/page.py
index 7e56cee2d6..678d500686 100644
--- a/tests/ui_tests/app/page/cluster_list/page.py
+++ b/tests/ui_tests/app/page/cluster_list/page.py
@@ -11,25 +11,35 @@
 # limitations under the License.
 
 """Cluster List page PageObjects classes"""
-
 from contextlib import contextmanager
+from typing import Optional
 
 import allure
 from adcm_pytest_plugin.utils import wait_until_step_succeeds
 from selenium.common.exceptions import TimeoutException
 from selenium.webdriver.remote.webdriver import WebElement
 
+from tests.ui_tests.app.page.cluster.locators import (
+    ClusterComponentsLocators,
+)
 from tests.ui_tests.app.page.cluster_list.locators import ClusterListLocators
 from tests.ui_tests.app.page.common.base_page import (
     BasePageObject,
     PageHeader,
     PageFooter,
 )
+from tests.ui_tests.app.page.common.configuration.locators import CommonConfigMenu
+from tests.ui_tests.app.page.common.configuration.page import CommonConfigMenuObj
 from tests.ui_tests.app.page.common.dialogs_locators import (
     ActionDialog,
     DeleteDialog,
 )
+from tests.ui_tests.app.page.common.host_components.page import HostComponentsPage
+from tests.ui_tests.app.page.common.popups.locator import (
+    HostCreationLocators,
+)
 from tests.ui_tests.app.page.common.popups.locator import ListConcernPopupLocators
+from tests.ui_tests.app.page.common.popups.page import HostCreatePopupObj
 from tests.ui_tests.app.page.common.table.page import CommonTableObj
 
 
@@ -40,7 +50,9 @@ def __init__(self, driver, base_url):
         super().__init__(driver, base_url, "/cluster")
         self.header = PageHeader(self.driver, self.base_url)
         self.footer = PageFooter(self.driver, self.base_url)
+        self.config = CommonConfigMenuObj(self.driver, self.base_url)
         self.table = CommonTableObj(self.driver, self.base_url, ClusterListLocators.ClusterTable)
+        self.host_popup = HostCreatePopupObj(self.driver, self.base_url)
 
     @allure.step("Create cluster")
     def create_cluster(self, bundle: str, description: str = None, is_license: bool = False):
@@ -160,13 +172,56 @@ def delete_cluster_by_row(self, row: WebElement):
         self.wait_element_hide(DeleteDialog.body)
 
     @allure.step("Run upgrade {upgrade_name} for cluster from row")
-    def run_upgrade_in_cluster_row(self, row: WebElement, upgrade_name: str):
-        """Run upgrade for cluster from row"""
+    def run_upgrade_in_cluster_row(
+        self,
+        row: WebElement,
+        upgrade_name: str,
+        config: Optional[dict] = None,
+        hc_acl: bool = False,
+        is_new_host: bool = False,
+        disclaimer_text: Optional[str] = None,
+    ):
+        """
+        Run upgrade for cluster from row
+
+        :param row: row with upgrade
+        :param upgrade_name: upgrade action name
+        :param config: config options
+        :param hc_acl: hc_acl options
+        :param is_new_host: condition if new host is needed, new host will be created and added in upgrade popup.
+                            only with hc_acl option
+        :param disclaimer_text: disclaimer text in first popup
+        """
 
         self.find_child(row, self.table.locators.ClusterRow.upgrade).click()
         self.wait_element_visible(self.table.locators.UpgradePopup.block)
         self.find_and_click(self.table.locators.UpgradePopup.button(upgrade_name))
         self.wait_element_visible(ActionDialog.body)
+        if disclaimer_text:
+            assert self.find_element(ActionDialog.text).text == disclaimer_text, "Different text in disclaimer dialog"
+            self.find_and_click(ActionDialog.run)
+            self.wait_element_visible(ActionDialog.body)
+        if config:
+            for key, value in config.items():
+                self.wait_element_visible(CommonConfigMenu.config_row)
+                self.config.type_in_field_with_few_inputs(
+                    row=self.config.get_config_row(display_name=key), values=[value]
+                )
+            if self.is_element_displayed(ActionDialog.next_btn):
+                self.find_and_click(ActionDialog.next_btn)
+        if hc_acl:
+            if is_new_host:
+                components_page = HostComponentsPage(self.driver, self.base_url)
+                components_page.click_add_host_btn()
+                self.host_popup.create_host("Test_host")
+                self.wait_element_hide(HostCreationLocators.block)
+            for comp_row in self.find_elements(ClusterComponentsLocators.component_row):
+                comp_row_name = self.find_child(comp_row, ClusterComponentsLocators.Row.name)
+                self.wait_element_visible(comp_row_name)
+                comp_row_name.click()
+            self.find_child(
+                self.find_elements(ClusterComponentsLocators.host_row)[0], ClusterComponentsLocators.Row.name
+            ).click()
         self.find_and_click(ActionDialog.run)
         self.wait_element_hide(ActionDialog.body)
 
diff --git a/tests/ui_tests/app/page/common/base_page.py b/tests/ui_tests/app/page/common/base_page.py
index 481ecee422..c8355c1039 100644
--- a/tests/ui_tests/app/page/common/base_page.py
+++ b/tests/ui_tests/app/page/common/base_page.py
@@ -328,7 +328,7 @@ def _assert_page_is_opened():
         page_name = self.__class__.__name__.replace('Page', '')
         with allure.step(f'Wait page {page_name} is opened'):
             wait_until_step_succeeds(_assert_page_is_opened, period=0.5, timeout=timeout)
-            self.wait_element_hide(CommonToolbarLocators.progress_bar)
+            self.wait_element_hide(CommonToolbarLocators.progress_bar, timeout=60)
 
     @allure.step('Write text to input element: "{text}"')
     def send_text_to_element(
@@ -350,6 +350,7 @@ def _send_keys_and_check():
             if clean_input:
                 self.clear_by_keys(element)
             input_element = self.find_element(element, timeout) if isinstance(element, Locator) else element
+            input_element.click()
             input_element.send_keys(text)
             assert (actual_value := input_element.get_property('value')) == text, (
                 f'Value of input {element.name if isinstance(element, Locator) else element.text} '
@@ -590,6 +591,16 @@ def _wait_job():
                 int(self.get_success_job_amount_from_header()) == expected_job_amount
             ), f"Should be {expected_job_amount} tasks in popup header"
 
+        wait_until_step_succeeds(_wait_job, period=1, timeout=90)
+
+    def wait_in_progress_job_amount_from_header(self, expected_job_amount: int):
+        """Wait for in progress job amount to be as expected"""
+
+        def _wait_job():
+            assert (
+                int(self.get_in_progress_job_amount_from_header()) == expected_job_amount
+            ), f"Should be {expected_job_amount} tasks in popup header"
+
         wait_until_step_succeeds(_wait_job, period=1, timeout=70)
 
     @allure.step('Open profile using account popup in header')
diff --git a/tests/ui_tests/app/page/common/configuration/page.py b/tests/ui_tests/app/page/common/configuration/page.py
index 8b8d8e4dff..6b401364bf 100644
--- a/tests/ui_tests/app/page/common/configuration/page.py
+++ b/tests/ui_tests/app/page/common/configuration/page.py
@@ -27,12 +27,10 @@
 from selenium.webdriver.remote.webdriver import WebElement
 
 from tests.ui_tests.app.page.common.base_page import BasePageObject
-from tests.ui_tests.app.page.common.common_locators import (
-    ObjectPageLocators,
-)
 from tests.ui_tests.app.page.common.common_locators import (
     ObjectPageMenuLocators,
     CommonLocators,
+    ObjectPageLocators,
 )
 from tests.ui_tests.app.page.common.configuration.fields import ConfigFieldsManipulator
 from tests.ui_tests.app.page.common.configuration.locators import CommonConfigMenu
@@ -98,6 +96,7 @@ def get_textbox_rows(self, timeout=2) -> List[WebElement]:
     def save_config(self, load_timeout: int = 5):
         """Save current configuration"""
 
+        self.find_and_click(self.locators.search_input)
         self.find_and_click(self.locators.save_btn)
         self.wait_element_hide(self.locators.loading_text, timeout=load_timeout)
 
@@ -289,6 +288,7 @@ def type_in_field_with_few_inputs(self, row: Union[WebElement, str], values: [st
                 field = self.find_children(field_row, self.locators.ConfigRow.input)[id]
             if clear:
                 field.clear()
+            self.find_children(field_row, self.locators.ConfigRow.input)[id].click()
             self.find_children(field_row, self.locators.ConfigRow.input)[id].send_keys(value)
 
     @allure.step('Select option "{option}" in option field')
@@ -409,10 +409,12 @@ def check_invalid_value_message(self, error_message: str):
         self.check_element_should_be_visible(self.locators.field_error(error_message))
 
     def is_save_btn_disabled(self):
+        self.find_and_click(self.locators.search_input)
         return self.find_element(self.locators.save_btn).get_attribute("disabled") == 'true'
 
     @allure.step("Check save button status")
     def check_save_btn_state_and_save_conf(self, expected_state: bool):
+        self.find_and_click(self.locators.search_input)
         assert (
             not (self.is_save_btn_disabled()) == expected_state
         ), f'Save button should{" not " if expected_state is True else " "}be disabled'
diff --git a/tests/ui_tests/app/page/common/dialogs_locators.py b/tests/ui_tests/app/page/common/dialogs_locators.py
index 0a3e859fbf..aa4cf2827b 100644
--- a/tests/ui_tests/app/page/common/dialogs_locators.py
+++ b/tests/ui_tests/app/page/common/dialogs_locators.py
@@ -28,4 +28,6 @@ class ActionDialog:
     """Action Dialog class"""
 
     body = Locator(By.CSS_SELECTOR, "mat-dialog-container", "Dialog with choices")
+    text = Locator(By.CSS_SELECTOR, "app-dialog mat-dialog-content", "Dialog content")
+    next_btn = Locator(By.CSS_SELECTOR, ".mat-stepper-next", "Next button in action dialog")
     run = Locator(By.CSS_SELECTOR, "app-dialog button[color='accent']", "Run button in action dialog")
diff --git a/tests/ui_tests/app/page/common/host_components/locators.py b/tests/ui_tests/app/page/common/host_components/locators.py
new file mode 100644
index 0000000000..3dd339b6d8
--- /dev/null
+++ b/tests/ui_tests/app/page/common/host_components/locators.py
@@ -0,0 +1,53 @@
+# Licensed 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.
+
+"""Cluster List page locators"""
+
+from selenium.webdriver.common.by import By
+
+from tests.ui_tests.app.helpers.locator import Locator
+
+
+class HostComponentsLocators:
+    """Common host components page locators"""
+
+    restore_btn = Locator(By.XPATH, "//button[./span[contains(text(), 'Restore')]]", "Restore button")
+    save_btn = Locator(By.XPATH, "//button[./span[contains(text(), 'Save')]]", "Save button")
+
+    components_title = Locator(By.XPATH, "//h3[./span[contains(text(), 'Components')]]", "Title for Components block")
+    service_page_link = Locator(By.CSS_SELECTOR, "mat-card-content a[href*='service']", "Link to service page")
+
+    hosts_title = Locator(By.XPATH, "//h3[./span[contains(text(), 'Hosts')]]", "Title for Hosts block")
+    hosts_page_link = Locator(By.CSS_SELECTOR, "mat-card-content a[href*='host']", "Link to hosts page")
+    create_hosts_btn = Locator(
+        By.CSS_SELECTOR, "app-service-host button[adcm_test='create-btn']", "Create hosts button"
+    )
+
+    host_row = Locator(By.XPATH, "//div[./h3/span[contains(text(), 'Host')]]//app-much-2-many", "Host row")
+    component_row = Locator(
+        By.XPATH,
+        "//div[./h3/span[contains(text(), 'Components')]]//app-much-2-many",
+        "Component row",
+    )
+
+    class Row:
+        """Components page row elements locators"""
+
+        name = Locator(By.XPATH, ".//button[@mat-button]/span/span[not(contains(@class, 'warn'))]", "Item name")
+        number = Locator(By.CSS_SELECTOR, "button[mat-raised-button] span:first-of-type", "Amount of links")
+        relations_row = Locator(By.CSS_SELECTOR, "div[class*='relations-list']", "Row with relations")
+
+        class RelationsRow:
+            """Components page relations row elements locators"""
+
+            name = Locator(By.CSS_SELECTOR, "div>span", "Related item name")
+            delete_btn = Locator(By.CSS_SELECTOR, "button", "Delete item button")
diff --git a/tests/ui_tests/app/page/common/host_components/page.py b/tests/ui_tests/app/page/common/host_components/page.py
new file mode 100644
index 0000000000..2f78c5c146
--- /dev/null
+++ b/tests/ui_tests/app/page/common/host_components/page.py
@@ -0,0 +1,120 @@
+# Licensed 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.
+
+"""Common Host Component classes"""
+
+from dataclasses import dataclass
+
+import allure
+from selenium.webdriver.remote.webdriver import WebElement
+
+from tests.ui_tests.app.page.common.base_page import (
+    BasePageObject,
+)
+from tests.ui_tests.app.page.common.host_components.locators import HostComponentsLocators
+from tests.ui_tests.app.page.common.popups.locator import HostCreationLocators
+
+
+@dataclass
+class ComponentsHostRowInfo:
+    """Information from host row about host on Components page"""
+
+    name: str
+    components: str
+
+
+class HostComponentsPage(BasePageObject):
+    """HostComponentsPage page components menu"""
+
+    def click_service_page_link(self):
+        """Click on Service page link"""
+        self.find_and_click(HostComponentsLocators.service_page_link)
+
+    def click_hosts_page_link(self):
+        """Click on Hosts page link"""
+        self.find_and_click(HostComponentsLocators.hosts_page_link)
+
+    def click_add_host_btn(self):
+        """Click on Add Host button"""
+        self.find_and_click(HostComponentsLocators.create_hosts_btn)
+        self.wait_element_visible(HostCreationLocators.block)
+
+    def get_host_rows(self):
+        """Get all hosts rows"""
+        return self.find_elements(HostComponentsLocators.host_row)
+
+    def get_components_rows(self):
+        """Get all components rows"""
+        return self.find_elements(HostComponentsLocators.component_row)
+
+    def get_row_info(self, row: WebElement):
+        """Get components row info"""
+        return ComponentsHostRowInfo(
+            name=self.find_child(row, HostComponentsLocators.Row.name).text,
+            components=self.find_child(row, HostComponentsLocators.Row.number).text,
+        )
+
+    def find_host_row_by_name(self, host_name: str):
+        """Find Host row by name"""
+        for host_row in self.get_host_rows():
+            host_name_element = self.find_child(host_row, HostComponentsLocators.Row.name)
+            if host_name_element.text == host_name:
+                return host_row
+        raise AssertionError(f"There are no host with name '{host_name}'")
+
+    def find_component_row_by_name(self, component_name: str):
+        """Find Component row by name"""
+        for component_row in self.get_components_rows():
+            component_name_element = self.find_child(component_row, HostComponentsLocators.Row.name)
+            if component_name_element.text == component_name:
+                return component_row
+        raise AssertionError(f"There are no component with name '{component_name}'")
+
+    @allure.step("Click on host row")
+    def click_host(self, host_row: WebElement):
+        """Click on Host row"""
+        self.find_child(host_row, HostComponentsLocators.Row.name).click()
+
+    @allure.step("Click on component row")
+    def click_component(self, component_row: WebElement):
+        """Click on Component row"""
+        self.find_child(component_row, HostComponentsLocators.Row.name).click()
+
+    @allure.step("Click on row number in component row")
+    def click_number_in_component(self, component_row: WebElement):
+        """Click on Component row number"""
+        self.find_child(component_row, HostComponentsLocators.Row.number).click()
+
+    @allure.step("Click on save button")
+    def click_save_btn(self):
+        """Click on Save button"""
+        self.find_and_click(HostComponentsLocators.save_btn)
+
+    @allure.step("Click on restore button")
+    def click_restore_btn(self):
+        """Click on Restore button"""
+        self.find_and_click(HostComponentsLocators.restore_btn)
+
+    @allure.step("Delete item {item_name} from row")
+    def delete_related_item_in_row_by_name(self, row: WebElement, item_name: str):
+        """Delete related item by button from row"""
+        self.wait_element_visible(HostComponentsLocators.Row.relations_row)
+        for item_row in self.find_children(row, HostComponentsLocators.Row.relations_row):
+            item_name_element = self.find_child(item_row, HostComponentsLocators.Row.RelationsRow.name)
+            if item_name_element.text == item_name:
+                self.find_child(item_row, HostComponentsLocators.Row.RelationsRow.delete_btn).click()
+                return
+        raise AssertionError(f"There are no item with name '{item_name}'")
+
+    def check_that_save_btn_disabled(self):
+        """Get Save button available state"""
+        return self.find_element(HostComponentsLocators.save_btn).get_attribute("disabled") == "true"
diff --git a/tests/ui_tests/app/page/common/popups/locator.py b/tests/ui_tests/app/page/common/popups/locator.py
index 2e42163bf3..7d0e1052ae 100644
--- a/tests/ui_tests/app/page/common/popups/locator.py
+++ b/tests/ui_tests/app/page/common/popups/locator.py
@@ -30,6 +30,7 @@ class CommonPopupLocators:
     hide_btn_by_text = TemplateLocator(
         By.XPATH, "//div[./div/div[text()='{}']]/button", "Hide pop up button with text {}"
     )
+    verbose_chbx = Locator(By.XPATH, "//mat-checkbox[.//span[text()='Verbose']]", "Verbose checkbox")
 
 
 class HostCreationLocators:
@@ -37,8 +38,8 @@ class HostCreationLocators:
 
     block = Locator(By.CSS_SELECTOR, "mat-dialog-container", "Host creation popup block")
     fqdn_input = Locator(By.CSS_SELECTOR, "input[data-placeholder='Fully qualified domain name']", "Host FQDN input")
-    create_btn = Locator(By.XPATH, "//button[./span[contains(text(), 'Create')]]", "Create button")
-    cancel_btn = Locator(By.XPATH, "//button[./span[text()='Cancel']]", "Cancel button")
+    create_btn = Locator(By.XPATH, "//app-add-controls//button[./span[contains(text(), 'Create')]]", "Create button")
+    cancel_btn = Locator(By.XPATH, "//app-add-controls//button[./span[text()='Cancel']]", "Cancel button")
 
     class Provider:
         """Provider creation and selection section"""
diff --git a/tests/ui_tests/app/page/common/tooltip_links/locator.py b/tests/ui_tests/app/page/common/tooltip_links/locator.py
index 131c111193..a1f797a75c 100644
--- a/tests/ui_tests/app/page/common/tooltip_links/locator.py
+++ b/tests/ui_tests/app/page/common/tooltip_links/locator.py
@@ -28,6 +28,9 @@ class CommonToolbarLocators:
     admin_link = Locator(By.CSS_SELECTOR, "a[routerlink='/admin']", "Link to /admin")
     text_link = TemplateLocator(By.XPATH, "//a[text()='{}']", "Link to {}")
     action_btn = TemplateLocator(By.XPATH, "//span[.//a[text()='{}']]//app-action-list/button", "Action button to {}")
+    adcm_action_btn = Locator(
+        By.XPATH, "//mat-nav-list[./a[@routerlink='/admin']]//app-action-list/button", "Action button to adcm"
+    )
     upgrade_btn = TemplateLocator(By.XPATH, "//*[.//a[text()='{}']]//app-upgrade/button", "Upgrade button to {}")
     warn_btn = TemplateLocator(By.XPATH, "//span[.//a[text()='{}']]//app-concern-list-ref/button", "Warn button to {}")
 
diff --git a/tests/ui_tests/app/page/common/tooltip_links/page.py b/tests/ui_tests/app/page/common/tooltip_links/page.py
index aa240394a1..da2fc6de94 100644
--- a/tests/ui_tests/app/page/common/tooltip_links/page.py
+++ b/tests/ui_tests/app/page/common/tooltip_links/page.py
@@ -51,6 +51,30 @@ def run_action(self, tab_name: str, action_name: str):
         self.find_and_click(ActionDialog.run)
         self.wait_element_hide(ActionDialog.body)
 
+    @allure.step("Check action {action_name} in ADCM tab is inactive")
+    def is_adcm_action_inactive(self, action_name: str):
+        """Check Action from toolbar"""
+
+        self.wait_element_visible(CommonToolbarLocators.admin_link)
+        self.find_and_click(CommonToolbarLocators.adcm_action_btn)
+        is_active = (
+            self.wait_element_visible(CommonToolbarLocators.Popup.item(action_name)).get_attribute('disabled') == "true"
+        )
+        self.find_and_click(CommonToolbarLocators.adcm_action_btn, is_js=True)
+        return is_active
+
+    @allure.step("Run action {action_name} in ADCM tab")
+    def run_adcm_action(self, action_name: str):
+        """Run Action from toolbar"""
+
+        self.wait_element_visible(CommonToolbarLocators.admin_link)
+        self.find_and_click(CommonToolbarLocators.adcm_action_btn)
+        self.wait_element_visible(CommonToolbarLocators.Popup.popup_block)
+        self.wait_element_clickable(CommonToolbarLocators.Popup.item(action_name)).click()
+        self.wait_element_visible(ActionDialog.body)
+        self.find_and_click(ActionDialog.run)
+        self.wait_element_hide(ActionDialog.body)
+
     @allure.step("Run upgrade {upgrade_name} in {tab_name}")
     def run_upgrade(self, tab_name: str, upgrade_name: str):
         """Run Upgrade from toolbar"""
diff --git a/tests/ui_tests/app/page/job_list/locators.py b/tests/ui_tests/app/page/job_list/locators.py
index daeffe093f..c5d7ec3e5c 100644
--- a/tests/ui_tests/app/page/job_list/locators.py
+++ b/tests/ui_tests/app/page/job_list/locators.py
@@ -37,6 +37,8 @@ class Row:
             """Task List page row elements locators"""
 
             action_name = Locator(By.CSS_SELECTOR, "app-task-name a", "Action name in row")
+            # task (a.k.a. multi-job) have another element storing the name
+            task_action_name = Locator(By.CSS_SELECTOR, "app-task-name * span", "Action name in row")
             invoker_objects = Locator(By.CSS_SELECTOR, "app-task-objects a", "Object that invoked action in row")
             start_date = Locator(By.CSS_SELECTOR, "mat-cell.action_date:nth-child(4)", "Start date in row")
             finish_date = Locator(By.CSS_SELECTOR, "mat-cell.action_date:nth-child(5)", "Finish date in row")
@@ -44,6 +46,9 @@ class Row:
             # but in both cases we can identify status by class
             status = Locator(By.CSS_SELECTOR, "app-task-status-column *", "Status span in row")
 
+            # when running (and cancel is allowed, then we should take another element to get status)
+            status_under_btn = Locator(By.CSS_SELECTOR, "app-task-status-column button * mat-icon", "Status in row")
+
             expand_task = Locator(By.XPATH, ".//mat-icon[contains(text(), 'expand_more')]", "Expand task button")
 
         class ExpandedTask:
diff --git a/tests/ui_tests/app/page/job_list/page.py b/tests/ui_tests/app/page/job_list/page.py
index 44b2167ac9..cb4c8db51d 100644
--- a/tests/ui_tests/app/page/job_list/page.py
+++ b/tests/ui_tests/app/page/job_list/page.py
@@ -17,9 +17,11 @@
 from typing import TypeVar, Union, List
 
 import allure
+from selenium.common.exceptions import TimeoutException
 
 from selenium.webdriver.remote.webelement import WebElement
 
+from tests.library.conditional_retriever import FromOneOf, DataSource
 from tests.ui_tests.app.helpers.locator import Locator
 from tests.ui_tests.app.page.common.base_page import (
     BasePageObject,
@@ -35,6 +37,7 @@
 class JobStatus(Enum):
     """Available job statuses"""
 
+    ABORTED = 'aborted'
     RUNNING = 'running'
     SUCCESS = 'success'
     FAILED = 'failed'
@@ -86,18 +89,37 @@ def get_task_info_from_table(self, row_num: int = 0, *, full_invoker_objects_lin
                                           or just object which invoked the action
         """
         row = self.table.get_row(row_num)
+
+        def extract_status(locator):
+            return self._get_status_from_class_string(self.find_child(row, locator, timeout=4))
+
         row_locators = TaskListLocators.Table.Row
         if full_invoker_objects_link:
             invoker_objects = self.find_children(row, row_locators.invoker_objects)
             object_link = '/'.join(obj.text.strip() for obj in invoker_objects)
         else:
             object_link = self.find_child(row, row_locators.invoker_objects).text.strip()
+        # if task can be cancelled, then it will need another locator to determine the status
+        get_status = FromOneOf(
+            [
+                DataSource(extract_status, [row_locators.status]),
+                DataSource(extract_status, [row_locators.status_under_btn]),
+            ],
+            (KeyError, TimeoutError),
+        )
+        get_name_element = FromOneOf(
+            [
+                DataSource(self.find_child, [row, row_locators.action_name]),
+                DataSource(self.find_child, [row, row_locators.task_action_name]),
+            ],
+            (TimeoutError, TimeoutException),
+        )
         return TableTaskInfo(
-            action_name=self.find_child(row, row_locators.action_name).text,
+            action_name=get_name_element().text,
             invoker_objects=object_link,
             start_date=self.find_child(row, row_locators.start_date).text,
             finish_date=self.find_child(row, row_locators.finish_date).text,
-            status=self._get_status_from_class_string(self.find_child(row, row_locators.status)),
+            status=get_status(),
         )
 
     def get_task_info_from_popup(self, row_num: int = 0) -> PopupTaskInfo:
@@ -179,10 +201,13 @@ def _get_status_from_class_string(status_element: WebElement) -> JobStatus:
         for status in JobStatus:
             if status.value in class_string:
                 return status
-        raise KeyError('Job status not found in class string: %s' % str(class_string))
+        raise KeyError(
+            'Job status not found in class string: %s' % str(class_string)  # pylint: disable=consider-using-f-string
+        )
 
     def get_selected_filter(self):
         """Get selected filter text"""
         for filter_element in self.find_elements(TaskListLocators.Filter.filter_btn):
             if filter_element.get_attribute("aria-pressed") == "true":
                 return filter_element.text
+        raise RuntimeError('None of filters are selected')
diff --git a/tests/ui_tests/test_admin_page.py b/tests/ui_tests/test_admin_page.py
index bc3caa3c54..ad90cf715d 100644
--- a/tests/ui_tests/test_admin_page.py
+++ b/tests/ui_tests/test_admin_page.py
@@ -10,7 +10,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name, unused-argument
 
 """UI tests for /admin page"""
 
@@ -70,14 +70,12 @@
 
 
 @pytest.fixture()
-# pylint: disable-next=unused-argument
 def users_page(app_fs: ADCMTest) -> AdminUsersPage:
     """Get Admin Users Page"""
     return AdminUsersPage(app_fs.driver, app_fs.adcm.url).open()
 
 
 @pytest.fixture()
-# pylint: disable-next=unused-argument
 def settings_page(app_fs: ADCMTest) -> AdminSettingsPage:
     """Get Admin Settings Page"""
     return AdminSettingsPage(app_fs.driver, app_fs.adcm.url).open()
@@ -241,6 +239,43 @@ def test_reset_config(self, settings_page: AdminSettingsPage):
             settings_page.config.reset_to_default(config_field_row)
             settings_page.config.assert_input_value_is(params['init_value'], params['field_display_name'])
 
+    # TODO add mark during ADCM-2967
+    def test_ldap_config(self, settings_page: AdminSettingsPage):
+        """Test ldap"""
+        params = {'test_action': "Test LDAP connection", 'connect_action': "Run LDAP sync", "test_value": "test"}
+        with allure.step("Check ldap actions are disabled"):
+            assert settings_page.toolbar.is_adcm_action_inactive(
+                action_name=params['connect_action']
+            ), f"Action {params['connect_action']} should be disabled"
+            assert settings_page.toolbar.is_adcm_action_inactive(
+                action_name=params['test_action']
+            ), f"Action {params['test_action']} should be disabled"
+        with allure.step("Fill ldap config"):
+            settings_page.config.expand_or_close_group(group_name="LDAP integration")
+            settings_page.config.type_in_field_with_few_inputs(row="LDAP URI", values=[params['test_value']])
+            settings_page.config.type_in_field_with_few_inputs(row="Bind DN", values=[params['test_value']])
+            settings_page.config.type_in_field_with_few_inputs(
+                row="Bind Password", values=[params['test_value'], params['test_value']]
+            )
+            settings_page.config.type_in_field_with_few_inputs(row="User search base", values=[params['test_value']])
+            settings_page.config.type_in_field_with_few_inputs(row="Group search base", values=[params['test_value']])
+            settings_page.config.save_config()
+            settings_page.config.wait_config_loaded()
+        with allure.step("Check ldap actions are enabled"):
+            assert not settings_page.toolbar.is_adcm_action_inactive(
+                action_name=params['connect_action']
+            ), f"Action {params['connect_action']} should be enabled"
+            assert not settings_page.toolbar.is_adcm_action_inactive(
+                action_name=params['test_action']
+            ), f"Action {params['test_action']} should be enabled"
+        with allure.step("Check Test LDAP connection action"):
+            settings_page.toolbar.run_adcm_action(action_name=params['test_action'])
+            settings_page.header.wait_in_progress_job_amount_from_header(expected_job_amount=1)
+            settings_page.header.wait_in_progress_job_amount_from_header(expected_job_amount=0)
+        with allure.step("Check Run LDAP sync action"):
+            settings_page.toolbar.run_adcm_action(action_name=params['connect_action'])
+            settings_page.header.wait_in_progress_job_amount_from_header(expected_job_amount=1)
+
 
 @pytest.mark.usefixtures("login_to_adcm_over_api")
 class TestAdminUsersPage:
@@ -318,6 +353,17 @@ def test_change_admin_password(self, users_page: AdminUsersPage):
         with allure.step('Check login was successful'):
             AdminIntroPage(users_page.driver, users_page.base_url).wait_page_is_opened(timeout=5)
 
+    @pytest.mark.usefixtures("configure_adcm_ldap_ad")
+    def test_ldap_user_change_is_forbidden(self, users_page: AdminUsersPage, ldap_user_in_group):
+        """Change ldap user"""
+
+        users_page.header.wait_success_job_amount_from_header(1)
+        with allure.step(f'Check user {ldap_user_in_group["name"]} is listed in users list'):
+            assert users_page.is_user_presented(
+                ldap_user_in_group['name']
+            ), f'User {ldap_user_in_group["name"]} was not created'
+        users_page.check_ldap_user(ldap_user_in_group["name"])
+
 
 @pytest.mark.usefixtures("login_to_adcm_over_api")
 class TestAdminRolesPage:
@@ -462,6 +508,33 @@ def test_delete_group_from_groups_page(self, app_fs):
         with allure.step('Check that group has been deleted'):
             assert len(page.table.get_all_rows()) == 0, "There should be 0 groups"
 
+    @pytest.mark.usefixtures("configure_adcm_ldap_ad")
+    def test_ldap_group_change_is_forbidden(self, app_fs, ldap_user_in_group):
+        """Change ldap group"""
+
+        params = {'group_name': 'adcm_users'}
+        groups_page = AdminGroupsPage(app_fs.driver, app_fs.adcm.url).open()
+        groups_page.header.wait_success_job_amount_from_header(1)
+        with allure.step(f"Check group {params['group_name']} is listed in groups list"):
+            assert (
+                groups_page.get_all_groups()[0].name == params['group_name']
+            ), f"Group {params['group_name']} should be in groups list"
+        groups_page.check_ldap_group(params['group_name'])
+
+    @pytest.mark.usefixtures("configure_adcm_ldap_ad")
+    def test_add_ldap_user_to_group(self, app_fs, ldap_user_in_group):
+        """Add ldap user to group"""
+
+        params = {'group_name': 'Test_group'}
+        groups_page = AdminGroupsPage(app_fs.driver, app_fs.adcm.url).open()
+        groups_page.create_custom_group(name=params['group_name'], description=None, users=None)
+        groups_page.header.wait_success_job_amount_from_header(1)
+        groups_page.update_group(name=params['group_name'], users=ldap_user_in_group["name"])
+        with allure.step(f"Check group {params['group_name']} has user {ldap_user_in_group['name']}"):
+            assert (
+                ldap_user_in_group["name"] in groups_page.get_group_by_name(params['group_name']).text
+            ), f"Group {params['group_name']} should have user {ldap_user_in_group['name']}"
+
 
 class TestAdminPolicyPage:
     """Tests for the /admin/policies"""
diff --git a/tests/ui_tests/test_cluster_list_page.py b/tests/ui_tests/test_cluster_list_page.py
index b6ffb41a90..7bc9739b99 100644
--- a/tests/ui_tests/test_cluster_list_page.py
+++ b/tests/ui_tests/test_cluster_list_page.py
@@ -49,7 +49,6 @@
     ClusterHostPage,
     ClusterServicesPage,
     ClusterComponentsPage,
-    ComponentsHostRowInfo,
     ClusterStatusPage,
     ClusterGroupConfigConfig,
     ClusterGroupConfigHosts,
@@ -57,6 +56,7 @@
 from tests.ui_tests.app.page.cluster_list.page import ClusterListPage
 from tests.ui_tests.app.page.common.configuration.page import CONFIG_ITEMS
 from tests.ui_tests.app.page.common.group_config_list.page import GroupConfigRowInfo
+from tests.ui_tests.app.page.common.host_components.page import ComponentsHostRowInfo
 from tests.ui_tests.app.page.common.import_page.page import ImportItemInfo
 from tests.ui_tests.app.page.common.status.page import (
     SUCCESS_COLOR,
@@ -91,6 +91,7 @@
 BUNDLE_ENTERPRISE = "cluster_enterprise"
 BUNDLE_IMPORT = "cluster_to_import"
 BUNDLE_UPGRADE = "upgradable_cluster"
+BUNDLE_UPGRADE_V2 = "upgradable_cluster_v2"
 BUNDLE_REQUIRED_FIELDS = "cluster_and_service_with_required_string"
 BUNDLE_DEFAULT_FIELDS = "cluster_and_service_with_default_string"
 BUNDLE_WITH_SERVICES = "cluster_with_services"
@@ -104,10 +105,11 @@
 BUNDLE_WITH_DESCRIPTION_FIELDS = "cluster_with_all_config_params"
 BUNDLE_WITH_REQUIRED_IMPORT = "cluster_required_import"
 BUNDLE_WITH_REQUIRED_COMPONENT = "cluster_required_hostcomponent"
+DISCLAIMER_TEXT = "Are you really want to click me?"
 
 
 # pylint: disable=redefined-outer-name,unused-argument,too-many-lines,too-many-public-methods
-# pylint: disable=too-many-arguments,too-many-boolean-expressions,too-many-branches,too-many-nested-blocks
+# pylint: disable=too-many-arguments,too-many-boolean-expressions,too-many-branches,too-many-nested-blocks,too-many-locals
 
 pytestmark = pytest.mark.usefixtures("login_to_adcm_over_api")
 
@@ -250,6 +252,15 @@ def check_default_field_values_in_configs(
         cluster_config_page.config.assert_input_value_is(expected_value=expected_value, display_name=field_type)
 
 
+@allure.step("Check that cluster has been upgraded")
+def check_cluster_upgraded(app_fs, upgrade_cluster_name: str, state: str):
+    """Open cluster list page and check that cluster has been upgraded"""
+
+    cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
+    row = cluster_page.get_row_by_cluster_name(upgrade_cluster_name)
+    assert cluster_page.get_cluster_state_from_row(row) == state, f"Cluster state should be {state}"
+
+
 # !===== Tests =====!
 
 
@@ -384,12 +395,61 @@ def test_run_upgrade_on_cluster_list_page(self, sdk_client_fs, app_fs):
         cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
         row_with_upgrade = cluster_page.get_row_by_cluster_name(cluster_to_upgrade.name)
         cluster_page.run_upgrade_in_cluster_row(row=row_with_upgrade, upgrade_name=params["upgrade"])
-        with allure.step("Check that cluster has been upgraded"):
-            cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
-            row = cluster_page.get_row_by_cluster_name(params["upgrade_cluster_name"])
-            assert (
-                cluster_page.get_cluster_state_from_row(row) == params["state"]
-            ), f"Cluster state should be {params['state']}"
+        check_cluster_upgraded(app_fs, params["upgrade_cluster_name"], params["state"])
+
+    @pytest.mark.parametrize(
+        ("upgrade_name", "config", "hc_acl", "is_new_host", "disclaimer_text"),
+        [
+            ("simple_upgrade", None, False, False, False),
+            ("upgrade_with_hc_acl", None, True, False, False),
+            ("upgrade_with_config", {"somestring2": "test"}, False, False, False),
+            ("upgrade_with_config_and_hc_acl", {"somestring2": "test"}, True, False, False),
+            ("upgrade_with_hc_acl_and_disclaimer", None, True, False, DISCLAIMER_TEXT),
+            ("upgrade_with_config_and_disclaimer", {"somestring2": "test"}, False, False, DISCLAIMER_TEXT),
+            ("upgrade_with_config_and_hc_acl_and_disclaimer", {"somestring2": "test"}, True, False, DISCLAIMER_TEXT),
+            # next steps are skipped until https://tracker.yandex.ru/ADCM-3001
+            # ("upgrade_with_hc_acl", None, True, True, False),
+            # ("upgrade_with_config_and_hc_acl", {"somestring2": "test"}, True, True, False),
+            # ("upgrade_with_hc_acl_and_disclaimer", None, True, True, DISCLAIMER_TEXT),
+            # ("upgrade_with_config_and_hc_acl_and_disclaimer", {"somestring2": "test"}, True, True, DISCLAIMER_TEXT),
+        ],
+    )
+    def test_run_upgrade_v2_on_cluster_list_page(
+        self,
+        upload_and_create_provider,
+        sdk_client_fs,
+        app_fs,
+        upgrade_name,
+        config,
+        hc_acl,
+        is_new_host,
+        disclaimer_text,
+    ):
+        """Test run upgrade new version from the /cluster page"""
+        params = {
+            "state": "upgraded",
+        }
+        with allure.step("Upload main cluster bundle"):
+            bundle = cluster_bundle(sdk_client_fs, BUNDLE_COMMUNITY)
+            cluster = bundle.cluster_create(name=CLUSTER_NAME)
+            if not is_new_host:
+                host = upload_and_create_provider.host_create(HOST_NAME)
+                cluster.host_add(host)
+        with allure.step("Upload cluster bundle to upgrade"):
+            cluster_bundle(sdk_client_fs, BUNDLE_UPGRADE_V2)
+        cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
+        row_with_upgrade = cluster_page.get_row_by_cluster_name(CLUSTER_NAME)
+        cluster_page.run_upgrade_in_cluster_row(
+            row=row_with_upgrade,
+            upgrade_name=upgrade_name,
+            config=config,
+            hc_acl=hc_acl,
+            is_new_host=is_new_host,
+            disclaimer_text=disclaimer_text,
+        )
+        cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
+        cluster_page.header.wait_success_job_amount_from_header(1)
+        check_cluster_upgraded(app_fs, CLUSTER_NAME, params["state"])
 
 
 class TestClusterMainPage:
@@ -436,12 +496,7 @@ def test_run_upgrade_on_cluster_page_by_toolbar(self, sdk_client_fs, app_fs):
             cluster_to_upgrade = bundle.cluster_create(name=params["upgrade_cluster_name"])
         main_page = ClusterMainPage(app_fs.driver, app_fs.adcm.url, cluster_to_upgrade.id).open()
         main_page.toolbar.run_upgrade(params["upgrade_cluster_name"], params["upgrade"])
-        with allure.step("Check that cluster has been upgraded"):
-            cluster_page = ClusterListPage(app_fs.driver, app_fs.adcm.url).open()
-            row = cluster_page.get_row_by_cluster_name(params["upgrade_cluster_name"])
-            assert (
-                cluster_page.get_cluster_state_from_row(row) == params["state"]
-            ), f"Cluster state should be {params['state']}"
+        check_cluster_upgraded(app_fs, params["upgrade_cluster_name"], params["state"])
 
     def test_check_cluster_run_action_on_cluster_page_by_toolbar(self, app_fs, create_community_cluster):
         """Test run action from the /cluster/{}/main page toolbar"""
@@ -1076,6 +1131,7 @@ def test_configs_fields_invisible_true(
             assert cluster_config_page.config.is_save_btn_disabled(), 'Save button should be disabled'
 
     # pylint: disable=too-many-locals
+    @pytest.mark.skip("https://tracker.yandex.ru/ADCM-3037")
     @pytest.mark.full()
     @pytest.mark.parametrize("field_type", TYPES)
     @pytest.mark.parametrize("is_advanced", [True, False], ids=("field_advanced", "field_non-advanced"))
@@ -1137,14 +1193,13 @@ def check_expectations():
             if expected['alerts'] and not is_read_only:
                 cluster_config_page.config.check_invalid_value_message(field_type)
 
-        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         if is_advanced:
             cluster_config_page.config.check_no_rows_or_groups_on_page()
         else:
             check_expectations()
         cluster_config_page.config.click_on_advanced()
-        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         check_expectations()
+        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
 
     # pylint: enable=too-many-locals
 
@@ -1157,6 +1212,7 @@ def test_number_validation_on_cluster_config_page(self, sdk_client_fs: ADCMClien
         _, cluster_config_page = prepare_cluster_and_open_config_page(sdk_client_fs, path, app_fs)
 
         with allure.step('Check that save button is active'):
+            cluster_config_page.config.get_config_row("numbers_test").click()
             assert not cluster_config_page.config.is_save_btn_disabled(), 'Save button should be active'
         cluster_config_page.config.clear_field_by_keys(params["filed_name"])
 
@@ -1334,6 +1390,7 @@ def test_group_configs_fields_invisible_true(
         with allure.step('Check that save button is disabled'):
             assert cluster_config_page.config.is_save_btn_disabled(), 'Save button should be disabled'
 
+    @pytest.mark.skip("https://tracker.yandex.ru/ADCM-3037")
     @pytest.mark.full()
     @pytest.mark.parametrize("field_type", TYPES)
     @pytest.mark.parametrize("activatable", [True, False], ids=("activatable", "non-activatable"))
@@ -1418,13 +1475,13 @@ def check_expectations():
                     else:
                         assert len(cluster_config_page.config.get_all_config_rows()) == 1, "Field should not be visible"
 
-        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         if group_advanced:
             cluster_config_page.config.check_no_rows_or_groups_on_page()
         else:
             check_expectations()
         cluster_config_page.config.click_on_advanced()
         check_expectations()
+        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
 
 
 class TestClusterGroupConfigPage:
@@ -1493,6 +1550,7 @@ def test_check_pagination_on_group_config_component_page(self, app_fs, create_co
 
     # pylint: disable=too-many-locals
 
+    @pytest.mark.skip("https://tracker.yandex.ru/ADCM-3037")
     @pytest.mark.full()
     @pytest.mark.parametrize("field_type", TYPES)
     @pytest.mark.parametrize("is_advanced", [True, False], ids=("field_advanced", "field_non-advanced"))
@@ -1562,24 +1620,25 @@ def check_expectations():
                         ), f"Checkbox for field {field_type} should be disabled"
                     if config_group_customization and not is_read_only:
                         if not cluster_config_page.group_config.is_customization_chbx_checked(config_item):
+                            cluster_config_page.config.check_save_btn_state_and_save_conf(False)
                             cluster_config_page.group_config.click_on_customization_chbx(config_item)
+                        cluster_config_page.config.check_save_btn_state_and_save_conf(True)
                         assert cluster_config_page.group_config.is_customization_chbx_checked(
                             config_item
                         ), f"Config field {field_type} should be checked"
             if expected['alerts'] and (not is_read_only) and config_group_customization:
                 cluster_config_page.config.check_invalid_value_message(field_type)
 
-        # skip next check until https://arenadata.atlassian.net/browse/ADCM-2769
-        # cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
+        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         if is_advanced:
             cluster_config_page.config.check_no_rows_or_groups_on_page()
         else:
             check_expectations()
         cluster_config_page.config.click_on_advanced()
-        # skip next check until https://arenadata.atlassian.net/browse/ADCM-2769
-        # cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
+        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         check_expectations()
 
+    @pytest.mark.skip("https://tracker.yandex.ru/ADCM-3037")
     @pytest.mark.full()
     @pytest.mark.parametrize("field_type", TYPES)
     @pytest.mark.parametrize("activatable", [True, False], ids=("activatable", "non-activatable"))
@@ -1701,8 +1760,7 @@ def check_expectations():
                     else:
                         assert len(cluster_config_page.config.get_all_config_rows()) == 1, "Field should not be visible"
 
-        # skip next check until https://arenadata.atlassian.net/browse/ADCM-2769
-        # cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
+        cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         if group_advanced:
             cluster_config_page.config.check_no_rows_or_groups_on_page()
             cluster_config_page.group_config.check_no_rows()
diff --git a/tests/ui_tests/test_cluster_list_page_data/cluster_required_hostcomponent/config.yaml b/tests/ui_tests/test_cluster_list_page_data/cluster_required_hostcomponent/config.yaml
index 37e35dadf3..6b8f32656b 100644
--- a/tests/ui_tests/test_cluster_list_page_data/cluster_required_hostcomponent/config.yaml
+++ b/tests/ui_tests/test_cluster_list_page_data/cluster_required_hostcomponent/config.yaml
@@ -25,4 +25,4 @@
       requires:
         - component: Metastore
     Metastore:
-      constraint: [0,1]
\ No newline at end of file
+      constraint: [1]
\ No newline at end of file
diff --git a/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/config.yaml b/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/config.yaml
new file mode 100644
index 0000000000..23382e4a9d
--- /dev/null
+++ b/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/config.yaml
@@ -0,0 +1,199 @@
+# Licensed 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.
+
+- type: cluster
+  name: test_cluster
+  edition: community
+  version: 1.6
+
+  upgrade:
+    - &upgrade
+      name: simple_upgrade
+      versions:
+        min: 0.1
+        max: 1.5
+      scripts:
+        - &action
+          name: first_action
+          script: ./succeed.yaml
+          script_type: ansible
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+      states:
+        available: any
+        on_success: upgraded
+
+    - <<: *upgrade
+      name: upgrade_with_hc_acl
+      versions:
+        min: 0.1
+        max: 1.5
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+
+    - <<: *upgrade
+      name: upgrade_with_config
+      versions:
+        min: 0.1
+        max: 1.5
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+
+      config:
+        - name: somestring2
+          type: string
+          required: false
+        - name: someint2
+          type: integer
+          default: 122
+
+    - <<: *upgrade
+      name: upgrade_with_config_and_hc_acl
+      versions:
+        min: 0.1
+        max: 1.5
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+      config:
+        - name: somestring2
+          type: string
+          required: false
+        - name: someint2
+          type: integer
+          default: 122
+
+    - <<: *upgrade
+      name: upgrade_with_hc_acl_and_disclaimer
+      versions:
+        min: 0.1
+        max: 1.5
+      ui_options:
+        disclaimer: "Are you really want to click me?"
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+
+    - <<: *upgrade
+      name: upgrade_with_config_and_disclaimer
+      versions:
+        min: 0.1
+        max: 1.5
+      ui_options:
+        disclaimer: "Are you really want to click me?"
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+
+      config:
+        - name: somestring2
+          type: string
+          required: false
+        - name: someint2
+          type: integer
+          default: 122
+
+    - <<: *upgrade
+      name: upgrade_with_config_and_hc_acl_and_disclaimer
+      versions:
+        min: 0.1
+        max: 1.5
+      ui_options:
+        disclaimer: "Are you really want to click me?"
+      scripts:
+        - <<: *action
+          name: first_action
+        - name: switch action
+          script: bundle_switch
+          script_type: internal
+        - <<: *action
+          name: first action after switch
+      hc_acl:
+        - service: test_service
+          component: test_component
+          action: add
+      config:
+        - name: somestring2
+          type: string
+          required: false
+        - name: someint2
+          type: integer
+          default: 122
+
+  config: &config
+    - name: somestring
+      type: string
+      required: false
+    - name: someint
+      type: integer
+      default: 12
+    - name: somethingelse
+      type: map
+      required: false
+    - name: moar
+      type: list
+      default:
+        - high
+        - noon
+
+
+- type: service
+  version: 2.3
+  name: test_service
+
+  config: *config
+
+  components:
+    test_component:
+      config: *config
diff --git a/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/succeed.yaml b/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/succeed.yaml
new file mode 100644
index 0000000000..3f4b78d31d
--- /dev/null
+++ b/tests/ui_tests/test_cluster_list_page_data/upgradable_cluster_v2/succeed.yaml
@@ -0,0 +1,22 @@
+# Licensed 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.
+
+- name: Succeed action
+  hosts: localhost
+  connection: local
+  gather_facts: no
+
+  tasks:
+    - name: dummy action
+      debug:
+        msg: "Some message"
+
diff --git a/tests/ui_tests/test_login_page.py b/tests/ui_tests/test_login_page.py
index 8e8cfb91d2..f058cacbda 100644
--- a/tests/ui_tests/test_login_page.py
+++ b/tests/ui_tests/test_login_page.py
@@ -14,6 +14,7 @@
 
 import allure
 import pytest
+from adcm_pytest_plugin.params import including_https
 
 from tests.ui_tests.app.page.admin.page import AdminIntroPage
 from tests.ui_tests.app.page.login.page import LoginPage
@@ -34,6 +35,20 @@ def test_check_login_to_adcm(app_fs, adcm_credentials):
         intro_page.header.check_auth_page_elements()
 
 
+@including_https
+@pytest.mark.parametrize("configure_adcm_ldap_ad", [False, True], ids=["ssl-off", "ssl-on"], indirect=True)
+@pytest.mark.usefixtures("configure_adcm_ldap_ad")
+def test_login_as_ldap_user(app_fs, ldap_user_in_group):
+    """Test successful LDAP user authentication"""
+    username, password = ldap_user_in_group["name"], ldap_user_in_group["password"]
+    login_page = LoginPage(app_fs.driver, app_fs.adcm.url).open()
+    login_page.login_user(username, password)
+    with allure.step("Check if LDAP user has been authorized"):
+        intro_page = AdminIntroPage(app_fs.driver, app_fs.adcm.url)
+        intro_page.wait_page_is_opened()
+        intro_page.header.check_auth_page_elements()
+
+
 @pytest.mark.parametrize(("name", "password"), [("", "admin"), ("admin", "")], ids=("no_name", "no_password"))
 def test_check_login_button_unavailable(app_fs, name, password):
     """Test that login button is unavailable for given conditions"""
diff --git a/tests/ui_tests/test_regression.py b/tests/ui_tests/test_regression.py
index a3de9761cb..6778d80238 100644
--- a/tests/ui_tests/test_regression.py
+++ b/tests/ui_tests/test_regression.py
@@ -16,9 +16,10 @@
 
 import allure
 import pytest
-from adcm_client.objects import Cluster, Service, Component, Provider
-from adcm_pytest_plugin.utils import get_data_dir
+from adcm_client.objects import Cluster, Service, Component, Provider, Task, ADCMClient
+from adcm_pytest_plugin.utils import get_data_dir, wait_until_step_succeeds
 from adcm_pytest_plugin.steps.actions import run_cluster_action_and_assert_result, run_provider_action_and_assert_result
+from coreapi.exceptions import ErrorMessage
 
 from tests.conftest import TEST_USER_CREDENTIALS
 from tests.functional.tools import AnyADCMObject, get_object_represent
@@ -27,6 +28,7 @@
 from tests.ui_tests.app.page.common.base_page import BaseDetailedPage
 from tests.ui_tests.app.page.component.page import ComponentMainPage
 from tests.ui_tests.app.page.host.page import HostMainPage
+from tests.ui_tests.app.page.job_list.page import JobListPage, JobStatus
 from tests.ui_tests.app.page.provider.page import ProviderMainPage
 from tests.ui_tests.app.page.service.page import ServiceMainPage
 from tests.ui_tests.conftest import login_over_api
@@ -142,6 +144,7 @@ def _check_main_info_after_config_save(
             text_before = page.get_description()
             config_page = page.open_config_tab()
             config_page.config.set_description('New Awesome Description')
+            config_page.config.type_in_field_with_few_inputs(row="just_line", values=[" "])
             config_page.config.save_config()
             page.open()
             text_after = page.get_description()
@@ -158,3 +161,58 @@ def _init_page(self, app: ADCMTest, page_type, adcm_object):
         else:
             ids = (adcm_object.id,)
         return page_type(app.driver, app.adcm.url, *ids)
+
+
+class TestAllowToTerminate:
+    """
+    Test cases related to task termination
+    """
+
+    CLUSTER_NAME = 'Terminator'
+    client: ADCMClient
+
+    pytestmark = [
+        pytest.mark.parametrize('generic_bundle', ['action_termination_allowed'], indirect=True),
+        pytest.mark.usefixtures('login_to_adcm_over_api', '_bind_client', 'cluster'),
+    ]
+
+    @pytest.fixture()
+    def _bind_client(self, sdk_client_fs):
+        self.client = sdk_client_fs
+
+    @pytest.fixture()
+    def cluster(self, generic_bundle) -> Cluster:
+        """Create a cluster from the generic bundle with allow_to_terminate actions"""
+        return generic_bundle.cluster_create(self.CLUSTER_NAME)
+
+    @pytest.fixture()
+    def page(self, app_fs) -> JobListPage:
+        """Get page with list of jobs"""
+        return JobListPage(app_fs.driver, app_fs.adcm.url).open()
+
+    @pytest.mark.parametrize('action_name', ['simple', 'multi'])
+    def test_cancel_job(self, action_name: str, page: JobListPage):
+        """Test cancelling task (job and multijob)"""
+        task = self._run_cluster_action(action_name)
+        with allure.step('Check that job is running'):
+            wait_until_step_succeeds(self._check_job_status, timeout=5, period=1, page=page, status=JobStatus.RUNNING)
+        with allure.step('Cancel task and check it is cancelled without page refresh'):
+            self._cancel_task(task)
+            wait_until_step_succeeds(self._check_job_status, timeout=5, period=1, page=page, status=JobStatus.ABORTED)
+
+    def _run_cluster_action(self, name: str) -> Task:
+        with allure.step(f'Run action "{name}" on cluster'):
+            return self.client.cluster(name=self.CLUSTER_NAME).action(name=name).run()
+
+    def _cancel_task(self, task: Task):
+        def _cancel():
+            try:
+                task.cancel()
+            except ErrorMessage as e:
+                raise AssertionError('Failed to cancel task') from e
+
+        wait_until_step_succeeds(_cancel, timeout=5, period=0.5)
+
+    def _check_job_status(self, page: JobListPage, status: JobStatus, row_id: int = 0):
+        row_info = page.get_task_info_from_table(row_id)
+        assert row_info.status == status, f'Job should be {status.value}'
diff --git a/tests/ui_tests/test_service_page.py b/tests/ui_tests/test_service_page.py
index 61074914aa..9d609d988e 100644
--- a/tests/ui_tests/test_service_page.py
+++ b/tests/ui_tests/test_service_page.py
@@ -303,6 +303,7 @@ def test_save_advanced_config_on_service_config_page(self, app_fs, sdk_client_fs
         service_config_page.config.compare_versions(params["config_name_old"])
         service_config_page.config.check_config_fields_history_with_test_values()
 
+    @pytest.mark.skip("https://tracker.yandex.ru/ADCM-3017")
     @pytest.mark.parametrize(
         "bundle_name", ["password_no_confirm_false_required_false", "password_no_confirm_true_required_false"]
     )
@@ -453,7 +454,6 @@ def test_field_tooltips_on_service_config_page(self, app_fs, sdk_client_fs):
         for item in CONFIG_ITEMS:
             service_config_page.config.check_text_in_tooltip(item, f"Test description {item}")
 
-    @pytest.mark.skip()
     @pytest.mark.full()
     def test_save_configuration_hell_on_service_config_page(self, app_fs, sdk_client_fs):
         """