Merge pull request #657 from nfsantos/OAK-9881

OAK-9881 - Unreachable code in the logic that processes like constraints in Elastic
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfig.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfig.java
index b77e02d..1e8f9e9 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfig.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfig.java
@@ -407,6 +407,38 @@
      * Group specific config
      */
     public static class Group extends Authorizable {
+        
+        private boolean dynamicGroups;
 
+        /**
+         * <p>Returns {@code true} if external group identities are being synchronized into the repository as dynamic groups.
+         * In this case a dedicated {@link org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider} must be 
+         * present in order to have group membership reflected through User Management API.</p>
+         * 
+         * <p>Note, that currently this option only takes effect if it is enabled together with dynamic membership 
+         * (i.e. {@link User#getDynamicMembership()} returns true). In this case a dedicated 
+         * {@link org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider} based on the 
+         * {@code ExternalGroupPrincipalProvider} will be registered.</p>
+         *
+         * @return {@code true} if external groups should be synchronized as dynamic groups (i.e. without having their
+         * members added); {@code false} otherwise. Note, that this option currently only takes effect if {@link User#getDynamicMembership()} is enabled.
+         */
+        public boolean getDynamicGroups() {
+            return dynamicGroups;
+        }
+
+        /**
+         * Enable or disable the dynamic group option. If turned on together with {@link User#getDynamicMembership()} 
+         * external group identities will be synchronized into the repository but without storing their members. 
+         * In other words, group membership is generated dynamically.
+         *
+         * @param dynamicGroups Boolean flag to enable or disable synchronization of dynamic groups.
+         * @return {@code this}
+         * @see #getDynamicGroups() for details.
+         */
+        public @NotNull Group setDynamicGroups(boolean dynamicGroups) {
+            this.dynamicGroups = dynamicGroups;
+            return this;
+        }
     }
 }
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/package-info.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/package-info.java
index efe234b..abb9536 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/package-info.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/package-info.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@Version("1.6.0")
+@Version("1.7.0")
 package org.apache.jackrabbit.oak.spi.security.authentication.external.basic;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImpl.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImpl.java
index 2bbfae6..2a6ab15 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImpl.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImpl.java
@@ -273,6 +273,23 @@
     public static final String PARAM_GROUP_PATH_PREFIX = "group.pathPrefix";
 
     /**
+     * @see DefaultSyncConfig.Group#getDynamicGroups()
+     */
+    public static final boolean PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT = false;
+    
+    /**
+     * @see DefaultSyncConfig.Group#getDynamicGroups()
+     */
+    @Property(
+            label = "Dynamic Groups",
+            description = "If enabled external identity groups are synchronized as dynamic groups i.e. members/membership " +
+                    "is resolved dynamically by a DynamicMembershipProvider. Note: currently this option only takes effect " +
+                    "if 'User Dynamic Membership' is enabled.",
+            boolValue = PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT
+    )
+    public static final String PARAM_GROUP_DYNAMIC_GROUPS = "group.dynamicGroups";
+
+    /**
      * Default value for {@link #PARAM_ENABLE_RFC7613_USERCASE_MAPPED_PROFILE}
      */
     public static final boolean PARAM_ENABLE_RFC7613_USERCASE_MAPPED_PROFILE_DEFAULT = false;
@@ -314,6 +331,7 @@
                         params.getConfigValue(PARAM_USER_PROPERTY_MAPPING, PARAM_USER_PROPERTY_MAPPING_DEFAULT)));
 
         cfg.group()
+                .setDynamicGroups(params.getConfigValue(PARAM_GROUP_DYNAMIC_GROUPS, PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT))
                 .setExpirationTime(getMilliSeconds(params, PARAM_GROUP_EXPIRATION_TIME, PARAM_GROUP_EXPIRATION_TIME_DEFAULT, ONE_DAY))
                 .setApplyRFC7613UsernameCaseMapped(params.getConfigValue(PARAM_ENABLE_RFC7613_USERCASE_MAPPED_PROFILE, PARAM_ENABLE_RFC7613_USERCASE_MAPPED_PROFILE_DEFAULT))
                 .setPathPrefix(params.getConfigValue(PARAM_GROUP_PATH_PREFIX, PARAM_GROUP_PATH_PREFIX_DEFAULT))
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContext.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContext.java
index 107100f..c5837fb 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContext.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContext.java
@@ -93,31 +93,44 @@
         if (identity instanceof ExternalUser) {
             return super.sync(identity);
         } else if (identity instanceof ExternalGroup) {
-            try {
-                Group group = getAuthorizable(identity, Group.class);
-                if (group != null) {
-                    // group has been synchronized before -> continue updating for consistency.
-                    return syncGroup((ExternalGroup) identity, group);
-                } else {
-                    // external group has never been synchronized before:
-                    // don't sync external groups into the repository internal user management
-                    // but limit synchronized information to group-principals stored
-                    // separately with each external user such that the subject gets
-                    // properly populated upon login
-                    ExternalIdentityRef ref = identity.getExternalId();
-
-                    log.debug("ExternalGroup {}: Not synchronized as authorizable Group into the repository.", ref.getString());
-
-                    SyncResult.Status status = (isSameIDP(ref)) ? SyncResult.Status.NOP : SyncResult.Status.FOREIGN;
-                    return new DefaultSyncResultImpl(new DefaultSyncedIdentity(identity.getId(), ref, true, -1), status);
-                }
-            } catch (RepositoryException e) {
-                throw new SyncException(e);
+            ExternalIdentityRef ref = identity.getExternalId();
+            if (!isSameIDP(ref)) {
+                // create result in accordance with sync(String) where status is FOREIGN
+                return new DefaultSyncResultImpl(new DefaultSyncedIdentity(identity.getId(), ref, true, -1), SyncResult.Status.FOREIGN);
             }
+            return sync((ExternalGroup) identity, ref);
         } else {
             throw new IllegalArgumentException("identity must be user or group but was: " + identity);
         }
     }
+    
+    @NotNull
+    private SyncResult sync(@NotNull ExternalGroup identity, @NotNull ExternalIdentityRef ref) throws SyncException {
+        try {
+            Group group = getAuthorizable(identity, Group.class);
+            if (group != null) {
+                // this group has been synchronized before -> continue updating for consistency.
+                return syncGroup(identity, group);
+            } else if (hasDynamicGroups()) {
+                // group does not exist and dynamic-groups option is enabled -> sync the group
+                log.debug("ExternalGroup {}: synchronizing as dynamic group {}.", ref.getString(), identity.getId());
+                group = createGroup(identity);
+                DefaultSyncResultImpl res = syncGroup(identity, group);
+                res.setStatus(SyncResult.Status.ADD);
+                return res;
+            } else {
+                // external group has never been synchronized before and dynamic membership is enabled:
+                // don't sync external groups into the repository internal user management
+                // but limit synchronized information to group-principals stored
+                // separately with each external user such that the subject gets
+                // properly populated upon login
+                log.debug("ExternalGroup {}: Not synchronized as Group into the repository.", ref.getString());
+                return new DefaultSyncResultImpl(new DefaultSyncedIdentity(identity.getId(), ref, true, -1), SyncResult.Status.NOP);
+            }
+        } catch (RepositoryException e) {
+            throw new SyncException(e);
+        }
+    }
 
     //-------------------------------------------------< DefaultSyncContext >---
     @Override
@@ -127,23 +140,23 @@
         }
 
         boolean groupsSyncedBefore = groupsSyncedBefore(auth);
-        if (groupsSyncedBefore && !config.user().getEnforceDynamicMembership()) {
-            // user has been synchronized before dynamic membership has been turned on and dynamic membership is not enforced
+        if (groupsSyncedBefore && !enforceDynamicSync()) {
+            // user has been synchronized before dynamic membership has been turned on. continue regular sync unless 
+            // either dynamic membership is enforced or dynamic-group option is enabled.
             super.syncMembership(external, auth, depth);
         } else {
-            // retrieve membership of the given external user (up to the configured
-            // depth) and add (or replace) the rep:externalPrincipalNames property
-            // with the accurate collection of principal names.
             try {
-                Value[] vs;
-                if (depth <= 0) {
-                    vs = new Value[0];
-                } else {
-                    Set<String> principalsNames = new HashSet<>();
-                    collectPrincipalNames(principalsNames, external.getDeclaredGroups(), depth);
-                    vs = createValues(principalsNames);
+                Iterable<ExternalIdentityRef> declaredGroupRefs = external.getDeclaredGroups();
+                // store dynamic membership with the user
+                setExternalPrincipalNames(auth, declaredGroupRefs, depth);
+                
+                // if dynamic-group option is enabled -> sync groups without member-information
+                // in case group-membership has been synched before -> clear it
+                if (hasDynamicGroups() && depth > 0) {
+                    createDynamicGroups(declaredGroupRefs, depth);
                 }
-                auth.setProperty(ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES, vs);
+                
+                // clean up any other membership
                 if (groupsSyncedBefore) {
                     clearGroupMembership(auth);
                 }
@@ -159,6 +172,28 @@
     }
 
     /**
+     * Retrieve membership of the given external user (up to the configured depth) and add (or replace) the 
+     * rep:externalPrincipalNames property with the accurate collection of principal names.
+     * 
+     * @param authorizable The target synced user
+     * @param declareGroupRefs The declared group references for the external user
+     * @param depth The configured depth to resolve nested groups.
+     * @throws ExternalIdentityException If group principal names cannot be calculated
+     * @throws RepositoryException If another error occurs
+     */
+    private void setExternalPrincipalNames(@NotNull Authorizable authorizable, Iterable<ExternalIdentityRef> declareGroupRefs, long depth) throws ExternalIdentityException, RepositoryException {
+        Value[] vs;
+        if (depth <= 0) {
+            vs = new Value[0];
+        } else {
+            Set<String> principalsNames = new HashSet<>();
+            collectPrincipalNames(principalsNames, declareGroupRefs, depth);
+            vs = createValues(principalsNames);
+        }
+        authorizable.setProperty(ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES, vs);
+    }
+    
+    /**
      * Recursively collect the principal names of the given declared group
      * references up to the given depth.
      *
@@ -190,20 +225,78 @@
         }
     }
     
-    private Collection<String> clearGroupMembership(@NotNull Authorizable authorizable) throws RepositoryException {
-        Set<String> principalNames = new HashSet<>();
-        Iterator<Group> grpIter = authorizable.declaredMemberOf();
-        while (grpIter.hasNext()) {
-            Group grp = grpIter.next();
-            principalNames.add(grp.getPrincipal().getName());
-            if (isSameIDP(grp)) {
-                grp.removeMember(authorizable);
-                if (!grp.getDeclaredMembers().hasNext()) {
-                    grp.remove();
+    private void createDynamicGroups(@NotNull Iterable<ExternalIdentityRef> declaredGroupIdRefs, 
+                                     long depth) throws RepositoryException, ExternalIdentityException {
+        for (ExternalIdentityRef groupRef : declaredGroupIdRefs) {
+            ExternalGroup externalGroup = getExternalGroupFromRef(groupRef);
+            if (externalGroup != null) {
+                Group gr = userManager.getAuthorizable(externalGroup.getId(), Group.class);
+                if (gr == null) {
+                    gr = createGroup(externalGroup);
+                }
+                syncGroup(externalGroup, gr);
+                if (depth > 1) {
+                    createDynamicGroups(externalGroup.getDeclaredGroups(),depth-1);
                 }
             }
         }
-        return principalNames;
+    }
+    
+    @NotNull
+    private Collection<String> clearGroupMembership(@NotNull Authorizable authorizable) throws RepositoryException {
+        Set<String> groupPrincipalNames = new HashSet<>();
+        Set<Group> toRemove = new HashSet<>();
+
+        // loop over declared and inherited groups as it has been synchronzied before to clean up any previously 
+        // defined membership to external groups and automembership.
+        // principal-names are collected solely for migration trigger through JXM
+        clearGroupMembership(authorizable, groupPrincipalNames, toRemove);
+        
+        // finally remove external groups that are no longer needed
+        for (Group group : toRemove) {
+            group.remove();
+        }
+        return groupPrincipalNames;
+    }
+    
+    private void clearGroupMembership(@NotNull Authorizable authorizable, @NotNull Set<String> groupPrincipalNames, @NotNull Set<Group> toRemove) throws RepositoryException {
+        Iterator<Group> grpIter = authorizable.declaredMemberOf();
+        Set<String> autoMembership = ((authorizable.isGroup()) ? config.group() : config.user()).getAutoMembership(authorizable);
+        while (grpIter.hasNext()) {
+            Group grp = grpIter.next();
+            if (isSameIDP(grp)) {
+                // collected same-idp group principals for the rep:externalPrincipalNames property 
+                groupPrincipalNames.add(grp.getPrincipal().getName());
+                grp.removeMember(authorizable);
+                clearGroupMembership(grp, groupPrincipalNames, toRemove);
+                if (clearGroup(grp)) {
+                    toRemove.add(grp);
+                }
+            } else if (autoMembership.contains(grp.getID())) {
+                // clear auto-membership
+                grp.removeMember(authorizable);
+                clearGroupMembership(grp, groupPrincipalNames, toRemove);
+            } else {
+                // some other membership that has not been added by the sync process
+                log.debug("TODO");
+            }
+        }
+    }
+    
+    private boolean hasDynamicGroups() {
+        return config.group().getDynamicGroups();
+    }
+    
+    private boolean enforceDynamicSync() {
+        return config.user().getEnforceDynamicMembership() || hasDynamicGroups();
+    }
+    
+    private boolean clearGroup(@NotNull Group group) throws RepositoryException {
+        if (hasDynamicGroups()) {
+            return false;
+        } else {
+            return !group.getDeclaredMembers().hasNext();
+        }
     }
     
     private static boolean groupsSyncedBefore(@NotNull Authorizable authorizable) throws RepositoryException {
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProvider.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProvider.java
index b624679..ea7b5ba 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProvider.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProvider.java
@@ -56,6 +56,8 @@
 import java.util.stream.StreamSupport;
 
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_ID;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.NT_REP_AUTHORIZABLE;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.NT_REP_GROUP;
 import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.NT_REP_USER;
 import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_AUTHORIZABLE_ID;
 
@@ -69,21 +71,24 @@
     private final UserManager userManager;
     private final NamePathMapper namePathMapper;
     private final AutoMembershipPrincipals autoMembershipPrincipals;
+    private final AutoMembershipPrincipals groupAutoMembershipPrincipals;
     
     AutoMembershipProvider(@NotNull Root root,
                            @NotNull UserManager userManager, @NotNull NamePathMapper namePathMapper,
                            @NotNull Map<String, String[]> autoMembershipMapping,
+                           @Nullable Map<String, String[]> groupAutoMembershipMapping,
                            @NotNull Map<String, AutoMembershipConfig> autoMembershipConfigMap) {
         this.root = root;
         this.userManager = userManager;
         this.namePathMapper = namePathMapper;
         this.autoMembershipPrincipals = new AutoMembershipPrincipals(userManager, autoMembershipMapping, autoMembershipConfigMap);
+        this.groupAutoMembershipPrincipals = (groupAutoMembershipMapping == null) ? null : new AutoMembershipPrincipals(userManager, groupAutoMembershipMapping, autoMembershipConfigMap);
     }
 
     AutoMembershipProvider(@NotNull Root root,
                            @NotNull UserManager userManager, @NotNull NamePathMapper namePathMapper,
                            @NotNull SyncConfigTracker scTracker) {
-        this(root, userManager, namePathMapper, scTracker.getAutoMembership(), scTracker.getAutoMembershipConfig());
+        this(root, userManager, namePathMapper, scTracker.getAutoMembership(), (scTracker.hasDynamicGroupsEnabled() ? scTracker.getGroupAutoMembership() : null), scTracker.getAutoMembershipConfig());
     }
     
     @Override
@@ -104,11 +109,25 @@
     @Override
     public boolean isMember(@NotNull Group group, @NotNull Authorizable authorizable, boolean includeInherited) throws RepositoryException {
         String idpName = getIdpName(authorizable);
-        if (idpName == null || authorizable.isGroup()) {
-            // not an external user (NOTE: with dynamic membership enabled external groups will not be synced into the repository)
+        if (idpName == null) {
+            // not an external identity
             return false;
         }
         
+        // the authorizablle to test is a group
+        if (authorizable.isGroup()) {
+            // not an external user (NOTE: with dynamic membership enabled external groups will only be sync into the 
+            // repository if 'dynamic-group' option is enabled in addition)
+            if (groupAutoMembershipPrincipals == null) {
+                return false;
+            } else if (group.getID().equals(authorizable.getID())) {
+                // shortcut for the authorizable to test being the group itself
+                return false;
+            } else {
+                return isMember(groupAutoMembershipPrincipals, idpName, group, authorizable, includeInherited);
+            } 
+        }
+        
         // an external user
         return isMember(autoMembershipPrincipals, idpName, group, authorizable, includeInherited);
     }
@@ -125,13 +144,34 @@
     @Override
     public @NotNull Iterator<Group> getMembership(@NotNull Authorizable authorizable, boolean includeInherited) throws RepositoryException {
         String idpName = getIdpName(authorizable);
-        if (idpName == null || authorizable.isGroup()) {
-            // not an external user (NOTE: with dynamic membership enabled external groups will not be synced into the repository)
+        if (idpName == null) {
+            // not an external identity
             return RangeIteratorAdapter.EMPTY;
         }
-        Collection<Group> groups = autoMembershipPrincipals.getAutoMembership(idpName, authorizable, false).values();
-        Iterator<Group> groupIt = new RangeIteratorAdapter(groups);
+
+        Map<Principal, Group> m;
+        if (authorizable.isGroup()) {
+            // not an external user (NOTE: with dynamic membership enabled external groups will only be sync into the 
+            // repository if 'dynamic-group' option is enabled in addition)
+            if (groupAutoMembershipPrincipals == null) {
+                m = Collections.emptyMap();
+            } else {
+                m = groupAutoMembershipPrincipals.getAutoMembership(idpName, authorizable, false);
+            }
+        } else {
+            // an external user
+            m = autoMembershipPrincipals.getAutoMembership(idpName, authorizable, false);
+        }
         
+        return getGroupIterator(m.values(), includeInherited);
+    }
+    
+    @NotNull
+    private static Iterator<Group> getGroupIterator(@NotNull Collection<Group> groups, boolean includeInherited) {
+        if (groups.isEmpty()) {
+            return RangeIteratorAdapter.EMPTY;
+        }
+        Iterator<Group> groupIt = new RangeIteratorAdapter(groups);
         if (!includeInherited) {
             return groupIt;
         } else {
@@ -149,16 +189,23 @@
         // retrieve all idp-names for which the given group-principal is configured in the auto-membership option
         // NOTE: while the configuration takes the group-id the cache in 'autoMembershipPrincipals' is built based on the principal
         Set<String> idpNames = autoMembershipPrincipals.getConfiguredIdpNames(p);
+        Set<String> groupIdpNames = Collections.emptySet();
+        if (groupAutoMembershipPrincipals != null) {
+            groupIdpNames = groupAutoMembershipPrincipals.getConfiguredIdpNames(p);
+            idpNames.addAll(groupIdpNames);
+        }
         if (idpNames.isEmpty()) {
             return;
         }
 
-        // since this provider is only enabled for dynamic-automembership only users are expected to be returned by the
-        // query and thus the 'includeInherited' flag can be ignored.
+        String nodeType = (groupIdpNames.isEmpty()) ? NT_REP_USER : (idpNames.size() == groupIdpNames.size()) ? NT_REP_GROUP : NT_REP_AUTHORIZABLE;
+
+        // since this provider is only enabled for dynamic-automembership the 'includeInherited' flag can be ignored.
+        // as group-membership for dynamic users is flattened and automembership-configuration for groups is included.
         // TODO: execute a single (more complex) query ?
         for (String idpName : idpNames) {
             Map<String, ? extends PropertyValue> bindings = buildBinding(idpName);
-            String statement = "SELECT '" + REP_AUTHORIZABLE_ID + "' FROM ["+NT_REP_USER+"] WHERE PROPERTY(["
+            String statement = "SELECT '" + REP_AUTHORIZABLE_ID + "' FROM ["+nodeType+"] WHERE PROPERTY(["
                     + REP_EXTERNAL_ID + "], '" + PropertyType.TYPENAME_STRING + "')"
                     + " LIKE $" + BINDING_AUTHORIZABLE_IDS + QueryEngine.INTERNAL_SQL2_QUERY;
             try {
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipService.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipService.java
new file mode 100644
index 0000000..cb1262b
--- /dev/null
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipService.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import org.apache.jackrabbit.api.security.user.UserManager;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.namepath.NamePathMapper;
+import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider;
+import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipService;
+import org.jetbrains.annotations.NotNull;
+
+class DynamicGroupMembershipService implements DynamicMembershipService {
+
+    private final SyncConfigTracker scTracker;
+
+    public DynamicGroupMembershipService(@NotNull SyncConfigTracker scTracker) {
+        this.scTracker = scTracker;
+    }
+
+    @Override
+    @NotNull
+    public DynamicMembershipProvider getDynamicMembershipProvider(@NotNull Root root, @NotNull UserManager userManager, @NotNull NamePathMapper namePathMapper) {
+        if (scTracker.hasDynamicGroupsEnabled()) {
+            return new ExternalGroupPrincipalProvider(root, userManager, namePathMapper, scTracker);
+        } else {
+            return DynamicMembershipProvider.EMPTY;
+        }
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtil.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtil.java
new file mode 100644
index 0000000..8612c64
--- /dev/null
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtil.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
+import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.jcr.RepositoryException;
+import java.util.Set;
+
+class DynamicGroupUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(DynamicGroupUtil.class);
+
+    private static final Set<String> MEMBER_NODE_NAMES = ImmutableSet.of(UserConstants.REP_MEMBERS, UserConstants.REP_MEMBERS_LIST);
+    private static final Set<String> MEMBERS_TYPES = ImmutableSet.of(UserConstants.NT_REP_MEMBER_REFERENCES, UserConstants.NT_REP_MEMBER_REFERENCES_LIST, UserConstants.NT_REP_MEMBERS);
+
+    private DynamicGroupUtil() {}
+
+    static boolean isGroup(@NotNull Tree tree) {
+        return UserUtil.isType(tree, AuthorizableType.GROUP);
+    }
+
+    static boolean isMemberProperty(@NotNull PropertyState propertyState) {
+        return UserConstants.REP_MEMBERS.equals(propertyState.getName());
+    }
+    
+    static @Nullable String findGroupIdInHierarchy(@NotNull Tree tree) {
+        Tree t = tree;
+        while (!t.isRoot()) {
+            String id = UserUtil.getAuthorizableId(t);
+            if (id != null) {
+                return id;
+            }
+            t = t.getParent();
+        }
+        return null;
+    }
+
+    @NotNull
+    static Tree getTree(@NotNull Authorizable authorizable, @NotNull Root root) throws RepositoryException {
+        return (authorizable instanceof TreeAware) ? ((TreeAware) authorizable).getTree() : root.getTree(authorizable.getPath());
+    }
+
+    static boolean hasStoredMemberInfo(@NotNull Group group, @NotNull Root root) {
+        try {
+            Tree tree = getTree(group, root);
+            return tree.hasProperty(UserConstants.REP_MEMBERS) || MEMBER_NODE_NAMES.stream().anyMatch(tree::hasChild);
+        } catch (RepositoryException e) {
+            log.error("Cannot test for stored members information, failed to obtain tree from group.", e);
+            return false;
+        }
+    }
+
+    static boolean isMembersType(@NotNull Tree tree) {
+        String primaryType = TreeUtil.getPrimaryTypeName(tree);
+        return primaryType != null && MEMBERS_TYPES.contains(primaryType);
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProvider.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProvider.java
new file mode 100644
index 0000000..23effe1
--- /dev/null
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProvider.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.tree.RootProvider;
+import org.apache.jackrabbit.oak.plugins.tree.TreeProvider;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.DefaultValidator;
+import org.apache.jackrabbit.oak.spi.commit.SubtreeValidator;
+import org.apache.jackrabbit.oak.spi.commit.Validator;
+import org.apache.jackrabbit.oak.spi.commit.ValidatorProvider;
+import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
+import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
+import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
+import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Set;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.DynamicGroupUtil.findGroupIdInHierarchy;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.DynamicGroupUtil.isGroup;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.DynamicGroupUtil.isMemberProperty;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.DynamicGroupUtil.isMembersType;
+
+class DynamicGroupValidatorProvider extends ValidatorProvider implements ExternalIdentityConstants {
+    
+    private final RootProvider rootProvider;
+    private final TreeProvider treeProvider;
+    private final Set<String> idpNamesWithDynamicGroups;
+    private final String groupRootPath;
+
+    private Root rootBefore;
+    private Root rootAfter;
+
+    DynamicGroupValidatorProvider(@NotNull RootProvider rootProvider,
+                                  @NotNull TreeProvider treeProvider,
+                                  @NotNull SecurityProvider securityProvider,
+                                  @NotNull Set<String> idpNamesWithDynamicGroups) {
+        this.rootProvider = rootProvider;
+        this.treeProvider = treeProvider;
+        this.idpNamesWithDynamicGroups = idpNamesWithDynamicGroups;
+
+        this.groupRootPath = checkNotNull(UserUtil.getAuthorizableRootPath(securityProvider.getParameters(UserConfiguration.NAME), AuthorizableType.GROUP));
+    }
+
+    @Override
+    protected @NotNull Validator getRootValidator(NodeState before, NodeState after, CommitInfo info) {
+        if (idpNamesWithDynamicGroups.isEmpty()) {
+            return DefaultValidator.INSTANCE;
+        }
+        
+        this.rootBefore = rootProvider.createReadOnlyRoot(before);
+        this.rootAfter = rootProvider.createReadOnlyRoot(after);
+        
+        return new SubtreeValidator(new DynamicGroupValidator(), Iterables.toArray(PathUtils.elements(groupRootPath), String.class));
+    }
+    
+    private class DynamicGroupValidator extends DefaultValidator {
+
+        private Tree parentBefore;
+        private Tree parentAfter;
+
+        boolean isDynamicGroup = false;
+
+        private DynamicGroupValidator() {}
+
+        private DynamicGroupValidator(@NotNull Tree parentBefore, @NotNull Tree parentAfter, boolean isDynamicGroup) {
+            this.parentBefore = parentBefore;
+            this.parentAfter = parentAfter;
+            this.isDynamicGroup = isDynamicGroup;
+        }
+
+        private DynamicGroupValidator(@NotNull Tree parentAfter, boolean isDynamicGroup) {
+            this.parentAfter = parentAfter;
+            this.isDynamicGroup = isDynamicGroup;
+        }
+        
+        @Override
+        public void propertyAdded(PropertyState after) throws CommitFailedException {
+            if (isDynamicGroup && isMemberProperty(after)) {
+                throw commitFailedException(getParentAfter());
+            }
+        }
+
+        @Override
+        public void propertyChanged(PropertyState before, PropertyState after) throws CommitFailedException {
+            if (isDynamicGroup && isMemberProperty(before)) {
+                Set<String> refsBefore = Sets.newHashSet(before.getValue(Type.STRINGS));
+                Set<String> refsAfter = Sets.newHashSet(after.getValue(Type.STRINGS));
+                refsAfter.removeAll(refsBefore);
+                if (!refsAfter.isEmpty()) {
+                    throw commitFailedException(getParentBefore());
+                }
+            }
+        }
+
+        @Override
+        public Validator childNodeAdded(String name, NodeState after) throws CommitFailedException {
+            Tree afterTree = treeProvider.createReadOnlyTree(getParentAfter(), name, after);
+            boolean dynamicGroupChild = isDynamicGroup(this, afterTree);
+
+            if (dynamicGroupChild) {
+                if (isMembersType(afterTree)) {
+                    throw commitFailedException(getParentAfter());
+                }
+                return new DynamicGroupValidator(afterTree, true);
+            } else if (!isGroup(afterTree)) {
+                return new DynamicGroupValidator(afterTree, false);
+            } else {
+                // a regular group -> no need to traverse into the subtree
+                return null;
+            }
+        }
+
+        @Override
+        public Validator childNodeChanged(String name, NodeState before, NodeState after) {
+            Tree beforeTree = treeProvider.createReadOnlyTree(getParentBefore(), name, before);
+            Tree afterTree = treeProvider.createReadOnlyTree(getParentAfter(), name, after);
+
+            boolean dynamicGroupChild = isDynamicGroup(this, beforeTree);
+            if (dynamicGroupChild || !isGroup(beforeTree)) {
+                return new DynamicGroupValidator(beforeTree, afterTree, dynamicGroupChild);
+            } else {
+                // no need to traverse into a regular group
+                return null;
+            }
+        }
+        
+        private boolean isDynamicGroup(@NotNull DynamicGroupValidator parentValidator, @NotNull Tree tree) {
+            if (parentValidator.isDynamicGroup) {
+                return true;
+            } else {
+                return isDynamicGroup(tree);
+            }
+        }
+        
+        private boolean isDynamicGroup(@NotNull Tree tree) {
+            if (UserUtil.isType(tree, AuthorizableType.GROUP)) {
+                PropertyState ps = tree.getProperty(REP_EXTERNAL_ID);
+                if (ps == null) {
+                    return false;
+                }
+                String providerName = ExternalIdentityRef.fromString(ps.getValue(Type.STRING)).getProviderName();
+                return providerName != null && idpNamesWithDynamicGroups.contains(providerName);
+            } 
+            return false;
+        }
+        
+        private @NotNull Tree getParentBefore() {
+            if (parentBefore == null) {
+                parentBefore = rootBefore.getTree(groupRootPath);
+            }
+            return parentBefore;
+        }
+
+        private @NotNull Tree getParentAfter() {
+            if (parentAfter == null) {
+                parentAfter = rootAfter.getTree(groupRootPath);
+            }
+            return parentAfter;
+        }
+
+        private @NotNull CommitFailedException commitFailedException(@NotNull Tree tree) {
+            String msg = String.format("Attempt to add members to dynamic group '%s' at '%s'", findGroupIdInHierarchy(tree), tree.getPath());
+            return new CommitFailedException(CommitFailedException.CONSTRAINT, 77, msg);
+        }
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProvider.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProvider.java
index f92f0d3..f0ac9b8 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProvider.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProvider.java
@@ -25,6 +25,7 @@
 import org.apache.jackrabbit.api.security.principal.ItemBasedPrincipal;
 import org.apache.jackrabbit.api.security.principal.PrincipalManager;
 import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.api.security.user.Group;
 import org.apache.jackrabbit.api.security.user.UserManager;
 import org.apache.jackrabbit.commons.iterator.AbstractLazyIterator;
 import org.apache.jackrabbit.oak.api.PropertyState;
@@ -37,14 +38,15 @@
 import org.apache.jackrabbit.oak.api.Type;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
-import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.AutoMembershipConfig;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncContext;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
 import org.apache.jackrabbit.oak.spi.security.principal.GroupPrincipals;
 import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
 import org.apache.jackrabbit.oak.spi.security.principal.PrincipalProvider;
 import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
+import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider;
 import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
 import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
 import org.jetbrains.annotations.NotNull;
@@ -94,7 +96,7 @@
  * @since Oak 1.5.3
  * @see org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DynamicSyncContext
  */
-class ExternalGroupPrincipalProvider implements PrincipalProvider, ExternalIdentityConstants {
+class ExternalGroupPrincipalProvider implements PrincipalProvider, ExternalIdentityConstants, DynamicMembershipProvider {
 
     private static final Logger log = LoggerFactory.getLogger(ExternalGroupPrincipalProvider.class);
 
@@ -104,34 +106,68 @@
     private final NamePathMapper namePathMapper;
 
     private final UserManager userManager;
-    private final AutoMembershipPrincipals autoMembershipPrincipals;
+    private final Set<String> idpNamesWithDynamicGroups;
+    private final boolean hasOnlyDynamicGroups;
 
-    ExternalGroupPrincipalProvider(@NotNull Root root, @NotNull UserConfiguration uc,
+    private final AutoMembershipPrincipals autoMembershipPrincipals;
+    private final AutoMembershipPrincipals groupAutoMembershipPrincipals;
+
+    ExternalGroupPrincipalProvider(@NotNull Root root, @NotNull UserManager userManager,
                                    @NotNull NamePathMapper namePathMapper,
-                                   @NotNull Map<String, String[]> autoMembershipMapping,
-                                   @NotNull Map<String, AutoMembershipConfig> autoMembershipConfigMap) {
+                                   @NotNull SyncConfigTracker syncConfigTracker) {
         this.root = root;
         this.namePathMapper = namePathMapper;
+        this.userManager = userManager;
+        
+        idpNamesWithDynamicGroups = syncConfigTracker.getIdpNamesWithDynamicGroups();
+        hasOnlyDynamicGroups = (idpNamesWithDynamicGroups.size() == syncConfigTracker.getServiceReferences().length);
 
-        userManager = uc.getUserManager(root, namePathMapper);
-        autoMembershipPrincipals = new AutoMembershipPrincipals(userManager, autoMembershipMapping, autoMembershipConfigMap);
+        autoMembershipPrincipals = new AutoMembershipPrincipals(userManager, syncConfigTracker.getAutoMembership(), syncConfigTracker.getAutoMembershipConfig());
+        groupAutoMembershipPrincipals = (idpNamesWithDynamicGroups.isEmpty()) ? null : new AutoMembershipPrincipals(userManager, syncConfigTracker.getGroupAutoMembership(), syncConfigTracker.getAutoMembershipConfig());
+    }
+
+    // Tests only
+    ExternalGroupPrincipalProvider(@NotNull Root root, @NotNull UserConfiguration userConfiguration,
+                                   @NotNull NamePathMapper namePathMapper, 
+                                   @NotNull String idpName,
+                                   @NotNull DefaultSyncConfig syncConfig,
+                                   @NotNull Set<String> idpNamesWithDynamicGroups, boolean hasOnlyDynamicGroups) {
+        this.root = root;
+        this.namePathMapper = namePathMapper;
+        this.userManager = userConfiguration.getUserManager(root, namePathMapper);
+
+        this.idpNamesWithDynamicGroups = idpNamesWithDynamicGroups;
+        this.hasOnlyDynamicGroups = hasOnlyDynamicGroups;
+
+        autoMembershipPrincipals = new AutoMembershipPrincipals(userManager, 
+                Collections.singletonMap(idpName, Iterables.toArray(Iterables.concat(syncConfig.user().getAutoMembership(),syncConfig.group().getAutoMembership()), String.class)),
+                Collections.singletonMap(idpName, syncConfig.user().getAutoMembershipConfig()));
+        groupAutoMembershipPrincipals = (idpNamesWithDynamicGroups.isEmpty()) ? null : 
+                new AutoMembershipPrincipals(userManager, 
+                Collections.singletonMap(idpName, syncConfig.group().getAutoMembership().toArray(new String[0])),
+                Collections.singletonMap(idpName, syncConfig.group().getAutoMembershipConfig()));
     }
 
     //--------------------------------------------------< PrincipalProvider >---
     @Override
     public Principal getPrincipal(@NotNull String principalName) {
-        Result result = findPrincipals(principalName, true);
-        if (result != null && result.getRows().iterator().hasNext()) {
-            return new ExternalGroupPrincipal(principalName);
-        } else {
+        if (hasOnlyDynamicGroups) {
+            // shortcut: the default user-principal-provider will return the group principal
             return null;
         }
+        
+        Result result = findPrincipals(principalName, true);
+        Iterator<? extends ResultRow> rows = (result == null) ? Iterators.emptyIterator() : result.getRows().iterator();
+        if (rows.hasNext()) {
+            return new ExternalGroupPrincipal(principalName, getIdpName(rows.next()));
+        }
+        return null;
     }
 
     @NotNull
     @Override
     public Set<Principal> getMembershipPrincipals(@NotNull Principal principal) {
-        if (!GroupPrincipals.isGroup(principal)) {
+        if (hasDynamicMembershipPrincipals(principal)) {
             try {
                 if (principal instanceof ItemBasedPrincipal) {
                     String path = ((ItemBasedPrincipal) principal).getPath();
@@ -141,7 +177,7 @@
                         return getGroupPrincipals(a, t);
                     }
                 } else {
-                    return getGroupPrincipals(userManager.getAuthorizable(principal));
+                    return getGroupPrincipals(userManager.getAuthorizable(principal), false);
                 }
             } catch (RepositoryException e) {
                 log.debug(e.getMessage());
@@ -155,7 +191,7 @@
     @Override
     public Set<? extends Principal> getPrincipals(@NotNull String userID) {
         try {
-            return getGroupPrincipals(userManager.getAuthorizable(userID));
+            return getGroupPrincipals(userManager.getAuthorizable(userID), true);
         } catch (RepositoryException e) {
             log.debug(e.getMessage());
             return ImmutableSet.of();
@@ -165,14 +201,20 @@
     @NotNull
     @Override
     public Iterator<? extends Principal> findPrincipals(@Nullable String nameHint, int searchType) {
-        if (PrincipalManager.SEARCH_TYPE_NOT_GROUP != searchType) {
-            Result result = findPrincipals(Strings.nullToEmpty(nameHint), false);
-            if (result != null) {
-                return Iterators.filter(new GroupPrincipalIterator(nameHint, result), Objects::nonNull);
-            }
+        // this provider only serves GroupPrincipal instances for external group accounts that have
+        // not been synchronized into the repository. if groups for all configured IDPs are synchronzied as
+        // dynamic groups the default principal-provider backed by user/group accounts will be in charge.
+        if (PrincipalManager.SEARCH_TYPE_NOT_GROUP == searchType || hasOnlyDynamicGroups) {
+            return Collections.emptyIterator();
         }
-
-        return Collections.emptyIterator();
+        
+        // search for external group principals that have not been synchronzied into the repository
+        Result result = findPrincipals(Strings.nullToEmpty(nameHint), false);
+        if (result != null) {
+            return Iterators.filter(new GroupPrincipalIterator(nameHint, result), Objects::nonNull);
+        } else {
+            return Collections.emptyIterator();
+        }
     }
 
     @NotNull
@@ -202,6 +244,85 @@
         return stream.iterator();
     }
 
+    //------------------------------------------< DynamicMembershipProvider >---
+
+    @Override
+    public boolean coversAllMembers(@NotNull Group group) {
+        return isDynamic(group) && !DynamicGroupUtil.hasStoredMemberInfo(group, root);
+    }
+
+    @Override
+    public @NotNull Iterator<Authorizable> getMembers(@NotNull Group group, boolean includeInherited) throws RepositoryException {
+        if (!isDynamic(group)) {
+            return Iterators.emptyIterator();
+        } else {
+            Result result = findPrincipals(group.getPrincipal().getName(), true);
+            if (result != null) {
+                return new MemberIterator<Authorizable>(result) {
+                    @Override
+                    Authorizable get(@NotNull Authorizable authorizable) {
+                        return authorizable;
+                    }
+                };
+            } else {
+                return Collections.emptyIterator();
+            }
+        }
+    }
+
+    @Override
+    public boolean isMember(@NotNull Group group, @NotNull Authorizable authorizable, boolean includeInherited) throws RepositoryException {
+        if (authorizable.isGroup() || !isDynamic(group) || !isDynamic(authorizable)) {
+            return false;
+        } else {
+            String principalName = group.getPrincipal().getName();
+            return isDynamicMember(principalName, authorizable);
+        }
+    }
+
+    @Override
+    public @NotNull Iterator<Group> getMembership(@NotNull Authorizable authorizable, boolean includeInherited) throws RepositoryException {
+        if (authorizable.isGroup() || !isDynamic(authorizable)) {
+            return Iterators.emptyIterator();
+        } else {
+            Value[] vs = authorizable.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
+            if (vs == null || vs.length == 0) {
+                return Iterators.emptyIterator();
+            }
+            
+            Set<Value> valueSet = ImmutableSet.copyOf(vs);
+            return Iterators.filter(Iterators.transform(valueSet.iterator(), value -> {
+                try {
+                    String groupPrincipalName = value.getString();
+                    Authorizable gr = userManager.getAuthorizable(new PrincipalImpl(groupPrincipalName));
+                    return (gr != null && gr.isGroup()) ? (Group) gr : null;
+                } catch (RepositoryException e) {
+                    return null;
+                }
+            }), Objects::nonNull);
+        }
+    }
+
+    /**
+     * Returns true if the given user/group belongs to an IDP that has dynamic-group configuration option enabled.
+     */
+    private boolean isDynamic(@NotNull Authorizable authorizable) {
+        if (idpNamesWithDynamicGroups.isEmpty()) {
+            return false;
+        }
+        try {
+            ExternalIdentityRef extIdRef = DefaultSyncContext.getIdentityRef(authorizable);
+            if (extIdRef == null) {
+                return false;
+            } else {
+                return idpNamesWithDynamicGroups.contains(extIdRef.getProviderName());
+            }
+        } catch (RepositoryException e) {
+            log.warn("Cannot retrieve rep:externalId property from identity {}", authorizable);
+            return false;
+        }
+    }
+
     //------------------------------------------------------------< private >---
     @Nullable
     private static String getIdpName(@NotNull Tree userTree) {
@@ -213,40 +334,63 @@
         }
     }
 
+    @Nullable
+    private static String getIdpName(@NotNull ResultRow row) {
+        return getIdpName(row.getTree(null));
+    }
+
     @NotNull
-    private Set<Principal> getGroupPrincipals(@Nullable Authorizable authorizable) throws RepositoryException {
-        if (authorizable != null && !authorizable.isGroup()) {
-            Tree userTree = root.getTree(authorizable.getPath());
-            return getGroupPrincipals(authorizable, userTree);
-        } else {
+    private Set<Principal> getGroupPrincipals(@Nullable Authorizable authorizable, boolean ignoreGroup) throws RepositoryException {
+        if (authorizable == null || (authorizable.isGroup() && ignoreGroup)) {
             return ImmutableSet.of();
+        } else {
+            return getGroupPrincipals(authorizable, DynamicGroupUtil.getTree(authorizable, root));
         }
     }
 
     @NotNull
-    private Set<Principal> getGroupPrincipals(@NotNull Authorizable authorizable, @NotNull Tree userTree) {
-        if (userTree.exists() && UserUtil.isType(userTree, AuthorizableType.USER)) {
-            PropertyState ps = userTree.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
+    private Set<Principal> getGroupPrincipals(@NotNull Authorizable authorizable, @NotNull Tree tree) {
+        if (!tree.exists()) {
+            return Collections.emptySet();
+        }
+        String idpName = getIdpName(tree);
+        if (idpName == null) {
+            // a tree without rep:externalid that would mark a valid synchronized external identity
+            return Collections.emptySet();
+        }
+        if (UserUtil.isType(tree, AuthorizableType.USER)) {
+            PropertyState ps = tree.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
             if (ps != null) {
                 // we have an 'external' user that has been synchronized with the dynamic-membership option
                 Set<Principal> groupPrincipals = Sets.newHashSet();
                 for (String principalName : ps.getValue(Type.STRINGS)) {
-                    groupPrincipals.add(new ExternalGroupPrincipal(principalName));
+                    groupPrincipals.add(new ExternalGroupPrincipal(principalName, idpName));
                 }
 
                 // add existing group principals as defined with the _autoMembership_ option.
-                String idpName = getIdpName(userTree);
-                if (idpName != null) {
-                    // resolve automembership including inherited group membership
-                    groupPrincipals.addAll(autoMembershipPrincipals.getAutoMembership(idpName, authorizable, true).keySet());
-                }
+                groupPrincipals.addAll(getAutomembershipPrincipals(idpName, authorizable));
                 return groupPrincipals;
+            } else {
+                return Collections.emptySet();
             }
+        } else {
+            // resolve automembership for dynamic groups
+            return getAutomembershipPrincipals(idpName, authorizable);
         }
-        // group principals cannot be retrieved
-        return ImmutableSet.of();
     }
 
+    private Set<Principal> getAutomembershipPrincipals(@NotNull String idpName, @NotNull Authorizable authorizable) {
+        if (authorizable.isGroup()) {
+            // no need to check for 'groupAutoMembershipPrincipals' being null as it is created if 'idpNamesWithDynamicGroups' is not empty
+            return (idpNamesWithDynamicGroups.contains(idpName)) ? 
+                    groupAutoMembershipPrincipals.getAutoMembership(idpName, authorizable, true).keySet() :
+                    Collections.emptySet();
+        } else {
+            return autoMembershipPrincipals.getAutoMembership(idpName, authorizable, true).keySet();
+        }
+    }
+
+
     /**
      * Runs an Oak query searching for {@link #REP_EXTERNAL_PRINCIPAL_NAMES} properties
      * that match the given name or name hint.
@@ -265,7 +409,7 @@
         try {
             Map<String, ? extends PropertyValue> bindings = buildBinding(nameHint, exactMatch);
             String op = (exactMatch) ? " = " : " LIKE ";
-            String statement = "SELECT '" + REP_EXTERNAL_PRINCIPAL_NAMES + "' FROM [rep:User] WHERE PROPERTY(["
+            String statement = "SELECT [" + REP_EXTERNAL_PRINCIPAL_NAMES + "] FROM [rep:User] WHERE PROPERTY(["
                     + REP_EXTERNAL_PRINCIPAL_NAMES + "], '" + PropertyType.TYPENAME_STRING + "')"
                     + op + "$" + BINDING_PRINCIPAL_NAMES + QueryEngine.INTERNAL_SQL2_QUERY;
             return root.getQueryEngine().executeQuery(statement, Query.JCR_SQL2, bindings, namePathMapper.getSessionLocalMappings());
@@ -294,7 +438,34 @@
         }
         return Collections.singletonMap(BINDING_PRINCIPAL_NAMES, PropertyValues.newString(val));
     }
+    
+    private static boolean isDynamicMember(@NotNull String groupPrincipalName, @Nullable Authorizable member) throws RepositoryException {
+        if (member == null || member.isGroup()) {
+            return false;
+        }
 
+        Value[] vs = member.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
+        if (vs == null) {
+            return false;
+        }
+        for (Value v : vs) {
+            if (groupPrincipalName.equals(v.getString())) {
+                return true;
+            }
+        }
+        return false;
+    }
+    
+    private boolean hasDynamicMembershipPrincipals(@NotNull Principal principal) {
+        if (!GroupPrincipals.isGroup(principal)) {
+            return true;
+        } else if (principal instanceof ExternalGroupPrincipal) {
+            return idpNamesWithDynamicGroups.contains(((ExternalGroupPrincipal) principal).getIdpName());
+        } else {
+            return principal instanceof ItemBasedPrincipal;
+        }
+    }
+    
     //------------------------------------------------------< inner classes >---
 
     /**
@@ -304,9 +475,18 @@
      */
     private final class ExternalGroupPrincipal extends PrincipalImpl implements GroupPrincipal {
 
-        private ExternalGroupPrincipal(@NotNull String principalName) {
+        private final String idpName;
+        
+        private ExternalGroupPrincipal(@NotNull String principalName, @Nullable String idpName) {
             super(principalName);
+            this.idpName = Strings.nullToEmpty(idpName);
+        }
 
+        /**
+         * @return The IDP-name of the external user on which this external-group principal name was contained in the rep:externalPrincipalNames property.
+         */
+        private @NotNull String getIdpName() {
+            return idpName;
         }
 
         @Override
@@ -332,19 +512,7 @@
                 }
             } else {
                 Authorizable a = userManager.getAuthorizable(member);
-                if (a == null || a.isGroup()) {
-                    return false;
-                }
-
-                Value[] vs = a.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
-                if (vs == null) {
-                    return false;
-                }
-                for (Value v : vs) {
-                    if (name.equals(v.getString())) {
-                        return true;
-                    }
-                }
+                return isDynamicMember(name, a);
             }
             return false;
         }
@@ -354,7 +522,12 @@
         public Enumeration<? extends Principal> members() {
             Result result = findPrincipals(getName(), true);
             if (result != null) {
-                return Iterators.asEnumeration(new MemberIterator(result));
+                return Iterators.asEnumeration(new MemberIterator<Principal>(result) {
+                    @Override
+                    Principal get(@NotNull Authorizable authorizable) throws RepositoryException {
+                        return authorizable.getPrincipal();
+                    }
+                });
             } else {
                 return Iterators.asEnumeration(Collections.emptyIterator());
             }
@@ -382,6 +555,7 @@
         private final Iterator<? extends ResultRow> rows;
 
         private Iterator<String> propValues = Collections.emptyIterator();
+        private String idpName = "";
 
         private GroupPrincipalIterator(@Nullable String queryString, @NotNull Result queryResult) {
             this.queryString = queryString;
@@ -392,7 +566,9 @@
         protected @Nullable Principal getNext() {
             if (!propValues.hasNext()) {
                 if (rows.hasNext()) {
-                    propValues = Iterators.filter(rows.next().getValue(REP_EXTERNAL_PRINCIPAL_NAMES).getValue(Type.STRINGS).iterator(), Objects::nonNull);
+                    ResultRow row = rows.next();
+                    propValues = Iterators.filter(row.getValue(REP_EXTERNAL_PRINCIPAL_NAMES).getValue(Type.STRINGS).iterator(), Objects::nonNull);
+                    idpName = getIdpName(row);
                 } else {
                     propValues = Collections.emptyIterator();
                 }
@@ -401,7 +577,7 @@
                 String principalName = propValues.next();
                 if (!processed.contains(principalName) && matchesQuery(principalName) ) {
                     processed.add(principalName);
-                    return new ExternalGroupPrincipal(principalName);
+                    return new ExternalGroupPrincipal(principalName, idpName);
                 }
             }
             return null;
@@ -427,8 +603,9 @@
      * exact name of the external group principal.
      *
      * @see ExternalGroupPrincipal#members()
+     * @see ExternalGroupPrincipalProvider#getMembers(Group, boolean) 
      */
-    private final class MemberIterator extends AbstractLazyIterator<Principal> {
+    private abstract class MemberIterator<T> extends AbstractLazyIterator<T> {
 
         /**
          * The query results containing the path of the user accounts
@@ -436,19 +613,19 @@
          * {@link #REP_EXTERNAL_PRINCIPAL_NAMES} property values.
          */
         private final Iterator<? extends ResultRow> rows;
-
+        
         private MemberIterator(@NotNull Result queryResult) {
             rows = queryResult.getRows().iterator();
         }
 
         @Override
-        protected @Nullable Principal getNext() {
+        protected @Nullable T getNext() {
             while (rows.hasNext()) {
                 String userPath = rows.next().getPath();
                 try {
                     Authorizable authorizable = userManager.getAuthorizableByPath(userPath);
                     if (authorizable != null) {
-                        return authorizable.getPrincipal();
+                        return get(authorizable);
                     }
                 } catch (RepositoryException e) {
                     log.debug("{}", e.getMessage());
@@ -456,5 +633,7 @@
             }
             return null;
         }
+        
+        abstract T get(@NotNull Authorizable authorizable) throws RepositoryException;
     }
 }
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfiguration.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfiguration.java
index f3606ac..6c767c4 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfiguration.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfiguration.java
@@ -107,6 +107,7 @@
     private SyncHandlerMappingTracker syncHandlerMappingTracker;
     
     private ServiceRegistration automembershipRegistration;
+    private ServiceRegistration dynamicMembershipRegistration;
 
     private ExternalIdentityMonitor monitor = ExternalIdentityMonitor.NOOP;
 
@@ -131,7 +132,7 @@
     public PrincipalProvider getPrincipalProvider(Root root, NamePathMapper namePathMapper) {
         if (dynamicMembershipEnabled()) {
             UserConfiguration uc = getSecurityProvider().getConfiguration(UserConfiguration.class);
-            return new ExternalGroupPrincipalProvider(root, uc, namePathMapper, syncConfigTracker.getAutoMembership(), syncConfigTracker.getAutoMembershipConfig());
+            return new ExternalGroupPrincipalProvider(root, uc.getUserManager(root, namePathMapper), namePathMapper, syncConfigTracker);
         } else {
             return EmptyPrincipalProvider.INSTANCE;
         }
@@ -154,15 +155,20 @@
     @Override
     public List<? extends ValidatorProvider> getValidators(@NotNull String workspaceName, @NotNull Set<Principal> principals, @NotNull MoveTracker moveTracker) {
         boolean isSystem = new SystemPrincipalConfig(getPrincipalNames()).containsSystemPrincipal(principals);
+       
+        ImmutableList.Builder<ValidatorProvider> vps = new ImmutableList.Builder<>();
+        vps.add(new ExternalIdentityValidatorProvider(isSystem, protectedExternalIds()));
+
+        Set<String> idpNamesWithDynamicGroups = getIdpNamesWithDynamicGroups();
+        if (!idpNamesWithDynamicGroups.isEmpty()) {
+            vps.add(new DynamicGroupValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), idpNamesWithDynamicGroups));
+        }
         
-        ValidatorProvider idValidatorProvider = new ExternalIdentityValidatorProvider(isSystem, protectedExternalIds());
         IdentityProtectionType ipt = getIdentityProtectionType();
         if (ipt != IdentityProtectionType.NONE && !isSystem) {
-            ValidatorProvider extUserValidatorProvider = new ExternalUserValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), ipt);
-            return ImmutableList.of(idValidatorProvider, extUserValidatorProvider);
-        } else {
-            return Collections.singletonList(idValidatorProvider);
+            vps.add(new ExternalUserValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), ipt));
         }
+        return vps.build();
     }
 
     @NotNull
@@ -196,6 +202,7 @@
         syncConfigTracker.open();
         
         automembershipRegistration = bundleContext.registerService(DynamicMembershipService.class.getName(), new AutomembershipService(syncConfigTracker), null);
+        dynamicMembershipRegistration = bundleContext.registerService(DynamicMembershipService.class.getName(), new DynamicGroupMembershipService(syncConfigTracker), null);
     }
 
     @SuppressWarnings("UnusedDeclaration")
@@ -211,6 +218,9 @@
         if (automembershipRegistration != null) {
             automembershipRegistration.unregister();
         }
+        if (dynamicMembershipRegistration != null) {
+            dynamicMembershipRegistration.unregister();
+        }
     }
 
     //------------------------------------------------------------< private >---
@@ -218,6 +228,10 @@
     private boolean dynamicMembershipEnabled() {
         return syncConfigTracker != null && syncConfigTracker.isEnabled();
     }
+    
+    private @NotNull Set<String> getIdpNamesWithDynamicGroups() {
+        return (syncConfigTracker == null) ? Collections.emptySet() : syncConfigTracker.getIdpNamesWithDynamicGroups();
+    }
 
     private boolean protectedExternalIds() {
         return getParameters().getConfigValue(ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDS, ExternalIdentityConstants.DEFAULT_PROTECT_EXTERNAL_IDS);
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/SyncConfigTracker.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/SyncConfigTracker.java
index 02187b0..ca615b7 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/SyncConfigTracker.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/SyncConfigTracker.java
@@ -33,7 +33,9 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * {@code ServiceTracker} to detect any {@link SyncHandler} that has
@@ -50,10 +52,57 @@
         this.mappingTracker = mappingTracker;
     }
 
+    /**
+     * @return {@code true} if dynamic membership is enabled for at least one registered sync-handler; {@code false} otherwise.
+     */
     boolean isEnabled() {
         return getReferences().length > 0;
     }
 
+    /**
+     * @return {@code true} if any of the register {@code SynchHandler} services has {@link DefaultSyncConfigImpl#PARAM_GROUP_DYNAMIC_GROUPS} 
+     * enabled on top of the dynamic-membership.
+     */
+    boolean hasDynamicGroupsEnabled() {
+        if (!isEnabled()) {
+            return false;
+        }
+        for (ServiceReference ref : getReferences()) {
+            if (PropertiesUtil.toBoolean(ref.getProperty(DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS), DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns the set of IDP names from the {@code org.apache.jackrabbit.oak.spi.security.authentication.external.impl.SyncHandlerMapping} 
+     * for which the associated {@code SyncHandler} is configured to have both {@link DefaultSyncConfigImpl#PARAM_USER_DYNAMIC_MEMBERSHIP dynamic membership} 
+     * and {@link DefaultSyncConfigImpl#PARAM_GROUP_DYNAMIC_GROUPS dynamic groups} enabled.
+     * 
+     * @return a set of IDP names that have dynamic groups enabled in addition to dynamic membership. If {@link #hasDynamicGroupsEnabled()}
+     * returns false, this method will return an empty set.
+     * @see #hasDynamicGroupsEnabled() 
+     */
+    @NotNull
+    Set<String> getIdpNamesWithDynamicGroups() {
+        if (!isEnabled()) {
+            return Collections.emptySet();
+        }
+
+        ServiceReference[] serviceReferences = getServiceReferences();
+        Set<String> idpNames = new HashSet<>(serviceReferences.length);
+        for (ServiceReference ref : serviceReferences) {
+            if (PropertiesUtil.toBoolean(ref.getProperty(DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS), DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT)) {
+                String syncHandlerName = PropertiesUtil.toString(ref.getProperty(DefaultSyncConfigImpl.PARAM_NAME), DefaultSyncConfigImpl.PARAM_NAME_DEFAULT);
+                for (String idpName : mappingTracker.getIdpNames(syncHandlerName)) {
+                    idpNames.add(idpName);
+                }
+            }
+        }
+        return idpNames;
+    }
+
     @NotNull
     Map<String, String[]> getAutoMembership() {
         Map<String, String[]> autoMembership = new HashMap<>();
@@ -61,21 +110,35 @@
             String syncHandlerName = PropertiesUtil.toString(ref.getProperty(DefaultSyncConfigImpl.PARAM_NAME), DefaultSyncConfigImpl.PARAM_NAME_DEFAULT);
             String[] userAuthMembership = PropertiesUtil.toStringArray(ref.getProperty(DefaultSyncConfigImpl.PARAM_USER_AUTO_MEMBERSHIP), new String[0]);
             String[] groupAuthMembership = PropertiesUtil.toStringArray(ref.getProperty(DefaultSyncConfigImpl.PARAM_GROUP_AUTO_MEMBERSHIP), new String[0]);
-            String[] membership =  ObjectArrays.concat(userAuthMembership, groupAuthMembership, String.class);
 
-            for (String idpName : mappingTracker.getIdpNames(syncHandlerName)) {
-                String[] previous = autoMembership.put(idpName, membership);
-                if (previous != null) {
-                    String msg = (Arrays.equals(previous, membership)) ? "Duplicate" : "Colliding";
-                    String prev = Arrays.toString(previous);
-                    String mbrs = Arrays.toString(membership);
-                    log.debug("{} auto-membership configuration for IDP '{}'; replacing previous values {} by {} defined by SyncHandler '{}'",
-                            msg, idpName, prev, mbrs, syncHandlerName);
-                }
-            }
+            populateMap(syncHandlerName, ObjectArrays.concat(userAuthMembership, groupAuthMembership, String.class), autoMembership);
         }
         return autoMembership;
     }
+
+    @NotNull
+    Map<String, String[]> getGroupAutoMembership() {
+        Map<String, String[]> autoMembership = new HashMap<>();
+        for (ServiceReference ref : getReferences()) {
+            String syncHandlerName = PropertiesUtil.toString(ref.getProperty(DefaultSyncConfigImpl.PARAM_NAME), DefaultSyncConfigImpl.PARAM_NAME_DEFAULT);
+            String[] groupAuthMembership = PropertiesUtil.toStringArray(ref.getProperty(DefaultSyncConfigImpl.PARAM_GROUP_AUTO_MEMBERSHIP), new String[0]);
+            populateMap(syncHandlerName, groupAuthMembership, autoMembership);
+        }
+        return autoMembership;
+    }
+
+    private void populateMap(@NotNull String syncHandlerName, @NotNull String[] autoMembershipParam, @NotNull Map<String, String[]> autoMembership) {
+        for (String idpName : mappingTracker.getIdpNames(syncHandlerName)) {
+            String[] previous = autoMembership.put(idpName, autoMembershipParam);
+            if (previous != null) {
+                String msg = (Arrays.equals(previous, autoMembershipParam)) ? "Duplicate" : "Colliding";
+                String prev = Arrays.toString(previous);
+                String mbrs = Arrays.toString(autoMembershipParam);
+                log.debug("{} group auto-membership configuration for IDP '{}'; replacing previous values {} by {} defined by SyncHandler '{}'",
+                        msg, idpName, prev, mbrs, syncHandlerName);
+            }
+        }
+    }
     
     @NotNull 
     Map<String, AutoMembershipConfig> getAutoMembershipConfig() {
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/AbstractExternalAuthTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/AbstractExternalAuthTest.java
index 80ce632..d0d8af1 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/AbstractExternalAuthTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/AbstractExternalAuthTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.jackrabbit.oak.spi.security.authentication.external;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.Sets;
 import org.apache.jackrabbit.api.security.user.Authorizable;
@@ -31,7 +32,11 @@
 import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
 import org.apache.jackrabbit.oak.spi.security.authentication.SystemSubject;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncHandler;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalLoginModuleFactory;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.SyncHandlerMapping;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.ExternalPrincipalConfiguration;
 import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
 import org.jetbrains.annotations.NotNull;
@@ -45,12 +50,15 @@
 import java.util.Calendar;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
 
 /**
  * Abstract base test for external-authentication tests.
@@ -183,6 +191,25 @@
         return syncConfig;
     }
 
+    protected DefaultSyncHandler registerSyncHandler(@NotNull Map<String, Object> syncConfigMap, @NotNull String idpName) {
+        context.registerService(SyncHandlerMapping.class, new ExternalLoginModuleFactory(), ImmutableMap.of(
+                SyncHandlerMapping.PARAM_IDP_NAME, idpName,
+                SyncHandlerMapping.PARAM_SYNC_HANDLER_NAME, syncConfigMap.get(DefaultSyncConfigImpl.PARAM_NAME)
+        ));
+        return (DefaultSyncHandler) context.registerService(SyncHandler.class, new DefaultSyncHandler(), syncConfigMap);
+    }
+    
+    protected @NotNull Map<String, Object> syncConfigAsMap() {
+        ImmutableMap.Builder<String, Object> builder = new ImmutableMap.Builder<>();
+        builder.put(DefaultSyncConfigImpl.PARAM_NAME, syncConfig.getName())
+                .put(DefaultSyncConfigImpl.PARAM_USER_DYNAMIC_MEMBERSHIP, syncConfig.user().getDynamicMembership())
+                .put(DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH, syncConfig.user().getMembershipNestingDepth())
+                .put(DefaultSyncConfigImpl.PARAM_USER_AUTO_MEMBERSHIP, syncConfig.user().getAutoMembership().toArray(new String[0]))
+                .put(DefaultSyncConfigImpl.PARAM_GROUP_AUTO_MEMBERSHIP, syncConfig.group().getAutoMembership().toArray(new String[0]))
+                .put(DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS, syncConfig.group().getDynamicGroups());
+        return builder.build();
+    }
+
     @NotNull
     protected Root getSystemRoot() throws Exception {
         if (systemRoot == null) {
@@ -205,4 +232,31 @@
             now = Calendar.getInstance().getTimeInMillis();
         }
     }
+
+    protected static Set<ExternalIdentityRef> getExpectedSyncedGroupRefs(long membershipNestingDepth, @NotNull ExternalIdentityProvider idp, @NotNull ExternalIdentity extId) throws Exception {
+        if (membershipNestingDepth <= 0) {
+            return Collections.emptySet();
+        }
+
+        Set<ExternalIdentityRef> groupRefs = new HashSet<>();
+        getExpectedSyncedGroupRefs(membershipNestingDepth, idp, extId, groupRefs);
+        return groupRefs;
+    }
+
+    protected static Set<String> getExpectedSyncedGroupIds(long membershipNestingDepth, @NotNull ExternalIdentityProvider idp, @NotNull ExternalIdentity extId) throws Exception {
+        Set<ExternalIdentityRef> groupRefs = getExpectedSyncedGroupRefs(membershipNestingDepth, idp, extId);
+        return groupRefs.stream().map(ExternalIdentityRef::getId).collect(Collectors.toSet());
+    }
+    
+    private static void getExpectedSyncedGroupRefs(long membershipNestingDepth, @NotNull ExternalIdentityProvider idp, 
+                                                  @NotNull ExternalIdentity extId, @NotNull Set<ExternalIdentityRef> groupRefs) throws Exception {
+        extId.getDeclaredGroups().forEach(groupRefs::add);
+        if (membershipNestingDepth > 1) {
+            for (ExternalIdentityRef ref : extId.getDeclaredGroups()) {
+                ExternalIdentity id = idp.getIdentity(ref);
+                assertNotNull(id);
+                getExpectedSyncedGroupRefs(membershipNestingDepth-1, idp, id, groupRefs);
+            }
+        }
+    }
 }
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfigTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfigTest.java
index 6703a47..707d136 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfigTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/basic/DefaultSyncConfigTest.java
@@ -114,6 +114,12 @@
 
         assertNotNull(groupConfig);
         assertAuthorizableConfig(groupConfig);
+        
+        assertFalse(groupConfig.getDynamicGroups());
+        assertSame(groupConfig, groupConfig.setDynamicGroups(true));
+        assertTrue(groupConfig.getDynamicGroups());
+        assertSame(groupConfig, groupConfig.setDynamicGroups(false));
+        assertFalse(groupConfig.getDynamicGroups());
     }
     
     @Test
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImplTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImplTest.java
index d81b111..f111a72 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImplTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DefaultSyncConfigImplTest.java
@@ -30,6 +30,8 @@
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_DISABLE_MISSING_USERS_DEFAULT;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_ENABLE_RFC7613_USERCASE_MAPPED_PROFILE_DEFAULT;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_AUTO_MEMBERSHIP_DEFAULT;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_EXPIRATION_TIME_DEFAULT;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_PATH_PREFIX_DEFAULT;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_PROPERTY_MAPPING_DEFAULT;
@@ -99,6 +101,18 @@
         assertArrayEquals(PARAM_GROUP_AUTO_MEMBERSHIP_DEFAULT, groupConfig.getAutoMembership().toArray(new String[0]));
         assertEquals(getMapping(PARAM_GROUP_PROPERTY_MAPPING_DEFAULT), groupConfig.getPropertyMapping());
         assertEquals(PARAM_GROUP_PATH_PREFIX_DEFAULT, groupConfig.getPathPrefix());
+        assertEquals(PARAM_GROUP_DYNAMIC_GROUPS_DEFAULT, groupConfig.getDynamicGroups());
+    }
+
+    @Test
+    public void testGroup() {
+        ConfigurationParameters params = ConfigurationParameters.of(PARAM_GROUP_DYNAMIC_GROUPS, true);
+        DefaultSyncConfig.Group groupConfig = DefaultSyncConfigImpl.of(params).group();
+        assertTrue(groupConfig.getDynamicGroups());
+        
+        params = ConfigurationParameters.of(PARAM_USER_ENFORCE_DYNAMIC_MEMBERSHIP, false);
+        groupConfig = DefaultSyncConfigImpl.of(params).group();
+        assertFalse(groupConfig.getDynamicGroups());
     }
 
     @Test
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicAutomembershipTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicAutomembershipTest.java
new file mode 100644
index 0000000..e6ef76c
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicAutomembershipTest.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl;
+
+import com.google.common.collect.Lists;
+import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import javax.jcr.RepositoryException;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.stream.StreamSupport;
+
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(Parameterized.class)
+public class DynamicAutomembershipTest extends DynamicSyncContextTest {
+
+    @Parameterized.Parameters(name = "name={1}")
+    public static Collection<Object[]> parameters() {
+        return Lists.newArrayList(
+                new Object[] { false, "DynamicGroups=false" },
+                new Object[] { true, "DynamicGroups=true" });
+    }
+    
+    private final boolean hasDynamicGroups;
+    
+    private Group group1;
+    private Group group2;
+    private Group group3;
+    private Group groupInherited;
+
+    public DynamicAutomembershipTest(boolean hasDynamicGroups, @NotNull String name) {
+        this.hasDynamicGroups = hasDynamicGroups;
+    }
+
+    @Override
+    public void before() throws Exception {
+        super.before();
+        
+        group1 = userManager.getAuthorizable("group1", Group.class);
+        group2 = userManager.getAuthorizable("group2", Group.class);
+        group3 = userManager.getAuthorizable("group3", Group.class);
+
+        groupInherited = userManager.createGroup("groupInherited");
+        groupInherited.addMembers("group1", "group2");
+        r.commit();
+    }
+
+    @Override
+    protected @NotNull DefaultSyncConfig createSyncConfig() {
+        DefaultSyncConfig config = super.createSyncConfig();
+        config.group().setDynamicGroups(hasDynamicGroups);
+        config.group().setAutoMembership("group1");
+        config.user().setAutoMembership("group2", "group3");
+        return config;
+    }
+
+    private static boolean containsGroup(@NotNull Iterator<Group> membership, @NotNull Group groupToTest) throws RepositoryException {
+        String groupIdToTest = groupToTest.getID();
+        Iterable<Group> iterable = () -> membership;
+        return StreamSupport.stream(iterable.spliterator(), false).anyMatch(group -> {
+            try {
+                return groupIdToTest.equals(group.getID());
+            } catch (RepositoryException repositoryException) {
+                return false;
+            }
+        });
+    }
+
+    @Override
+    @Test
+    public void testSyncExternalGroup() throws Exception {
+        ExternalGroup extGroup = idp.getGroup(GROUP_ID);
+        assertNotNull(extGroup);
+        
+        syncContext.sync(extGroup);
+        
+        if (hasDynamicGroups) {
+            Group gr = userManager.getAuthorizable(extGroup.getId(), Group.class);
+            assertNotNull(gr);
+            assertTrue(r.hasPendingChanges());
+
+            // verify group1-externalGroup relationship
+            assertTrue(containsGroup(gr.declaredMemberOf(), group1));
+            assertTrue(containsGroup(gr.memberOf(), group1));
+            assertTrue(group1.isDeclaredMember(gr));
+            assertTrue(group1.isMember(gr));
+            assertFalse(hasStoredMembershipInformation(r.getTree(group1.getPath()), r.getTree(gr.getPath())));
+
+            // user-specific automembership must not be reflected.
+            for (Group g : new Group[] {group2, group3}) {
+                assertFalse(g.isDeclaredMember(gr));
+                assertFalse(g.isMember(gr));
+            }
+            
+            // verify inheritedGroup-externalGroup relationship
+            assertFalse(containsGroup(gr.declaredMemberOf(), groupInherited));
+            assertTrue(containsGroup(gr.memberOf(), groupInherited));
+            assertFalse(groupInherited.isDeclaredMember(gr));
+            assertTrue(groupInherited.isMember(gr));
+        } else {
+            assertNull(userManager.getAuthorizable(extGroup.getId()));
+            assertFalse(r.hasPendingChanges());
+        }
+    }
+
+    @Override
+    @Test
+    public void testSyncExternalUserExistingGroups() throws Exception {
+        // verify group membership of the previously synced user
+        Authorizable user = userManager.getAuthorizable(previouslySyncedUser.getId());
+        assertSyncedMembership(userManager, user, previouslySyncedUser, Long.MAX_VALUE);
+
+        // resync the previously synced user with dynamic-membership enabled.
+        syncContext.setForceUserSync(true);
+        syncConfig.user().setMembershipExpirationTime(-1);
+        syncContext.sync(previouslySyncedUser);
+
+        Tree t = r.getTree(user.getPath());
+        
+        assertEquals(hasDynamicGroups, t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+        assertSyncedMembership(userManager, user, previouslySyncedUser);
+        
+        // verify automembership of the external user
+        for (Group gr : new Group[] {group1, group2, group3}) {
+            assertTrue(gr.isDeclaredMember(user));
+            assertTrue(gr.isMember(user));
+            containsGroup(user.declaredMemberOf(), gr);
+            containsGroup(user.memberOf(), gr);
+            
+            // if 'dynamic groups' are enabled the previously synced membership information of the local group 
+            // must be migrated to dynamic membership.
+            boolean hasStoredMembership = hasStoredMembershipInformation(r.getTree(gr.getPath()), r.getTree(user.getPath()));
+            if (hasDynamicGroups) {
+                assertFalse(hasStoredMembership);
+            } else {
+                boolean expected = syncConfig.user().getAutoMembership().contains(gr.getID());
+                assertEquals(expected, hasStoredMembership);
+            }
+        }
+        
+        // nested membership from auto-membership groups
+        assertFalse(groupInherited.isDeclaredMember(user));
+        assertTrue(groupInherited.isMember(user));
+
+        Group previousGroup = userManager.getAuthorizable(previouslySyncedUser.getDeclaredGroups().iterator().next().getId(), Group.class);
+        assertNotNull(previousGroup);
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicGroupsTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicGroupsTest.java
new file mode 100644
index 0000000..9e9c7ae
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicGroupsTest.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import org.apache.jackrabbit.api.security.principal.ItemBasedPrincipal;
+import org.apache.jackrabbit.api.security.principal.PrincipalManager;
+import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.api.security.user.User;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncResult;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import javax.jcr.RepositoryException;
+import java.security.Principal;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_MEMBERS;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(Parameterized.class)
+public class DynamicGroupsTest extends DynamicSyncContextTest {
+
+    @Parameterized.Parameters(name = "name={1}")
+    public static Collection<Object[]> parameters() {
+        return Lists.newArrayList(
+                new Object[] { DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT, "Membership-Nesting-Depth=0" },
+                new Object[] { DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT+1, "Membership-Nesting-Depth=1" },
+                new Object[] { DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT+2, "Membership-Nesting-Depth=2" });
+    }
+    
+    private final long membershipNestingDepth;
+
+    public DynamicGroupsTest(long membershipNestingDepth, @NotNull String name) {
+        this.membershipNestingDepth = membershipNestingDepth;
+    }
+    
+    @Override
+    protected @NotNull DefaultSyncConfig createSyncConfig() {
+        DefaultSyncConfig config = super.createSyncConfig();
+        config.group().setDynamicGroups(true);
+        config.user().setMembershipNestingDepth(membershipNestingDepth);
+        return config;
+    }
+
+    @Test
+    public void testSyncExternalGroup() throws Exception {
+        ExternalGroup gr = idp.getGroup(GROUP_ID);
+        
+        syncContext.sync(gr);
+        assertNotNull(userManager.getAuthorizable(gr.getId()));
+        assertTrue(r.hasPendingChanges());
+    }
+
+    @Test
+    public void testSyncExternalUserExistingGroups() throws Exception {
+        // verify group membership of the previously synced user
+        Authorizable a = userManager.getAuthorizable(previouslySyncedUser.getId());
+        assertSyncedMembership(userManager, a, previouslySyncedUser, Long.MAX_VALUE);
+
+        // resync the previously synced user with dynamic-membership enabled.
+        syncContext.setForceUserSync(true);
+        syncConfig.user().setMembershipExpirationTime(-1);
+        syncContext.sync(previouslySyncedUser);
+
+        Tree t = r.getTree(a.getPath());
+        
+        // dynamic-group option forces migration of previously synced groups
+        assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+
+        assertSyncedMembership(userManager, a, previouslySyncedUser);
+    }
+
+    @Test
+    public void testSyncMembershipWithEmptyExistingGroups() throws Exception {
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+        
+        // sync user with modified membership => must be reflected
+        // 1. empty set of declared groups
+        ExternalUser mod = new TestUserWithGroupRefs(previouslySyncedUser, ImmutableSet.of());
+        syncContext.syncMembership(mod, a, membershipNestingDepth);
+
+        assertSyncedMembership(userManager, a, mod, membershipNestingDepth);
+        Tree t = r.getTree(a.getPath());
+        assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+        assertEquals(0, t.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES).count());
+    }
+
+    @Test
+    public void testSyncMembershipWithChangedExistingGroups() throws Exception {
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+
+        // sync user with modified membership => must be reflected
+        // 2. set with different groups that defined on IDP
+        ExternalUser mod = new TestUserWithGroupRefs(previouslySyncedUser, ImmutableSet.of(
+                idp.getGroup("a").getExternalId(),
+                idp.getGroup("aa").getExternalId(),
+                idp.getGroup("secondGroup").getExternalId()));
+        syncContext.syncMembership(mod, a, membershipNestingDepth);
+
+        Tree t = r.getTree(a.getPath());
+        assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+
+        Set<String> groupIds = getExpectedSyncedGroupIds(membershipNestingDepth, idp, mod);
+        assertEquals(groupIds.size(), t.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES).count());
+        
+        assertMigratedGroups(previouslySyncedUser, t);
+        if (membershipNestingDepth == 0) {
+            for (String grId : groupIds) {
+                assertNull(userManager.getAuthorizable(grId));
+            }
+        } else {
+            assertMigratedGroups(mod, t);
+        }
+    }
+
+    private void assertMigratedGroups(@NotNull ExternalIdentity externalIdentity, @NotNull Tree userTree) throws Exception {
+        for (ExternalIdentityRef ref : externalIdentity.getDeclaredGroups()) {
+            Group gr = userManager.getAuthorizable(ref.getId(), Group.class);
+            assertNotNull(gr);
+            assertFalse(hasStoredMembershipInformation(r.getTree(gr.getPath()), userTree));
+        }
+    }
+    
+    @Test
+    public void testSyncNewGroup() throws Exception {
+        String id = "newGroup";
+        TestIdentityProvider.TestGroup gr = new TestIdentityProvider.TestGroup(id, idp.getName());
+        TestIdentityProvider testIDP = (TestIdentityProvider) idp;
+        testIDP.addGroup(gr);
+
+        sync(gr, SyncResult.Status.ADD);
+        assertNotNull(userManager.getAuthorizable(id));
+    }
+    
+    @Test
+    public void testReSyncUserMembershipExpired() throws Exception {
+        boolean forceSyncGroupPrevious = syncContext.isForceGroupSync();
+        boolean forceSyncUserPrevious = syncContext.isForceUserSync();
+        long expTimePrevious = syncConfig.user().getMembershipExpirationTime();
+        try {
+            syncContext.setForceGroupSync(true).setForceUserSync(true);
+            syncConfig.user().setMembershipExpirationTime(-1);
+            
+            // re-sync with dynamic-sync-context and force membership update
+            sync(idp.getUser(PREVIOUS_SYNCED_ID), SyncResult.Status.UPDATE);
+
+            // verify that user and groups have been migrated to dynamic membership/group
+            User user = userManager.getAuthorizable(PREVIOUS_SYNCED_ID, User.class);
+            assertNotNull(user);
+            assertTrue(user.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+            Set<String> extPNames = Arrays.stream(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).filter(Objects::nonNull).map(value -> {
+                try {
+                    return value.getString();
+                } catch (RepositoryException repositoryException) {
+                    return "";
+                }
+            }).collect(Collectors.toSet());
+            
+            // rep:principalNames are migrated using the current membershipNestingDepth (not the one from previous sync) 
+            Set<String> expectedGroupIds = getExpectedSyncedGroupIds(membershipNestingDepth, idp, previouslySyncedUser);
+            assertEquals(expectedGroupIds.size(), extPNames.size());
+
+            // group membership must have moved to dynamic membership
+            // previously synced membership (with different depth) must be adjusted to new value
+            Tree userTree = r.getTree(user.getPath());
+            for (String groupId : getExpectedSyncedGroupIds(PREVIOUS_NESTING_DEPTH, idp, previouslySyncedUser)) {
+                Group gr = userManager.getAuthorizable(groupId, Group.class);
+                // no group must be removed
+                assertNotNull(gr);
+                
+                // with dynamic.group option members must be migrated to dynamic membership even if 'enforce' option is disabled.
+                Tree t = r.getTree(gr.getPath());
+                assertFalse(hasStoredMembershipInformation(t, userTree));
+
+                boolean stillMember = expectedGroupIds.contains(gr.getID());
+                
+                // user-group relationship must be covered by the dynamic-membership provider now
+                assertEquals(stillMember, gr.isDeclaredMember(user));
+                
+                // verify that the group principal name is listed in the ext-principal-names property
+                assertEquals(stillMember, extPNames.contains(gr.getPrincipal().getName()));
+            }
+        } finally {
+            syncContext.setForceGroupSync(forceSyncGroupPrevious).setForceUserSync(forceSyncUserPrevious);
+            syncConfig.user().setMembershipExpirationTime(expTimePrevious);
+        }
+    }
+
+    @Test
+    public void testReSyncGroupCreatedPriorToEnabledDynamic() throws Exception {
+        boolean forceSyncPrevious = syncContext.isForceGroupSync();
+        String groupId = previouslySyncedUser.getDeclaredGroups().iterator().next().getId();
+        try {
+            // re-sync with dynamic-sync-context
+            syncContext.setForceGroupSync(true);
+            sync(idp.getGroup(groupId), SyncResult.Status.UPDATE);
+
+            // NOTE: group membership is NOT touched upon group-sync
+            Group gr = userManager.getAuthorizable(groupId, Group.class);
+            assertNotNull(gr);
+
+            User user = userManager.getAuthorizable(PREVIOUS_SYNCED_ID, User.class);
+            assertNotNull(user);
+            // note: previous sync was executed with deep nesting -> current nesting depth doesn't make a difference
+            assertTrue(gr.isMember(user));
+
+            // since group-sync does not touch membership information the group members data must still be present 
+            // with the group node.
+            Tree t = r.getTree(gr.getPath());
+            assertTrue(t.hasProperty(REP_MEMBERS));
+            assertFalse(t.hasChild(UserConstants.REP_MEMBERS_LIST));
+        } finally {
+            syncContext.setForceGroupSync(forceSyncPrevious);
+        }
+    }
+    
+    @Test
+    public void testGroupPrincipalLookup() throws Exception {
+        ExternalUser externalUser = idp.getUser(USER_ID);
+        sync(externalUser, SyncResult.Status.ADD);
+
+        PrincipalManager principalManager = getPrincipalManager(r);
+        for (ExternalIdentityRef ref : getExpectedSyncedGroupRefs(membershipNestingDepth, idp, externalUser)) {
+            String principalName = idp.getIdentity(ref).getPrincipalName();
+            Principal p = principalManager.getPrincipal(principalName);
+            assertNotNull(p);
+            // verify that this principal has been returned by the user-principal-provider and not the external-group-principal-provider.
+            assertTrue(p instanceof ItemBasedPrincipal);
+            
+            Authorizable group = userManager.getAuthorizable(p);
+            assertNotNull(group);
+            assertTrue(group.isGroup());
+            assertEquals(ref.getId(), group.getID());
+        }
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContextTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContextTest.java
index 35323c4..864c5b1 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContextTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/DynamicSyncContextTest.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
 import com.google.common.collect.Sets;
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.Group;
@@ -28,6 +29,7 @@
 import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.AbstractExternalAuthTest;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
@@ -35,7 +37,6 @@
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
-import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncContext;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncException;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncResult;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncedIdentity;
@@ -56,9 +57,12 @@
 import java.util.UUID;
 import java.util.stream.Collectors;
 
+import static org.apache.jackrabbit.JcrConstants.JCR_UUID;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.ID_SECOND_USER;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.ID_TEST_USER;
 import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_MEMBERS;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_MEMBERS_LIST;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
@@ -77,19 +81,35 @@
 
 public class DynamicSyncContextTest extends AbstractExternalAuthTest {
 
+    static final String PREVIOUS_SYNCED_ID = "third";
+    static final long PREVIOUS_NESTING_DEPTH = Long.MAX_VALUE;
+    static final String GROUP_ID = "aaa";
+    
     Root r;
     UserManager userManager;
     ValueFactory valueFactory;
 
     DynamicSyncContext syncContext;
 
+    // the external user identity that has been synchronized before dynamic membership is enabled.
+    ExternalUser previouslySyncedUser;
+
     @Before
     public void before() throws Exception {
         super.before();
         r = getSystemRoot();
+        
+        createAutoMembershipGroups();
+        previouslySyncedUser = syncPriorToDynamicMembership();
+        
         userManager = getUserManager(r);
         valueFactory = getValueFactory(r);
         syncContext = new DynamicSyncContext(syncConfig, idp, userManager, valueFactory);
+        
+        // inject user-configuration as well as sync-handler and sync-hander-mapping to have get dynamic-membership 
+        // providers registered.
+        context.registerInjectActivateService(getUserConfiguration());
+        registerSyncHandler(syncConfigAsMap(), idp.getName());
     }
 
     @After
@@ -102,6 +122,41 @@
         }
     }
 
+    private void createAutoMembershipGroups() throws RepositoryException {
+        DefaultSyncConfig sc = createSyncConfig();
+        UserManager um = getUserManager(r);
+        // create automembership groups
+        for (String id : Iterables.concat(sc.user().getAutoMembership(), sc.group().getAutoMembership())) {
+            um.createGroup(id);
+        }
+    }
+
+    /**
+     * Synchronized a separate user with DefaultSyncContext to test behavior for previously synchronized user/group
+     * with deep membership-nesting => all groups synched
+     */
+    private ExternalUser syncPriorToDynamicMembership() throws Exception {
+        DefaultSyncConfig priorSyncConfig = createSyncConfig();
+        priorSyncConfig.user().setMembershipNestingDepth(PREVIOUS_NESTING_DEPTH);
+        
+        String idpName = idp.getName();
+        TestIdentityProvider tidp = (TestIdentityProvider) idp; 
+        tidp.addGroup(new TestIdentityProvider.TestGroup("ttt", idpName));
+        tidp.addGroup(new TestIdentityProvider.TestGroup("tt", idpName).withGroups("ttt"));
+        tidp.addGroup(new TestIdentityProvider.TestGroup("thirdGroup", idpName).withGroups("tt"));
+        tidp.addUser(new TestIdentityProvider.TestUser(PREVIOUS_SYNCED_ID, idpName).withGroups("thirdGroup"));
+
+        UserManager um = getUserManager(r);
+        DefaultSyncContext ctx = new DefaultSyncContext(priorSyncConfig, idp, um, getValueFactory(r));
+        ExternalUser previouslySyncedUser = idp.getUser(PREVIOUS_SYNCED_ID);
+        assertNotNull(previouslySyncedUser);
+        SyncResult result = ctx.sync(previouslySyncedUser);
+        assertSame(SyncResult.Status.ADD, result.getStatus());
+        ctx.close();
+        r.commit();
+        return previouslySyncedUser;
+    }
+
     @Override
     @NotNull
     protected DefaultSyncConfig createSyncConfig() {
@@ -149,15 +204,65 @@
         }
     }
 
-    static void assertSyncedMembership(@NotNull UserManager userManager,
-                                       @NotNull Authorizable a,
-                                       @NotNull ExternalIdentity externalIdentity) throws Exception {
-        for (ExternalIdentityRef ref : externalIdentity.getDeclaredGroups()) {
+    void assertSyncedMembership(@NotNull UserManager userManager,
+                                @NotNull Authorizable a,
+                                @NotNull ExternalIdentity externalIdentity) throws Exception {
+        assertSyncedMembership(userManager, a, externalIdentity, syncConfig.user().getMembershipNestingDepth());
+    }
+
+    void assertSyncedMembership(@NotNull UserManager userManager,
+                                @NotNull Authorizable a,
+                                @NotNull ExternalIdentity externalIdentity,
+                                long membershipNestingDepth) throws Exception {
+        Iterable<ExternalIdentityRef> declaredGroupRefs = externalIdentity.getDeclaredGroups();
+        Set<ExternalIdentityRef> expectedGroupRefs = getExpectedSyncedGroupRefs(membershipNestingDepth, idp, externalIdentity);
+        for (ExternalIdentityRef ref : expectedGroupRefs) {
             Group gr = userManager.getAuthorizable(ref.getId(), Group.class);
             assertNotNull(gr);
             assertTrue(gr.isMember(a));
+            assertTrue(Iterators.contains(a.memberOf(), gr));
+            
+            if (Iterables.contains(declaredGroupRefs, ref)) {
+                assertTrue(gr.isDeclaredMember(a));
+                assertTrue(Iterators.contains(a.declaredMemberOf(), gr));
+            }
         }
     }
+    
+    void assertDeclaredGroups(@NotNull ExternalUser externalUser) throws Exception {
+        Set<ExternalIdentityRef> expectedGroupRefs = getExpectedSyncedGroupRefs(syncConfig.user().getMembershipNestingDepth(), idp, externalUser);
+        for (ExternalIdentityRef ref : expectedGroupRefs) {
+            Authorizable gr = userManager.getAuthorizable(ref.getId());
+            if (syncConfig.group().getDynamicGroups()) {
+                assertNotNull(gr);
+            } else {
+                assertNull(gr);
+            }
+        }
+    }
+
+
+    static boolean hasStoredMembershipInformation(@NotNull Tree groupTree, @NotNull Tree memberTree) {
+        String ref = TreeUtil.getString(memberTree, JCR_UUID);
+        assertNotNull(ref);
+
+        if (containsMemberRef(groupTree, ref)) {
+            return true;
+        }
+        if (groupTree.hasChild(REP_MEMBERS_LIST)) {
+            for (Tree t : groupTree.getChild(REP_MEMBERS_LIST).getChildren()) {
+                if (containsMemberRef(t, ref)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean containsMemberRef(@NotNull Tree tree, @NotNull String ref) {
+        Iterable<String> memberRefs = TreeUtil.getStrings(tree, REP_MEMBERS);
+        return memberRefs != null && Iterables.contains(memberRefs, ref);
+    }
 
     @Test(expected = IllegalArgumentException.class)
     public void testSyncExternalIdentity() throws Exception {
@@ -169,7 +274,9 @@
         ExternalUser externalUser = idp.getUser(USER_ID);
         sync(externalUser, SyncResult.Status.ADD);
 
-        assertNotNull(userManager.getAuthorizable(USER_ID));
+        Authorizable a = userManager.getAuthorizable(USER_ID);
+        assertNotNull(a);
+        assertDeclaredGroups(externalUser);
     }
 
     @Test
@@ -223,30 +330,24 @@
 
     @Test
     public void testSyncExternalUserExistingGroups() throws Exception {
-        syncConfig.user().setMembershipNestingDepth(1);
-
-        ExternalUser externalUser = idp.getUser(USER_ID);
-
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-
-        Authorizable a = userManager.getAuthorizable(USER_ID);
-        assertSyncedMembership(userManager, a, externalUser);
-
+        // verify group membership of the previously synced user
+        Authorizable a = userManager.getAuthorizable(previouslySyncedUser.getId());
+        assertSyncedMembership(userManager, a, previouslySyncedUser, Long.MAX_VALUE);
+        
+        // resync the previously synced user with dynamic-membership enabled.
         syncContext.setForceUserSync(true);
         syncConfig.user().setMembershipExpirationTime(-1);
-        syncContext.sync(externalUser);
+        syncContext.sync(previouslySyncedUser);
 
         Tree t = r.getTree(a.getPath());
         assertFalse(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
 
-        assertSyncedMembership(userManager, a, externalUser);
+        assertSyncedMembership(userManager, a, previouslySyncedUser);
     }
 
     @Test
     public void testSyncExternalGroup() throws Exception {
-        ExternalGroup gr = idp.listGroups().next();
+        ExternalGroup gr = idp.getGroup(GROUP_ID);
 
         syncContext.sync(gr);
         assertNull(userManager.getAuthorizable(gr.getId()));
@@ -255,27 +356,27 @@
 
     @Test
     public void testSyncExternalGroupVerifyStatus() throws Exception {
-        ExternalGroup gr = idp.listGroups().next();
+        ExternalGroup gr = idp.getGroup(GROUP_ID);
 
         SyncResult result = syncContext.sync(gr);
-        assertEquals(SyncResult.Status.NOP, result.getStatus());
+        SyncResult.Status expectedStatus = (syncConfig.group().getDynamicGroups()) ? SyncResult.Status.ADD : SyncResult.Status.NOP;
+        assertEquals(expectedStatus, result.getStatus());
 
         result = syncContext.sync(gr);
         assertEquals(SyncResult.Status.NOP, result.getStatus());
 
         syncContext.setForceGroupSync(true);
         result = syncContext.sync(gr);
-        assertEquals(SyncResult.Status.NOP, result.getStatus());
+        expectedStatus = (syncConfig.group().getDynamicGroups()) ? SyncResult.Status.UPDATE : SyncResult.Status.NOP;
+        assertEquals(expectedStatus, result.getStatus());
     }
 
     @Test
     public void testSyncExternalGroupExisting() throws Exception {
         // create an external external group that already has been synced into the repo
-        ExternalGroup externalGroup = idp.listGroups().next();
-        SyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalGroup);
-        ctx.close();
-
+        ExternalGroup externalGroup = idp.getGroup(previouslySyncedUser.getDeclaredGroups().iterator().next().getId());
+        assertNotNull(externalGroup);
+        
         // synchronizing using DynamicSyncContext must update the existing group
         syncContext.setForceGroupSync(true);
         SyncResult result = syncContext.sync(externalGroup);
@@ -312,7 +413,7 @@
         
         DynamicSyncContext ctx = new DynamicSyncContext(syncConfig, idp, um, valueFactory);
         try {
-            ctx.sync(idp.listGroups().next());
+            ctx.sync(idp.getGroup(GROUP_ID));
             fail();
         } catch (SyncException e) {
             assertEquals(ex, e.getCause());
@@ -322,7 +423,7 @@
 
     @Test
     public void testSyncUserByIdUpdate() throws Exception {
-        ExternalIdentity externalId = idp.listUsers().next();
+        ExternalIdentity externalId = idp.getUser(ID_SECOND_USER);
 
         Authorizable a = userManager.createUser(externalId.getId(), null);
         a.setProperty(DefaultSyncContext.REP_EXTERNAL_ID, valueFactory.createValue(externalId.getExternalId().getString()));
@@ -336,26 +437,67 @@
     }
 
     @Test
-    public void testSyncUserIdExistingGroups() throws Exception {
-        ExternalUser externalUser = idp.getUser(USER_ID);
+    public void testPreviouslySyncedIdentities() throws Exception {
+        Authorizable user = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+        assertNotNull(user);
+        assertFalse(user.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+        
+        assertSyncedMembership(userManager, user, previouslySyncedUser, PREVIOUS_NESTING_DEPTH);
+    }
 
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
+    @Test
+    public void testSyncUserIdExistingGroupsMembershipNotExpired() throws Exception {
+        // make sure membership is not expired
+        long previousExpTime = syncConfig.user().getMembershipExpirationTime();
+        DefaultSyncConfig.User uc = syncConfig.user();
+        try {
+            uc.setMembershipExpirationTime(Long.MAX_VALUE);
+            syncContext.setForceUserSync(true);
+            syncContext.sync(previouslySyncedUser.getId());
 
-        Authorizable user = userManager.getAuthorizable(externalUser.getId());
-        for (ExternalIdentityRef ref : externalUser.getDeclaredGroups()) {
-            Group gr = userManager.getAuthorizable(ref.getId(), Group.class);
-            assertTrue(gr.isMember(user));
+            Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+            Tree t = r.getTree(a.getPath());
+            
+            assertFalse(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+            assertSyncedMembership(userManager, a, previouslySyncedUser);
+        } finally {
+            uc.setMembershipExpirationTime(previousExpTime);
         }
+    }
+    
+    @Test
+    public void testSyncUserIdExistingGroups() throws Exception {
+        // mark membership information as expired
+        long previousExpTime = syncConfig.user().getMembershipExpirationTime();
+        DefaultSyncConfig.User uc = syncConfig.user();
+        try {
+            uc.setMembershipExpirationTime(-1);
+            syncContext.setForceUserSync(true);
+            syncContext.sync(previouslySyncedUser.getId());
 
-        syncContext.setForceUserSync(true);
-        syncContext.sync(externalUser.getId());
-
-        Authorizable a = userManager.getAuthorizable(USER_ID);
-        Tree t = r.getTree(a.getPath());
-        assertFalse(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
-        assertSyncedMembership(userManager, a, externalUser);
+            Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+            Tree t = r.getTree(a.getPath());
+            
+            boolean expectedMigration = (uc.getEnforceDynamicMembership() || syncConfig.group().getDynamicGroups());
+            
+            if (expectedMigration) {
+                assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+                int expSize = getExpectedSyncedGroupRefs(uc.getMembershipNestingDepth(), idp, previouslySyncedUser).size();
+                assertEquals(expSize, t.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES).count());
+            } else {
+                assertFalse(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+            }
+            
+            if (uc.getEnforceDynamicMembership() && !syncConfig.group().getDynamicGroups()) {
+                for (String id : getExpectedSyncedGroupIds(uc.getMembershipNestingDepth(), idp, previouslySyncedUser)) {
+                    assertNull(userManager.getAuthorizable(id));
+                }
+            } else {
+                assertSyncedMembership(userManager, a, previouslySyncedUser);
+            }
+        } finally {
+            uc.setMembershipExpirationTime(previousExpTime);
+        }
     }
 
     @Test
@@ -408,42 +550,40 @@
     }
 
     @Test
-    public void testSyncMembershipWithChangedExistingGroups() throws Exception {
-        long nesting = 1;
-        syncConfig.user().setMembershipNestingDepth(nesting);
+    public void testSyncMembershipWithEmptyExistingGroups() throws Exception {
+        long nesting = syncConfig.user().getMembershipNestingDepth();
 
-        ExternalUser externalUser = idp.getUser(USER_ID);
-
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-
-        Authorizable a = userManager.getAuthorizable(externalUser.getId());
-        assertSyncedMembership(userManager, a, externalUser);
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
 
         // sync user with modified membership => must be reflected
         // 1. empty set of declared groups
-        ExternalUser mod = new TestUserWithGroupRefs(externalUser, ImmutableSet.of());
+        ExternalUser mod = new TestUserWithGroupRefs(previouslySyncedUser, ImmutableSet.of());
         syncContext.syncMembership(mod, a, nesting);
-        assertSyncedMembership(userManager, a, mod);
+        assertSyncedMembership(userManager, a, mod, nesting);
+    }
+    
+    @Test
+    public void testSyncMembershipWithChangedExistingGroups() throws Exception {
+        long nesting = syncConfig.user().getMembershipNestingDepth();
 
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+
+        // sync user with modified membership => must be reflected
         // 2. set with different groups that defined on IDP
-        mod = new TestUserWithGroupRefs(externalUser, ImmutableSet.of(
+        ExternalUser mod = new TestUserWithGroupRefs(previouslySyncedUser, ImmutableSet.of(
                         idp.getGroup("a").getExternalId(),
                         idp.getGroup("aa").getExternalId(),
                         idp.getGroup("secondGroup").getExternalId()));
         syncContext.syncMembership(mod, a, nesting);
+        // persist changes to have the modified membership being reflected through assertions that use queries
+        r.commit();
         assertSyncedMembership(userManager, a, mod);
     }
 
     @Test
     public void testSyncMembershipForExternalGroup() throws Exception {
-        ExternalGroup externalGroup = idp.getGroup("a"); // a group that has declaredGroups
-        SyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalGroup);
-        ctx.close();
-        r.commit();
-
+        // previously synced 'third-group' has declaredGroups (i.e. nested membership)
+        ExternalGroup externalGroup = idp.getGroup(previouslySyncedUser.getDeclaredGroups().iterator().next().getId());
         Authorizable gr = userManager.getAuthorizable(externalGroup.getId());
         syncContext.syncMembership(externalGroup, gr, 1);
 
@@ -454,11 +594,11 @@
     @Test
     public void testSyncMembershipWithForeignGroups() throws Exception {
         TestIdentityProvider.TestUser testuser = (TestIdentityProvider.TestUser) idp.getUser(ID_TEST_USER);
-        Set<ExternalIdentityRef> sameIdpGroups = ImmutableSet.copyOf(testuser.getDeclaredGroups());
+        Set<ExternalIdentityRef> sameIdpGroups = getExpectedSyncedGroupRefs(syncConfig.user().getMembershipNestingDepth(), idp, testuser);
 
         TestIdentityProvider.ForeignExternalGroup foreignGroup = new TestIdentityProvider.ForeignExternalGroup();
         testuser.withGroups(foreignGroup.getExternalId());
-        assertFalse(Iterables.elementsEqual(sameIdpGroups, testuser.getDeclaredGroups()));
+        assertNotEquals(sameIdpGroups, testuser.getDeclaredGroups());
 
         sync(testuser, SyncResult.Status.ADD);
 
@@ -475,7 +615,7 @@
     @Test
     public void testSyncMembershipWithUserRef() throws Exception {
         TestIdentityProvider.TestUser testuser = (TestIdentityProvider.TestUser) idp.getUser(ID_TEST_USER);
-        Set<ExternalIdentityRef> groupRefs = ImmutableSet.copyOf(testuser.getDeclaredGroups());
+        Set<ExternalIdentityRef> groupRefs = getExpectedSyncedGroupRefs(syncConfig.user().getMembershipNestingDepth(), idp, testuser);
 
         ExternalUser second = idp.getUser(ID_SECOND_USER);
         testuser.withGroups(second.getExternalId());
@@ -540,23 +680,14 @@
 
     @Test
     public void testConvertToDynamicMembership() throws Exception {
-        ExternalUser externalUser = idp.getUser(USER_ID);
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-        r.commit();
-
-        User user = userManager.getAuthorizable(externalUser.getId(), User.class);
+        User user = userManager.getAuthorizable(PREVIOUS_SYNCED_ID, User.class);
         assertNotNull(user);
         assertFalse(user.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
         
         assertTrue(syncContext.convertToDynamicMembership(user));
         assertTrue(user.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
         
-        for (ExternalIdentityRef ref : externalUser.getDeclaredGroups()) {
-            Group gr = userManager.getAuthorizable(ref.getId(), Group.class);
-            assertNull(gr);
-        }
+        assertDeclaredGroups(previouslySyncedUser);
     }
 
     @Test
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/EnforceDynamicMembershipTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/EnforceDynamicMembershipTest.java
index 9dcc2bf..e4501ed 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/EnforceDynamicMembershipTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/EnforceDynamicMembershipTest.java
@@ -23,10 +23,8 @@
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
-import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncResult;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
-import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncContext;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 import org.junit.Test;
@@ -49,46 +47,25 @@
 
     @Test
     public void testSyncMembershipWithChangedExistingGroups() throws Exception {
-        long nesting = 1;
-        syncConfig.user().setMembershipNestingDepth(nesting);
-
-        ExternalUser externalUser = idp.getUser(USER_ID);
-
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-
-        Authorizable a = userManager.getAuthorizable(externalUser.getId());
-        assertSyncedMembership(userManager, a, externalUser);
-        r.commit();
-
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+        
         // set with different groups than defined on IDP
-        TestUserWithGroupRefs mod = new TestUserWithGroupRefs(externalUser, ImmutableSet.of(
+        TestUserWithGroupRefs mod = new TestUserWithGroupRefs(previouslySyncedUser, ImmutableSet.of(
                 idp.getGroup("a").getExternalId(),
                 idp.getGroup("aa").getExternalId(),
                 idp.getGroup("secondGroup").getExternalId()));
-        syncContext.syncMembership(mod, a, nesting);
+        syncContext.syncMembership(mod, a, 1);
 
         Tree t = r.getTree(a.getPath());
         assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
         assertMigratedGroups(userManager, mod, null);
-        assertMigratedGroups(userManager, externalUser, null);
+        assertMigratedGroups(userManager, previouslySyncedUser, null);
     }
 
     @Test
     public void testSyncExternalUserExistingGroups() throws Exception {
-        syncConfig.user().setMembershipNestingDepth(1);
-
-        ExternalUser externalUser = idp.getUser(USER_ID);
-
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-
-        Authorizable a = userManager.getAuthorizable(USER_ID);
-        assertSyncedMembership(userManager, a, externalUser);
         // add an addition member to one group
-        ExternalIdentityRef grRef = externalUser.getDeclaredGroups().iterator().next();
+        ExternalIdentityRef grRef = previouslySyncedUser.getDeclaredGroups().iterator().next();
         Group gr = userManager.getAuthorizable(grRef.getId(), Group.class);
         gr.addMember(userManager.createGroup("someOtherMember"));
         r.commit();
@@ -97,28 +74,21 @@
         // create a new context to make sure the membership data has expired
         DynamicSyncContext dsc = new DynamicSyncContext(syncConfig, idp, userManager, valueFactory);
         dsc.setForceUserSync(true);
-        assertSame(SyncResult.Status.UPDATE, dsc.sync(externalUser).getStatus());
+        assertSame(SyncResult.Status.UPDATE, dsc.sync(previouslySyncedUser).getStatus());
         
         // membership must have been migrated from group to rep:externalPrincipalNames
         // groups that have no other members left must be deleted.
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
+        assertNotNull(a);
         Tree t = r.getTree(a.getPath());
         assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
-        assertDynamicMembership(externalUser, 1);
-        assertMigratedGroups(userManager, externalUser, grRef);
+        assertDynamicMembership(previouslySyncedUser, 1);
+        assertMigratedGroups(userManager, previouslySyncedUser, grRef);
     }
 
     @Test
     public void testGroupFromDifferentIDP() throws Exception {
-        syncConfig.user().setMembershipNestingDepth(1);
-
-        ExternalUser externalUser = idp.getUser(USER_ID);
-
-        DefaultSyncContext ctx = new DefaultSyncContext(syncConfig, idp, userManager, valueFactory);
-        ctx.sync(externalUser);
-        ctx.close();
-
-        Authorizable a = userManager.getAuthorizable(USER_ID);
-        assertSyncedMembership(userManager, a, externalUser);
+        Authorizable a = userManager.getAuthorizable(PREVIOUS_SYNCED_ID);
         // add as member to a group from a different IDP
         Group gr = userManager.createGroup("anotherGroup");
         gr.addMember(a);
@@ -128,14 +98,15 @@
         // create a new context to make sure the membership data has expired
         DynamicSyncContext dsc = new DynamicSyncContext(syncConfig, idp, userManager, valueFactory);
         dsc.setForceUserSync(true);
-        assertSame(SyncResult.Status.UPDATE, dsc.sync(externalUser).getStatus());        r.commit();
+        assertSame(SyncResult.Status.UPDATE, dsc.sync(previouslySyncedUser).getStatus());       
+        r.commit();
         
         // membership must have been migrated from group to rep:externalPrincipalNames
         // groups that have no other members left must be deleted.
         Tree t = r.getTree(a.getPath());
         assertTrue(t.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
-        assertDynamicMembership(externalUser, 1);
-        assertMigratedGroups(userManager, externalUser, null);
+        assertDynamicMembership(previouslySyncedUser, 1);
+        assertMigratedGroups(userManager, previouslySyncedUser, null);
         
         gr = userManager.getAuthorizable("anotherGroup", Group.class);
         assertNotNull(gr);
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractAutoMembershipTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractAutoMembershipTest.java
index c25a9e0..1a18aa7 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractAutoMembershipTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractAutoMembershipTest.java
@@ -45,6 +45,11 @@
             IDP_INVALID_AM, new String[] {NON_EXISTING_GROUP_ID},
             IDP_MIXED_AM, new String[] {AUTOMEMBERSHIP_GROUP_ID_1, NON_EXISTING_GROUP_ID});
 
+    static final Map<String, String[]> MAPPING_GROUP = ImmutableMap.of(
+            IDP_VALID_AM, new String[] {AUTOMEMBERSHIP_GROUP_ID_3},
+            IDP_INVALID_AM, new String[] {NON_EXISTING_GROUP_ID},
+            IDP_MIXED_AM, new String[] {AUTOMEMBERSHIP_GROUP_ID_3, NON_EXISTING_GROUP_ID}); 
+
     UserManager userManager;
     Group automembershipGroup1;
     Group automembershipGroup2;
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractPrincipalTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractPrincipalTest.java
index e8200e1..4e99910 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractPrincipalTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AbstractPrincipalTest.java
@@ -16,12 +16,10 @@
  */
 package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
 
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import org.apache.jackrabbit.api.security.principal.GroupPrincipal;
 import org.apache.jackrabbit.api.security.user.Group;
 import org.apache.jackrabbit.oak.api.Root;
-import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.AbstractExternalAuthTest;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.AutoMembershipConfig;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
@@ -31,11 +29,12 @@
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncContext;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DynamicSyncContext;
-import org.apache.jackrabbit.oak.spi.security.principal.PrincipalProvider;
 import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
 import org.jetbrains.annotations.NotNull;
 
 import java.security.Principal;
+import java.util.Collections;
+import java.util.Set;
 import java.util.UUID;
 
 import static org.junit.Assert.assertNotNull;
@@ -43,7 +42,7 @@
 
 public abstract class AbstractPrincipalTest extends AbstractExternalAuthTest {
 
-    PrincipalProvider principalProvider;
+    ExternalGroupPrincipalProvider principalProvider;
 
     @Override
     public void before() throws Exception {
@@ -62,17 +61,14 @@
         systemRoot.commit();
 
         root.refresh();
-        principalProvider = createPrincipalProvider();
+        principalProvider = createPrincipalProvider(root, getSecurityProvider().getConfiguration(UserConfiguration.class));
     }
 
     @NotNull
-    private PrincipalProvider createPrincipalProvider() {
-        return createPrincipalProvider(getSecurityProvider().getConfiguration(UserConfiguration.class), getAutoMembership(), getAutoMembershipConfig());
-    }
-
-    @NotNull
-    ExternalGroupPrincipalProvider createPrincipalProvider(@NotNull UserConfiguration uc, @NotNull String[] autoMembership, @NotNull AutoMembershipConfig autoMembershipConfig) {
-        return new ExternalGroupPrincipalProvider(root, uc, NamePathMapper.DEFAULT, ImmutableMap.of(idp.getName(), autoMembership), ImmutableMap.of(idp.getName(), autoMembershipConfig));
+    ExternalGroupPrincipalProvider createPrincipalProvider(@NotNull Root r, @NotNull UserConfiguration uc) {
+        Set<String> idpNamesWithDynamicGroups = getIdpNamesWithDynamicGroups();
+        boolean hasOnlyDynamicGroups = (idpNamesWithDynamicGroups.size() == 1 && idpNamesWithDynamicGroups.contains(idp.getName()));
+        return new ExternalGroupPrincipalProvider(r, uc, getNamePathMapper(), idp.getName(), syncConfig, idpNamesWithDynamicGroups, hasOnlyDynamicGroups);    
     }
 
     @Override
@@ -84,20 +80,24 @@
         return config;
     }
 
-    String[] getAutoMembership() {
+    @NotNull String[] getAutoMembership() {
         return Iterables.toArray(Iterables.concat(syncConfig.user().getAutoMembership(),syncConfig.group().getAutoMembership()), String.class);
     }
     
-    AutoMembershipConfig getAutoMembershipConfig() {
+    @NotNull AutoMembershipConfig getAutoMembershipConfig() {
         return AutoMembershipConfig.EMPTY;
     }
+    
+    @NotNull Set<String> getIdpNamesWithDynamicGroups() {
+        return Collections.emptySet();
+    }
 
-    GroupPrincipal getGroupPrincipal() throws Exception {
+    @NotNull GroupPrincipal getGroupPrincipal() throws Exception {
         ExternalUser externalUser = idp.getUser(USER_ID);
         return getGroupPrincipal(externalUser.getDeclaredGroups().iterator().next());
     }
 
-    GroupPrincipal getGroupPrincipal(@NotNull ExternalIdentityRef ref) throws Exception {
+    @NotNull GroupPrincipal getGroupPrincipal(@NotNull ExternalIdentityRef ref) throws Exception {
         String principalName = idp.getIdentity(ref).getPrincipalName();
         Principal p = principalProvider.getPrincipal(principalName);
 
@@ -107,9 +107,13 @@
         return (GroupPrincipal) p;
     }
 
-    Group createTestGroup() throws Exception {
-        Group gr = getUserManager(root).createGroup("group" + UUID.randomUUID());
-        root.commit();
+    @NotNull Group createTestGroup() throws Exception {
+        return createTestGroup(root);
+    }
+
+    @NotNull Group createTestGroup(@NotNull Root r) throws Exception {
+        Group gr = getUserManager(r).createGroup("group" + UUID.randomUUID());
+        r.commit();
         return gr;
     }
 }
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProviderTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProviderTest.java
index 4db4c62..5939d52 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProviderTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/AutoMembershipProviderTest.java
@@ -16,8 +16,10 @@
  */
 package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
+import com.google.common.collect.Lists;
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.Group;
 import org.apache.jackrabbit.api.security.user.User;
@@ -29,9 +31,12 @@
 import org.jetbrains.annotations.NotNull;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 
 import javax.jcr.RepositoryException;
 import java.text.ParseException;
+import java.util.Collection;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
@@ -52,10 +57,24 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+@RunWith(Parameterized.class)
 public class AutoMembershipProviderTest extends AbstractAutoMembershipTest {
-
+    
+    @Parameterized.Parameters(name = "name={1}")
+    public static Collection<Object[]> parameters() {
+        return Lists.newArrayList(
+                new Object[] { false, "Dynamic-Groups = false" },
+                new Object[] { true, "Dynamic-Groups = true" });
+    }
+    
+    private final boolean dynamicGroupsEnabled;
+    
     private AutoMembershipProvider provider;
 
+    public AutoMembershipProviderTest(boolean dynamicGroupsEnabled, @NotNull String name) {
+        this.dynamicGroupsEnabled = dynamicGroupsEnabled;
+    }
+
     @Before
     public void before() throws Exception {
         super.before();
@@ -73,7 +92,19 @@
     
     @NotNull
     private AutoMembershipProvider createAutoMembershipProvider(@NotNull Root root, @NotNull UserManager userManager) {
-        return new AutoMembershipProvider(root, userManager, getNamePathMapper(), MAPPING, getAutoMembershipConfigMapping());
+        Map<String, String[]> groupMapping = (dynamicGroupsEnabled) ? MAPPING_GROUP : null;
+        return new AutoMembershipProvider(root, userManager, getNamePathMapper(), MAPPING, groupMapping, getAutoMembershipConfigMapping());
+    }
+    
+    private static void assertMatchingEntries(@NotNull Iterator<Authorizable> it, @NotNull String... expectedIds) {
+        Set<String> ids = ImmutableSet.copyOf(Iterators.transform(it, authorizable -> {
+            try {
+                return authorizable.getID();
+            } catch (RepositoryException repositoryException) {
+                return "";
+            }
+        }));
+        assertEquals(ImmutableSet.copyOf(expectedIds), ids);
     }
 
     @Test
@@ -145,7 +176,15 @@
         Group testGroup = getTestGroup();
         setExternalId(testGroup.getID(), IDP_VALID_AM);
 
-        assertFalse(provider.getMembers(automembershipGroup3, false).hasNext());
+        if (dynamicGroupsEnabled) {
+            // external group does have 'automembershipGroup3' as configured autom-membership
+            assertMatchingEntries(provider.getMembers(automembershipGroup3, false), testGroup.getID());
+
+            // external group doesn't have 'automembershipGroup1' as configured autom-membership
+            assertFalse(provider.getMembers(automembershipGroup1, false).hasNext());
+        } else {
+            assertFalse(provider.getMembers(automembershipGroup3, false).hasNext());
+        }
     }
 
     @Test
@@ -153,7 +192,37 @@
         Group testGroup = getTestGroup();
         setExternalId(testGroup.getID(), IDP_VALID_AM);
 
-        assertFalse(provider.getMembers(automembershipGroup3, true).hasNext());
+        if (dynamicGroupsEnabled) {
+            // external group does have 'automembershipGroup3' as configured autom-membership
+            assertMatchingEntries(provider.getMembers(automembershipGroup3, true), testGroup.getID());
+            
+            // external group doesn't have 'automembershipGroup1' as configured autom-membership
+            assertFalse(provider.getMembers(automembershipGroup1, true).hasNext());
+            
+        } else {
+            assertFalse(provider.getMembers(automembershipGroup3, true).hasNext());
+        }
+    }
+
+    @Test
+    public void testGetMembersMatchingUsersAndGroups() throws Exception {
+        Group testGroup = getTestGroup();
+        setExternalId(testGroup.getID(), IDP_VALID_AM);
+        String testUserId = getTestUser().getID();
+        setExternalId(testUserId, IDP_VALID_AM);
+        
+        // create provider with a group mapping that contains same group as user-mapping
+        if (dynamicGroupsEnabled) {
+            Map<String, String[]> grMapping = ImmutableMap.of(IDP_VALID_AM, new String[] {AUTOMEMBERSHIP_GROUP_ID_1});
+            AutoMembershipProvider amp = new AutoMembershipProvider(root, userManager, getNamePathMapper(), MAPPING, grMapping, getAutoMembershipConfigMapping());
+            // external group does have 'automembershipGroup3' as configured autom-membership
+            Iterator<Authorizable> it = amp.getMembers(automembershipGroup1, false);
+            assertMatchingEntries(it, testGroup.getID(), testUserId);
+        } else {
+            AutoMembershipProvider amp = new AutoMembershipProvider(root, userManager, getNamePathMapper(), MAPPING, null, getAutoMembershipConfigMapping());
+            Iterator<Authorizable> it = amp.getMembers(automembershipGroup1, false);
+            assertMatchingEntries(it, testUserId);
+        }
     }
     
     @Test
@@ -266,9 +335,18 @@
         Group testGroup = getTestGroup();
         setExternalId(testGroup.getID(), IDP_VALID_AM);
 
-        for (Group gr : new Group[] {automembershipGroup1, automembershipGroup3}) {
-            assertFalse(provider.isMember(gr, testGroup, false));
-            assertFalse(provider.isMember(gr, testGroup, true));
+        if (dynamicGroupsEnabled) {
+            assertTrue(provider.isMember(automembershipGroup3, testGroup, false));
+            assertTrue(provider.isMember(automembershipGroup3, testGroup, true));
+            
+            // automembershipGroup1 not configured for groups
+            assertFalse(provider.isMember(automembershipGroup1, testGroup, false));
+            assertFalse(provider.isMember(automembershipGroup1, testGroup, true));
+        } else {
+            for (Group gr : new Group[] {automembershipGroup1, automembershipGroup3}) {
+                assertFalse(provider.isMember(gr, testGroup, false));
+                assertFalse(provider.isMember(gr, testGroup, true));
+            }
         }
     }
     
@@ -356,8 +434,20 @@
         Group testGroup = getTestGroup();
         setExternalId(testGroup.getID(), IDP_VALID_AM);
 
-        assertFalse(provider.getMembership(testGroup, false).hasNext());
-        assertFalse(provider.getMembership(testGroup, true).hasNext());
+        if (dynamicGroupsEnabled) {
+            Iterator<Group> it = provider.getMembership(testGroup, false);
+            assertTrue(it.hasNext());
+            assertEquals(automembershipGroup3.getID(), it.next().getID());
+            assertFalse(it.hasNext());
+
+            it = provider.getMembership(testGroup, false);
+            assertTrue(provider.getMembership(testGroup, true).hasNext());
+            assertEquals(automembershipGroup3.getID(), it.next().getID());
+            assertFalse(it.hasNext());
+        } else {
+            assertFalse(provider.getMembership(testGroup, false).hasNext());
+            assertFalse(provider.getMembership(testGroup, true).hasNext());
+        }
     }
     
     @Test
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipServiceTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipServiceTest.java
new file mode 100644
index 0000000..c7150d0
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupMembershipServiceTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.AbstractExternalAuthTest;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncHandler;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncHandler;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.SyncHandlerMapping;
+import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_NAME;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl.PARAM_USER_DYNAMIC_MEMBERSHIP;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.SyncHandlerMapping.PARAM_IDP_NAME;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.SyncHandlerMapping.PARAM_SYNC_HANDLER_NAME;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal.AbstractAutoMembershipTest.IDP_VALID_AM;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+public class DynamicGroupMembershipServiceTest extends AbstractExternalAuthTest {
+
+    private SyncHandlerMappingTracker mappingTracker;
+    private SyncConfigTracker scTracker;
+    private DynamicGroupMembershipService service;
+
+    @Before
+    public void before() throws Exception {
+        super.before();
+
+        mappingTracker = new SyncHandlerMappingTracker(context.bundleContext());
+        mappingTracker.open();
+
+        scTracker = new SyncConfigTracker(context.bundleContext(), mappingTracker);
+        scTracker.open();
+
+        service = new DynamicGroupMembershipService(scTracker);
+
+        assertFalse(scTracker.isEnabled());
+    }
+
+    @After
+    public void after() throws Exception {
+        try {
+            mappingTracker.close();
+            scTracker.close();
+        } finally {
+            super.after();
+        }
+    }
+    
+    private static Map<String, String> getMappingParams() {
+        return ImmutableMap.of(PARAM_IDP_NAME, IDP_VALID_AM, PARAM_SYNC_HANDLER_NAME, "sh");
+    }
+
+    private static Map<String, Object> getSyncHandlerParams(boolean enableDynamicGroups) {
+        return ImmutableMap.of(
+                PARAM_USER_DYNAMIC_MEMBERSHIP, true,
+                PARAM_NAME, "sh",
+                PARAM_GROUP_DYNAMIC_GROUPS, enableDynamicGroups);
+    }
+    
+    @Test
+    public void testNotEnabled() {
+        assertSame(DynamicMembershipProvider.EMPTY, service.getDynamicMembershipProvider(root, getUserManager(root), getNamePathMapper()));
+    }
+
+    @Test
+    public void testDynamicMembershipNoDynamicGroups() {
+        context.registerService(SyncHandler.class, new DefaultSyncHandler(), getSyncHandlerParams(false));
+        assertTrue(scTracker.isEnabled());
+
+        context.registerService(SyncHandlerMapping.class, new SyncHandlerMapping() {}, getMappingParams());
+
+        assertFalse(scTracker.hasDynamicGroupsEnabled());
+        assertTrue(scTracker.getIdpNamesWithDynamicGroups().isEmpty());
+        
+        assertSame(DynamicMembershipProvider.EMPTY, service.getDynamicMembershipProvider(root, getUserManager(root), getNamePathMapper()));
+    }
+
+    @Test
+    public void testDynamicMembershipAndDynamicGroups() {
+        context.registerService(SyncHandler.class, new DefaultSyncHandler(), getSyncHandlerParams(true));
+        assertTrue(scTracker.isEnabled());
+
+        context.registerService(SyncHandlerMapping.class, new SyncHandlerMapping() {}, getMappingParams());
+
+        assertTrue(scTracker.hasDynamicGroupsEnabled());
+        assertEquals(Collections.singleton(IDP_VALID_AM), scTracker.getIdpNamesWithDynamicGroups());
+        
+        DynamicMembershipProvider dmp = service.getDynamicMembershipProvider(root, getUserManager(root), getNamePathMapper());
+        assertNotSame(DynamicMembershipProvider.EMPTY, dmp);
+        assertTrue(dmp instanceof ExternalGroupPrincipalProvider);
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtilTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtilTest.java
new file mode 100644
index 0000000..f8c433a
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupUtilTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.oak.AbstractSecurityTest;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
+import org.apache.jackrabbit.oak.spi.nodetype.NodeTypeConstants;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.junit.Test;
+
+import javax.jcr.RepositoryException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DynamicGroupUtilTest extends AbstractSecurityTest {
+    
+    @Test
+    public void findGroupIdInHierarchy() throws RepositoryException {
+        Group gr = getUserManager(root).createGroup("grId");
+        Tree tree = root.getTree(gr.getPath());
+        
+        assertEquals("grId", DynamicGroupUtil.findGroupIdInHierarchy(tree));
+
+        Tree child = TreeUtil.addChild(tree, "test", NodeTypeConstants.NT_OAK_UNSTRUCTURED);
+        assertEquals("grId", DynamicGroupUtil.findGroupIdInHierarchy(child));
+
+        Tree membersList = TreeUtil.addChild(tree, UserConstants.REP_MEMBERS_LIST, UserConstants.NT_REP_MEMBER_REFERENCES_LIST);
+        assertEquals("grId", DynamicGroupUtil.findGroupIdInHierarchy(membersList));
+
+        Tree members = TreeUtil.addChild(membersList, "any", UserConstants.NT_REP_MEMBER_REFERENCES);
+        assertEquals("grId", DynamicGroupUtil.findGroupIdInHierarchy(members));
+
+        assertNull(DynamicGroupUtil.findGroupIdInHierarchy(tree.getParent()));
+        assertNull(DynamicGroupUtil.findGroupIdInHierarchy(root.getTree(PathUtils.ROOT_PATH)));
+    }
+    
+    @Test
+    public void testHasStoredMemberInfoFails() throws RepositoryException {
+        Group gr = when(mock(Group.class).getPath()).thenThrow(new RepositoryException()).getMock();
+        assertFalse(DynamicGroupUtil.hasStoredMemberInfo(gr, root));
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProviderTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProviderTest.java
new file mode 100644
index 0000000..e5891c9
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorProviderTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.DefaultValidator;
+import org.apache.jackrabbit.oak.spi.commit.SubtreeValidator;
+import org.apache.jackrabbit.oak.spi.commit.Validator;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.junit.Test;
+
+import java.util.Collections;
+
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.DEFAULT_IDP_NAME;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+public class DynamicGroupValidatorProviderTest extends AbstractPrincipalTest {
+
+    private final NodeState nsBefore = mock(NodeState.class);
+    private final NodeState nsAfter = mock(NodeState.class);
+
+    @Test
+    public void testGetRootValidatorEmptyIdpNames() {
+        DynamicGroupValidatorProvider provider = new DynamicGroupValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), Collections.emptySet());
+        assertSame(DefaultValidator.INSTANCE, provider.getRootValidator(nsBefore, nsAfter, CommitInfo.EMPTY));
+        verifyNoInteractions(nsBefore, nsAfter);
+    }
+
+    @Test
+    public void testGetRootValidatorWithIdpName() {
+        DynamicGroupValidatorProvider provider = new DynamicGroupValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), Collections.singleton(DEFAULT_IDP_NAME));
+        Validator validator = provider.getRootValidator(nsBefore, nsAfter, CommitInfo.EMPTY);
+        assertTrue(validator instanceof SubtreeValidator);
+        verifyNoInteractions(nsBefore, nsAfter);
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorTest.java
new file mode 100644
index 0000000..ef8eba2
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/DynamicGroupValidatorTest.java
@@ -0,0 +1,406 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Lists;
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.api.security.user.User;
+import org.apache.jackrabbit.api.security.user.UserManager;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.memory.PropertyStates;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.Validator;
+import org.apache.jackrabbit.oak.spi.nodetype.NodeTypeConstants;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
+import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+
+import javax.jcr.ValueFactory;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.apache.jackrabbit.JcrConstants.JCR_PRIMARYTYPE;
+import static org.apache.jackrabbit.JcrConstants.JCR_UUID;
+import static org.apache.jackrabbit.oak.spi.nodetype.NodeTypeConstants.JCR_LASTMODIFIEDBY;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_ID;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.NT_REP_GROUP;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_MEMBERS;
+import static org.apache.jackrabbit.oak.spi.security.user.UserConstants.REP_MEMBERS_LIST;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DynamicGroupValidatorTest extends AbstractPrincipalTest {
+    
+    private Root r;
+    private UserManager userManager;
+    private User testUser;
+    private Group localGroup;
+    private Group dynamicGroup;
+
+    @Override
+    public void before() throws Exception {
+        super.before();
+        User tu = getTestUser();
+        
+        r = getSystemRoot();
+        userManager = getUserManager(r);
+        testUser = userManager.getAuthorizable(tu.getID(), User.class);
+        localGroup = createTestGroup(r);
+        dynamicGroup = userManager.getAuthorizable("aaa", Group.class);
+        assertNotNull(dynamicGroup);
+        
+        registerSyncHandler(syncConfigAsMap(), idp.getName());
+    }
+
+    @Override
+    public void after() throws Exception {
+        try {                
+            r.refresh();
+            if (localGroup != null) {
+                localGroup.remove();
+                r.commit();
+            }
+        } finally {
+            super.after();
+        }
+    }
+
+    @Override
+    protected @NotNull DefaultSyncConfig createSyncConfig() {
+        DefaultSyncConfig config =  super.createSyncConfig();
+        config.group().setDynamicGroups(true);
+        config.user().setMembershipNestingDepth(2);
+        return config;
+    }
+
+    @Override
+    @NotNull Set<String> getIdpNamesWithDynamicGroups() {
+        return Collections.singleton(idp.getName());
+    }
+    
+    @Test
+    public void testAddMemberDynamicGroup() throws Exception {
+        dynamicGroup.addMember(userManager.getAuthorizable(USER_ID));
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+
+    @Test
+    public void testAddMemberLocalGroup() throws Exception {
+        localGroup.addMember(testUser);
+        r.commit();
+
+        assertTrue(localGroup.isDeclaredMember(testUser));
+    }
+
+    @Test
+    public void testAddMembersProperty() throws Exception {
+        Tree groupTree = r.getTree(dynamicGroup.getPath());
+        assertFalse(groupTree.hasProperty(REP_MEMBERS));
+        
+        String uuid = r.getTree(userManager.getAuthorizable(USER_ID).getPath()).getProperty(JCR_UUID).getValue(Type.STRING);
+        groupTree.setProperty(REP_MEMBERS, ImmutableList.of(uuid), Type.WEAKREFERENCES);
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+
+    @Test
+    public void testAddMembersListTree() throws Exception {
+        Tree groupTree = r.getTree(dynamicGroup.getPath());
+        assertFalse(groupTree.hasChild(UserConstants.REP_MEMBERS_LIST));
+        TreeUtil.addChild(groupTree, UserConstants.REP_MEMBERS_LIST, UserConstants.NT_REP_MEMBER_REFERENCES_LIST);
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+
+    @Test
+    public void testAddMembersTree() throws Exception {
+        Tree groupTree = r.getTree(dynamicGroup.getPath());
+        assertFalse(groupTree.hasChild(REP_MEMBERS));
+        TreeUtil.addChild(groupTree, REP_MEMBERS, UserConstants.NT_REP_MEMBERS);
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+
+    @Test
+    public void testAddMembersTreeWithoutPrimaryType() throws Exception {
+        NodeState ns = mock(NodeState.class);
+        when(ns.getChildNode(anyString())).thenReturn(ns);
+        
+        String groupRoot = UserUtil.getAuthorizableRootPath(getUserConfiguration().getParameters(), AuthorizableType.GROUP);
+        DynamicGroupValidatorProvider provider = new DynamicGroupValidatorProvider(getRootProvider(), getTreeProvider(), getSecurityProvider(), getIdpNamesWithDynamicGroups());
+        Validator v = provider.getRootValidator(ns, ns, CommitInfo.EMPTY);
+        // traverse the subtree-validator
+        for (String name : PathUtils.elements(groupRoot)) {
+            v = v.childNodeAdded(name, ns);
+        }
+        
+        // add a dynamic group node
+        PropertyState ps = PropertyStates.createProperty(REP_EXTERNAL_ID, new ExternalIdentityRef("gr", idp.getName()).getString(), Type.STRING);
+        when(ns.getProperty(REP_EXTERNAL_ID)).thenReturn(ps);
+        PropertyState primaryPs = PropertyStates.createProperty(JCR_PRIMARYTYPE, NT_REP_GROUP, Type.NAME);
+        when(ns.getProperty(JCR_PRIMARYTYPE)).thenReturn(primaryPs);
+        v = v.childNodeAdded("group", ns);
+
+        // add rep:membersList child node without primary type inside the dynamic group -> must not fail
+        NodeState ns2 = mock(NodeState.class);
+        assertNotNull(v.childNodeAdded(REP_MEMBERS_LIST, ns2));
+    }
+    
+    @Test
+    public void testAddMembersToPreviouslySyncedGroup() throws Exception {
+        User second = userManager.getAuthorizable(TestIdentityProvider.ID_SECOND_USER, User.class);
+        assertNotNull(second);
+        assertFalse(second.hasProperty(REP_EXTERNAL_PRINCIPAL_NAMES));
+        Group secondGroup = userManager.getAuthorizable("secondGroup", Group.class);
+        assertNotNull(secondGroup);
+        Tree groupTree = r.getTree(secondGroup.getPath());
+        assertTrue(groupTree.hasProperty(REP_MEMBERS));
+        
+        // adding members to a group that has been synced-before must not succeed as the DynamicSyncContext will
+        // eventually migrate them to dynamic membership
+        secondGroup.addMember(userManager.getAuthorizable(USER_ID));
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+    
+    @Test
+    public void testAddProperties() throws Exception {
+        Tree groupTree = r.getTree(dynamicGroup.getPath());
+        ValueFactory vf = getValueFactory(r);
+        
+        dynamicGroup.setProperty("rel/path/test", vf.createValue("value"));
+        r.commit();
+        assertTrue(groupTree.hasChild("rel"));
+
+        groupTree.setProperty("test", "value");
+        r.commit();
+        assertTrue(dynamicGroup.hasProperty("test"));
+    }
+
+    @Test
+    public void testModifyProperties() throws Exception {
+        ValueFactory vf = getValueFactory(r);
+
+        dynamicGroup.setProperty("rel/path/test", vf.createValue("value"));
+        r.commit();
+        assertTrue(dynamicGroup.hasProperty("rel/path/test"));
+
+        dynamicGroup.setProperty("rel/path/test", vf.createValue("value2"));
+        r.commit();
+        assertTrue(dynamicGroup.hasProperty("rel/path/test"));
+    }
+
+    @Test
+    public void testModifyPropertiesLocalGroup() throws Exception {
+        ValueFactory vf = getValueFactory(r);
+
+        localGroup.setProperty("test", vf.createValue("value"));
+        r.commit();
+        assertTrue(localGroup.hasProperty("test"));
+
+        localGroup.setProperty("test", vf.createValue("value2"));
+        r.commit();
+        assertTrue(localGroup.hasProperty("test"));
+    }
+
+    @Test
+    public void testModifyMembersPropertyLocalGroup() throws Exception {
+        localGroup.addMember(testUser);
+        r.commit();
+
+        Tree groupTree = r.getTree(localGroup.getPath());
+        Tree userTree = r.getTree(userManager.getAuthorizable(USER_ID).getPath());
+        List<String> members = Lists.newArrayList(groupTree.getProperty(REP_MEMBERS).getValue(Type.STRINGS));
+        members.add(userTree.getProperty(JCR_UUID).getValue(Type.STRING));
+        groupTree.setProperty(REP_MEMBERS, members, Type.WEAKREFERENCES);
+        r.commit();
+
+        assertEquals(2, Iterators.size(localGroup.getMembers()));
+    }
+
+
+    @Test
+    public void testModifyFolderProperties() throws Exception {
+        Tree folderTree = r.getTree(localGroup.getPath()).getParent();
+        TreeUtil.addMixin(folderTree, NodeTypeConstants.MIX_LASTMODIFIED, r.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        r.commit();
+        
+        folderTree.setProperty(JCR_LASTMODIFIEDBY, "otherId");
+        r.commit();
+
+        assertEquals("otherId", folderTree.getProperty(JCR_LASTMODIFIEDBY).getValue(Type.STRING));
+        assertEquals("otherId", folderTree.getProperty(JCR_LASTMODIFIEDBY).getValue(Type.STRING));
+    }
+    
+    @Test
+    public void testModifyMembersPropertyRemove() throws Exception {
+        localGroup.addMember(testUser);
+        localGroup.addMember(userManager.getAuthorizable(USER_ID));
+        // mark the local group as dynamic (setting the extid property)
+        String extid = new ExternalIdentityRef(localGroup.getID(), idp.getName()).getString();
+        localGroup.setProperty(REP_EXTERNAL_ID, getValueFactory(r).createValue(extid));
+        r.commit();
+        
+        Tree groupTree = r.getTree(localGroup.getPath());
+        List<String> members = Lists.newArrayList(groupTree.getProperty(REP_MEMBERS).getValue(Type.STRINGS));
+        members.remove(1);
+        groupTree.setProperty(REP_MEMBERS, members, Type.WEAKREFERENCES);
+        r.commit();
+
+        assertEquals(1, Iterators.size(localGroup.getMembers()));
+    }
+
+    @Test
+    public void testModifyMembersPropertyAdd() throws Exception {
+        localGroup.addMember(testUser);
+        // mark the local group as dynamic (setting the extid property)
+        String extid = new ExternalIdentityRef(localGroup.getID(), idp.getName()).getString();
+        localGroup.setProperty(REP_EXTERNAL_ID, getValueFactory(r).createValue(extid));
+        r.commit();
+
+        Tree groupTree = r.getTree(localGroup.getPath());
+        Tree userTree = r.getTree(userManager.getAuthorizable(USER_ID).getPath());
+        List<String> members = Lists.newArrayList(groupTree.getProperty(REP_MEMBERS).getValue(Type.STRINGS));
+        members.add(userTree.getProperty(JCR_UUID).getValue(Type.STRING));
+        groupTree.setProperty(REP_MEMBERS, members, Type.WEAKREFERENCES);
+        try {
+            r.commit();
+            fail("CommitFailedException 77 expected.");
+        } catch (CommitFailedException e) {
+            assertEquals(77, e.getCode());
+        }
+    }
+    
+    @Test
+    public void testCreateDynamicGroup() throws Exception {
+        ExternalIdentityRef ref = new ExternalIdentityRef("thirdGroup", idp.getName());
+        Group gr = null;
+        try {
+            gr = userManager.createGroup(ref.getId(), new PrincipalImpl(ref.getId()), "some/intermediate/path");
+            gr.setProperty(REP_EXTERNAL_ID, getValueFactory(r).createValue(ref.getString()));
+            r.commit();
+
+            root.refresh();
+            assertNotNull(getUserManager(root).getAuthorizable(ref.getId()));
+        } finally {
+            if (gr != null) {
+                gr.remove();
+                r.commit();
+            }
+        }
+    }
+
+    @Test
+    public void testCreateGroupIncompleteExtId() throws Exception {
+        Group gr = null;
+        try {
+            gr = userManager.createGroup("thirdGroup", new PrincipalImpl("thirdGroup"), "some/intermediate/path");
+            gr.setProperty(REP_EXTERNAL_ID, getValueFactory(r).createValue("thirdGroup"));
+            r.commit();
+
+            root.refresh();
+            assertNotNull(getUserManager(root).getAuthorizable("thirdGroup"));
+        } finally {
+            if (gr != null) {
+                gr.remove();
+                r.commit();
+            }
+        }
+    }
+
+    @Test
+    public void testCreateGroupDifferentIDP() throws Exception {
+        ExternalIdentityRef ref = new ExternalIdentityRef("thirdGroup", "anotherIDP");
+        Group gr = null;
+        try {
+            gr = userManager.createGroup(ref.getId(), new PrincipalImpl(ref.getId()), "some/intermediate/path");
+            gr.setProperty(REP_EXTERNAL_ID, getValueFactory(r).createValue(ref.getString()));
+            r.commit();
+
+            root.refresh();
+            assertNotNull(getUserManager(root).getAuthorizable(ref.getId()));
+        } finally {
+            if (gr != null) {
+                gr.remove();
+                r.commit();
+            }
+        }
+    }
+
+    @Test
+    public void testCreateLocalGroup() throws Exception {
+        Group gr = null;
+        try {
+            String id = "testGroup"+ UUID.randomUUID();
+            gr = userManager.createGroup(id);
+            r.commit();
+
+            root.refresh();
+            assertNotNull(getUserManager(root).getAuthorizable(id));
+        } finally {
+            if (gr != null) {
+                gr.remove();
+                r.commit();
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderDMTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderDMTest.java
new file mode 100644
index 0000000..ad19ccd
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderDMTest.java
@@ -0,0 +1,459 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Lists;
+import org.apache.jackrabbit.api.security.principal.PrincipalManager;
+import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.api.security.user.User;
+import org.apache.jackrabbit.api.security.user.UserManager;
+import org.apache.jackrabbit.oak.api.QueryEngine;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityException;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncConfigImpl;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
+import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import javax.jcr.RepositoryException;
+import javax.jcr.Value;
+import java.security.Principal;
+import java.text.ParseException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.jackrabbit.api.security.user.UserManager.SEARCH_TYPE_GROUP;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.ID_SECOND_USER;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.REP_EXTERNAL_PRINCIPAL_NAMES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+/**
+ * Additional tests for {@code ExternalGroupPrincipalProvider} covering methods defined by 
+ * {@link org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider} interface.
+ */
+@RunWith(Parameterized.class)
+public class ExternalGroupPrincipalProviderDMTest extends AbstractPrincipalTest {
+
+    @Parameterized.Parameters(name = "name={2}")
+    public static Collection<Object[]> parameters() {
+        return Lists.newArrayList(
+                new Object[] { true, DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT+1, "Dynamic Groups Enabled, Membership-Nesting-Depth=1" },
+                new Object[] { true, DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT+2, "Dynamic Groups Enabled, Membership-Nesting-Depth=2" },
+                new Object[] { false, DefaultSyncConfigImpl.PARAM_USER_MEMBERSHIP_NESTING_DEPTH_DEFAULT, "Dynamic Groups NOT Enabled" });
+    }
+    
+    private final boolean dynamicGroupsEnabled;
+    private final long membershipNestingDepth;
+    
+    private Group testGroup;
+    
+    public ExternalGroupPrincipalProviderDMTest(boolean dynamicGroupsEnabled, int membershipNestingDepth, @NotNull String name) {
+        this.dynamicGroupsEnabled = dynamicGroupsEnabled;
+        this.membershipNestingDepth = membershipNestingDepth;
+    }
+
+    @Override
+    public void before() throws Exception {
+        super.before();
+
+        testGroup = createTestGroup();
+    }
+
+    @Override
+    public void after() throws Exception {
+        try {
+            root.refresh();
+            testGroup.remove();
+            root.commit();
+        } finally {
+            super.after();
+        }
+    }
+
+    @Override
+    protected @NotNull DefaultSyncConfig createSyncConfig() {
+        DefaultSyncConfig config =  super.createSyncConfig();
+        config.group().setDynamicGroups(dynamicGroupsEnabled);
+        config.user().setMembershipNestingDepth(membershipNestingDepth);
+        return config;
+    }
+
+    @Override
+    @NotNull Set<String> getIdpNamesWithDynamicGroups() {
+        if (dynamicGroupsEnabled) {
+            return Collections.singleton(idp.getName());
+        } else {
+            return super.getIdpNamesWithDynamicGroups();
+        }
+    }
+    
+    @Test
+    public void testCoversAllMembersLocalGroup() {
+        assertFalse(principalProvider.coversAllMembers(testGroup));
+    }
+
+    @Test
+    public void testCoversAllMembersDifferentIDP() throws Exception {
+        String extId = new ExternalIdentityRef(testGroup.getID(), "someIdp").getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+        assertFalse(principalProvider.coversAllMembers(testGroup));
+    }
+
+    @Test
+    public void testCoversAllMembers() throws Exception {
+        String extId = new ExternalIdentityRef(testGroup.getID(), idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+        assertEquals(dynamicGroupsEnabled, principalProvider.coversAllMembers(testGroup));
+    }
+    
+    @Test
+    public void testCannotAccessRepExternalId() throws Exception {
+        Group gr = when(mock(Group.class).getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenThrow(new RepositoryException("failure")).getMock();
+        assertFalse(principalProvider.coversAllMembers(gr));
+    }
+    
+    @Test
+    public void testCoversAllMembersGroupWithMemberProperty() throws Exception {
+        testGroup.addMember(getTestUser());
+
+        String extId = new ExternalIdentityRef(testGroup.getID(), idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+        
+        assertFalse(principalProvider.coversAllMembers(testGroup));
+        
+        // remove members again => must be fully dynamic
+        assertTrue(testGroup.removeMember(getTestUser()));
+        assertEquals(dynamicGroupsEnabled, principalProvider.coversAllMembers(testGroup));
+    }
+
+    @Test
+    public void testCoversAllMembersGroupWithMembersChild() throws Exception {
+        Tree groupTree = DynamicGroupUtil.getTree(testGroup, root);
+        String extId = new ExternalIdentityRef(testGroup.getID(), idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+
+        Map<String, String> nameType = ImmutableMap.of(
+                UserConstants.REP_MEMBERS, UserConstants.NT_REP_MEMBERS,
+                UserConstants.REP_MEMBERS_LIST, UserConstants.NT_REP_MEMBER_REFERENCES_LIST);
+        
+        for (Map.Entry<String,String> entry : nameType.entrySet()) {
+            Tree child = TreeUtil.addChild(groupTree, entry.getKey(), entry.getValue());
+            assertFalse(principalProvider.coversAllMembers(testGroup));
+            child.remove();
+        }
+        assertEquals(dynamicGroupsEnabled, principalProvider.coversAllMembers(testGroup));
+    }
+    
+    @Test
+    public void testGetMembersLocalGroup() throws Exception {
+        assertFalse(principalProvider.getMembers(testGroup, false).hasNext());
+        assertFalse(principalProvider.getMembers(testGroup, true).hasNext());
+        
+        testGroup.addMember(getTestUser());
+        root.commit();
+        assertFalse(principalProvider.getMembers(testGroup, false).hasNext());
+    }
+
+    @Test
+    public void testGetMembersNoResult() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+
+        assertFalse(principalProvider.getMembers(testGroup, false).hasNext());
+        assertFalse(principalProvider.getMembers(testGroup, true).hasNext());
+    }
+
+    @Test
+    public void testGetMembers() throws Exception {
+        Group gr = getUserManager(root).getAuthorizable("a", Group.class);
+        if (gr != null) {
+            // dynamic groups are enabled
+            Iterator<Authorizable> membersDecl = principalProvider.getMembers(gr, false);
+            Iterator<Authorizable> membersInh = principalProvider.getMembers(gr, true);
+            assertTrue(membersDecl.hasNext());
+            assertTrue(membersInh.hasNext());
+            assertTrue(Iterators.elementsEqual(membersDecl, membersInh));
+        }
+    }
+
+    @Test
+    public void testGetMembersWithParseException() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+
+        QueryEngine qe = mock(QueryEngine.class);
+        when(qe.executeQuery(anyString(), anyString(), any(Map.class), any(Map.class))).thenThrow(new ParseException("fail", 0));
+
+        Root r = when(mock(Root.class).getQueryEngine()).thenReturn(qe).getMock();
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(r, getUserConfiguration());
+
+        assertFalse(pp.getMembers(testGroup, true).hasNext());
+        assertFalse(pp.getMembers(testGroup, false).hasNext());
+    }
+
+    @Test
+    public void testIsMemberLocalGroup() throws Exception {
+        User user = getUserManager(root).getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+        
+        assertFalse(principalProvider.isMember(testGroup, user, true));
+        assertFalse(principalProvider.isMember(testGroup, user, false));
+        assertFalse(principalProvider.isMember(testGroup, testGroup, false));
+    }
+
+    @Test
+    public void testIsMemberLocalUser() throws Exception {
+        User user = getTestUser();
+        assertNotNull(user);
+
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+        testGroup.addMember(user);
+        
+        assertFalse(principalProvider.isMember(testGroup, user, true));
+        assertFalse(principalProvider.isMember(testGroup, user, false));
+    }
+
+    @Test
+    public void testIsMemberGroup() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+
+        assertFalse(principalProvider.isMember(testGroup, testGroup, true));
+        assertFalse(principalProvider.isMember(testGroup, testGroup, false));
+    }
+
+    @Test
+    public void testIsMemberNotMember() throws Exception {
+        User user = getUserManager(root).getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+        
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        testGroup.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, getValueFactory(root).createValue(extId));
+
+        assertFalse(principalProvider.isMember(testGroup, user, true));
+        assertFalse(principalProvider.isMember(testGroup, user, false));
+    }
+
+    @Test
+    public void testIsMember() throws Exception {
+        UserManager uMgr = getUserManager(root);
+        User user = uMgr.getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+
+        Group gr = uMgr.getAuthorizable("a", Group.class);
+        if (gr != null) {
+            // dynamic groups are enabled
+            assertTrue(principalProvider.isMember(gr, user, true));
+            assertTrue(principalProvider.isMember(gr, user, false));
+        } else {
+            assertFalse(principalProvider.isMember(testGroup, user, true));
+            assertFalse(principalProvider.isMember(testGroup, user, false));
+        }
+    }
+
+    @Test
+    public void testIsMemberMissingRepExternalPrincipalNames() throws Exception {
+        UserManager uMgr = getUserManager(root);
+        User user = uMgr.getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+        
+        // remove the rep:externalPrincipalNames property (if existing)
+        user.removeProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
+        
+        // with rep:externalPrincipalNames removed principal provider must behave the same with and without
+        // dynamic-groups enabled.
+        assertFalse(principalProvider.isMember(testGroup, user, true));
+        assertFalse(principalProvider.isMember(testGroup, user, false));
+    }
+    
+    @Test
+    public void testGetMembershipLocalGroup() throws Exception {
+        assertFalse(principalProvider.getMembership(testGroup, true).hasNext());
+        assertFalse(principalProvider.getMembership(testGroup, false).hasNext());
+    }
+
+    @Test
+    public void testGetMembershipLocalUser() throws Exception {
+        User user = getTestUser();
+        assertFalse(principalProvider.getMembership(user, true).hasNext());
+        assertFalse(principalProvider.getMembership(user, false).hasNext());
+    }
+    
+    @Test
+    public void testGetMembershipDeclared() throws Exception {
+        User user = getUserManager(root).getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+        
+        Iterator<Group> groups = principalProvider.getMembership(user, false);
+        if (dynamicGroupsEnabled) {
+            assertEquals(getExpectedNumberOfGroups(), Iterators.size(groups));
+        } else {
+            assertFalse(groups.hasNext());
+        }
+    }
+
+    @Test
+    public void testGetMembershipInherited() throws Exception {
+        User user = getUserManager(root).getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+
+        Iterator<Group> groups = principalProvider.getMembership(user, true);
+        if (dynamicGroupsEnabled) {
+            assertEquals(getExpectedNumberOfGroups(), Iterators.size(groups));
+        } else {
+            assertFalse(groups.hasNext());
+        }
+    }
+
+    @Test
+    public void testGetMembershipMissingRepExternalPrincipalNames() throws Exception {
+        User user = getUserManager(root).getAuthorizable(USER_ID, User.class);
+        assertNotNull(user);
+
+        user.removeProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
+
+        Iterator<Group> groups = principalProvider.getMembership(user, true);
+        assertFalse(groups.hasNext());
+    }
+    
+    private long getExpectedNumberOfGroups() throws Exception {
+        return getExpectedSyncedGroupIds(syncConfig.user().getMembershipNestingDepth(), idp, idp.getUser(USER_ID)).size();
+    }
+
+    @Test
+    public void testGetMembershipEmptyPrincipalNames() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        Value extIdValue = getValueFactory(root).createValue(extId);
+
+        User user = mock(User.class);
+        when(user.getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenReturn(new Value[] {extIdValue});
+        when(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).thenReturn(new Value[0]);
+
+        // empty value array
+        Iterator<Group> groups = principalProvider.getMembership(user, false);
+        assertFalse(groups.hasNext());
+    }
+
+    @Test
+    public void testGetMembershipNullPrincipalNames() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        Value extIdValue = getValueFactory(root).createValue(extId);
+
+        User user = mock(User.class);
+        when(user.getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenReturn(new Value[] {extIdValue});
+        when(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).thenReturn(null);
+
+        // rep:externalPrincipalNames is null
+        Iterator<Group> groups = principalProvider.getMembership(user, false);
+        assertFalse(groups.hasNext());
+    }
+
+    @Test
+    public void testGetMembershipGroupNonExisting() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        Value extIdValue = getValueFactory(root).createValue(extId);
+        Value[] extPrincNames = new Value[] {getValueFactory(root).createValue("nonexistingGroup")};
+
+        User user = mock(User.class);
+        when(user.getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenReturn(new Value[] {extIdValue});
+        when(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).thenReturn(extPrincNames);
+
+        Iterator<Group> groups = principalProvider.getMembership(user, true);
+        assertFalse(groups.hasNext());
+    }
+
+    @Test
+    public void testGetMembershipResolvesToUser() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        Value extIdValue = getValueFactory(root).createValue(extId);
+        Value[] extPrincNames = new Value[] {getValueFactory(root).createValue(ID_SECOND_USER)};
+
+        User user = mock(User.class);
+        when(user.getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenReturn(new Value[] {extIdValue});
+        when(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).thenReturn(extPrincNames);
+
+        Iterator<Group> groups = principalProvider.getMembership(user, false);
+        assertFalse(groups.hasNext());
+    }
+
+    @Test
+    public void testGetMembershipLookupFails() throws Exception {
+        String extId = new ExternalIdentityRef(USER_ID, idp.getName()).getString();
+        Value extIdValue = getValueFactory(root).createValue(extId);
+        Value[] extPrincNames = new Value[] {getValueFactory(root).createValue("a")};
+
+        User user = mock(User.class);
+        when(user.getProperty(ExternalIdentityConstants.REP_EXTERNAL_ID)).thenReturn(new Value[] {extIdValue});
+        when(user.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES)).thenReturn(extPrincNames);
+
+        UserManager um = spy(getUserManager(root));
+        doThrow(new RepositoryException()).when(um).getAuthorizable(any(Principal.class));
+        UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
+        
+        ExternalGroupPrincipalProvider provider = createPrincipalProvider(root, uc);
+        Iterator<Group> groups = provider.getMembership(user, false);
+        assertFalse(groups.hasNext());
+    }
+    
+    //------------------------------------------------------------------------------------------------------------------
+    // principal provider methods that have short-cut for setup where all sync-configurations have dynamic groups enabled
+    //------------------------------------------------------------------------------------------------------------------
+    @Test
+    public void testGetPrincipal() throws Exception {
+        // even if group 'a' has been synchronized the external-group-p-provider must not try to find it
+        // as it has been synced as regular group
+        assertNull(principalProvider.getPrincipal(idp.getGroup("a").getPrincipalName()));
+    }
+
+    @Test
+    public void testFindAllPrincipals() {
+        assertFalse(principalProvider.findPrincipals(PrincipalManager.SEARCH_TYPE_ALL).hasNext());
+        assertFalse(principalProvider.findPrincipals(PrincipalManager.SEARCH_TYPE_GROUP).hasNext());
+    }
+    
+    @Test
+    public void testFindPrincipals() throws ExternalIdentityException {
+        String principalName = idp.getGroup("a").getPrincipalName();
+        assertFalse(principalProvider.findPrincipals(principalName, SEARCH_TYPE_GROUP).hasNext());
+        assertFalse(principalProvider.findPrincipals(principalName, false, PrincipalManager.SEARCH_TYPE_GROUP, 0, Long.MAX_VALUE).hasNext());
+    }
+}
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderTest.java
index 23fec75..4925a1b 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalProviderTest.java
@@ -17,7 +17,6 @@
 package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import org.apache.jackrabbit.api.security.principal.GroupPrincipal;
 import org.apache.jackrabbit.api.security.principal.ItemBasedPrincipal;
@@ -31,6 +30,7 @@
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.commons.PathUtils;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityException;
@@ -54,6 +54,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -66,9 +67,11 @@
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
 
 public class ExternalGroupPrincipalProviderTest extends AbstractPrincipalTest {
 
@@ -99,9 +102,13 @@
         }
     }
 
-    @NotNull
+    @NotNull 
     Set<Principal> getExpectedAllSearchResult(@NotNull String userId) throws Exception {
-        return getExpectedGroupPrincipals(userId);
+        if (hasDynamicGroups()) {
+            return Collections.emptySet();
+        } else {
+            return getExpectedGroupPrincipals(userId);
+        }
     }
 
     private void collectExpectedPrincipals(Set<Principal> grPrincipals, @NotNull Iterable<ExternalIdentityRef> declaredGroups, long depth) throws Exception {
@@ -114,6 +121,19 @@
             collectExpectedPrincipals(grPrincipals, ei.getDeclaredGroups(), depth - 1);
         }
     }
+    
+    private boolean hasDynamicGroups() {
+        return getIdpNamesWithDynamicGroups().contains(idp.getName());
+    }
+    
+    @NotNull
+    private Set<Principal> buildExpectedPrincipals(@NotNull String principalName) {
+        if (hasDynamicGroups()) {
+            return Collections.emptySet();
+        } else {
+            return ImmutableSet.of(new PrincipalImpl(principalName));
+        }
+    }
 
     @Test
     public void testGetPrincipalLocalUser() throws Exception {
@@ -154,8 +174,14 @@
             String princName = idp.getIdentity(ref).getPrincipalName();
             Principal principal = principalProvider.getPrincipal(princName);
 
-            assertNotNull(principal);
-            assertTrue(principal instanceof GroupPrincipal);
+            if (hasDynamicGroups()) {
+                // dynamic groups that have been synced into the repository don't get served by the 
+                // ExternalGroupPrincipalProvider
+                assertNull(principal);
+            } else {
+                assertNotNull(principal);
+                assertTrue(principal instanceof GroupPrincipal);
+            }
         }
     }
 
@@ -208,8 +234,13 @@
         for (ExternalIdentityRef ref : externalUser.getDeclaredGroups()) {
             String pName = idp.getIdentity(ref).getPrincipalName();
             Principal p = principalProvider.getPrincipal(pName);
-            assertNotNull(p);
-            assertEquals(pName, p.getName());
+            if (hasDynamicGroups()) {
+                // dynamic groups are not served by the external-group-principal-provider
+                assertNull(p);
+            } else {
+                assertNotNull(p);
+                assertEquals(pName, p.getName());
+            }
         }
     }
 
@@ -276,15 +307,30 @@
 
     @Test
     public void testGetGroupMembershipExternalGroup() throws Exception {
-        Authorizable group = getUserManager(root).getAuthorizable("secondGroup");
+        UserManager um = getUserManager(root);
+        Authorizable group = um.getAuthorizable("secondGroup");
         assertNotNull(group);
 
-        Set<? extends Principal> principals = principalProvider.getMembershipPrincipals(group.getPrincipal());
-        assertTrue(principals.isEmpty());
-
-        // same if the principal is not marked as 'GroupPrincipal' and not tree-based-principal
-        principals = principalProvider.getMembershipPrincipals(new PrincipalImpl(group.getPrincipal().getName()));
-        assertTrue(principals.isEmpty());
+        for (Principal principal : new Principal[] {group.getPrincipal(), new PrincipalImpl(group.getPrincipal().getName())}) {
+            Set<? extends Principal> principals = principalProvider.getMembershipPrincipals(principal);
+            if (hasDynamicGroups()) {
+                Set<Principal> expected = getExpectedGroupAutomembership(group, um);
+                assertEquals(expected, principals);
+            } else {
+                assertTrue(principals.isEmpty());
+            }
+        }
+    }
+    
+    private Set<Principal> getExpectedGroupAutomembership(@NotNull Authorizable authorizable, @NotNull UserManager um) {
+        return syncConfig.group().getAutoMembership(authorizable).stream().map(id -> {
+            try {
+                Group gr = um.getAuthorizable(id, Group.class);
+                return (gr == null) ? null : gr.getPrincipal();
+            } catch (RepositoryException repositoryException) {
+                return null;
+            }
+        }).filter(Objects::nonNull).collect(Collectors.toSet());
     }
 
     @Test
@@ -306,10 +352,10 @@
     @Test
     public void testGetGroupMembershipItemBasedLookupFails() throws Exception {
         UserManager um = spy(getUserManager(root));
-        when(um.getAuthorizable(any(Principal.class))).thenThrow(new RepositoryException());
+        doThrow(new RepositoryException()).when(um).getAuthorizable(any(Principal.class));
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
 
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
         Principal principal = new PrincipalImpl(um.getAuthorizable(USER_ID).getPrincipal().getName());
         assertTrue(pp.getMembershipPrincipals(principal).isEmpty());
     }
@@ -357,12 +403,14 @@
 
     @Test
     public void testGetPrincipalsNonExistingUserTree() throws Exception {
-        Authorizable a = spy(getUserManager(root).getAuthorizable(USER_ID));
+        Authorizable a = mock(Authorizable.class, withSettings().extraInterfaces(User.class));
+        when(a.getID()).thenReturn(USER_ID);
         when(a.getPath()).thenReturn("/path/to/non/existing/item");
+        
         UserManager um = when(mock(UserManager.class).getAuthorizable(USER_ID)).thenReturn(a).getMock();
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
 
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
         assertTrue(pp.getPrincipals(USER_ID).isEmpty());
     }
 
@@ -371,10 +419,13 @@
         Authorizable group = getUserManager(root).createGroup("testGroup");
         Authorizable a = spy(getUserManager(root).getAuthorizable(USER_ID));
         when(a.getPath()).thenReturn(group.getPath());
+        if (a instanceof  TreeAware && group instanceof TreeAware) {
+            when(((TreeAware) a).getTree()).thenReturn(((TreeAware)group).getTree());
+        }
         UserManager um = when(mock(UserManager.class).getAuthorizable(USER_ID)).thenReturn(a).getMock();
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
 
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
         assertTrue(pp.getPrincipals(USER_ID).isEmpty());
     }
 
@@ -383,7 +434,7 @@
         UserManager um = when(mock(UserManager.class).getAuthorizable(anyString())).thenThrow(new RepositoryException()).getMock();
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
 
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
         assertTrue(pp.getPrincipals(USER_ID).isEmpty());
     }
 
@@ -394,12 +445,10 @@
         Tree t = root.getTree(userPath);
         t.removeProperty(REP_EXTERNAL_ID);
 
-        String[] automembership = getAutoMembership();
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(getUserConfiguration(), automembership, getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, getUserConfiguration());
 
-        Set<String> principalNamees = pp.getPrincipals(USER_ID).stream().map(Principal::getName).collect(Collectors.toSet());
-        assertFalse(principalNamees.isEmpty());
-        assertFalse(principalNamees.removeAll(ImmutableSet.copyOf(automembership)));
+        Set<String> principalNames = pp.getPrincipals(USER_ID).stream().map(Principal::getName).collect(Collectors.toSet());
+        assertTrue(principalNames.isEmpty());
     }
 
     @Test
@@ -415,7 +464,7 @@
 
     @Test
     public void testFindPrincipalsByHintTypeGroup() {
-        Set<? extends Principal> expected = ImmutableSet.of(new PrincipalImpl("a"));
+        Set<? extends Principal> expected = buildExpectedPrincipals("a");
         Set<? extends Principal> res = ImmutableSet
                 .copyOf(principalProvider.findPrincipals("a", PrincipalManager.SEARCH_TYPE_GROUP));
         assertEquals(expected, res);
@@ -427,7 +476,7 @@
 
     @Test
     public void testFindPrincipalsByHintTypeAll() {
-        Set<? extends Principal> expected = ImmutableSet.of(new PrincipalImpl("a"));
+        Set<? extends Principal> expected = buildExpectedPrincipals("a");
         Set<? extends Principal> res = ImmutableSet
                 .copyOf(principalProvider.findPrincipals("a", PrincipalManager.SEARCH_TYPE_ALL));
         assertEquals(expected, res);
@@ -442,7 +491,7 @@
         ExternalUser externalUser = idp.getUser(TestIdentityProvider.ID_WILDCARD_USER);
         sync(externalUser);
 
-        Set<? extends Principal> expected = ImmutableSet.of(new PrincipalImpl("_gr_u_"));
+        Set<? extends Principal> expected = buildExpectedPrincipals("_gr_u_");
         Set<? extends Principal> res = ImmutableSet
                 .copyOf(principalProvider.findPrincipals("_", PrincipalManager.SEARCH_TYPE_ALL));
         assertEquals(expected, res);
@@ -456,8 +505,7 @@
         ExternalUser externalUser = idp.getUser(TestIdentityProvider.ID_WILDCARD_USER);
         sync(externalUser);
 
-        Set<? extends Principal> expected = ImmutableSet.of(
-                new PrincipalImpl("g%r%"));
+        Set<? extends Principal> expected = buildExpectedPrincipals("g%r%");
         Set<? extends Principal> res = ImmutableSet.copyOf(principalProvider.findPrincipals("%", PrincipalManager.SEARCH_TYPE_ALL));
         assertEquals(expected, res);
         Set<? extends Principal> res2 = ImmutableSet
@@ -493,18 +541,18 @@
         sync(otherUser);
 
         Set<Principal> expected = new HashSet<>();
-        expected.add(new PrincipalImpl(gr.getPrincipalName()));
-        long depth = syncConfig.user().getMembershipNestingDepth();
-        if (depth > 1) {
-            collectExpectedPrincipals(expected, gr.getDeclaredGroups(), --depth);
+        if (!hasDynamicGroups()) {
+            expected.add(new PrincipalImpl(gr.getPrincipalName()));
+            long depth = syncConfig.user().getMembershipNestingDepth();
+            if (depth > 1) {
+                collectExpectedPrincipals(expected, gr.getDeclaredGroups(), --depth);
+            }
         }
 
         Iterator<? extends Principal> res = principalProvider.findPrincipals("a", PrincipalManager.SEARCH_TYPE_ALL);
-        assertTrue(res.hasNext());
         assertEquals(expected, ImmutableSet.copyOf(res));
         Iterator<? extends Principal> res2 = principalProvider.findPrincipals("a", false,
                 PrincipalManager.SEARCH_TYPE_ALL, 0, -1);
-        assertTrue(res2.hasNext());
         assertEquals(expected, ImmutableSet.copyOf(res2));
     }
 
@@ -512,8 +560,7 @@
     public void testFindPrincipalsSorted() {
         List<Principal> in = Arrays.asList(new PrincipalImpl("p3"), new PrincipalImpl("p1"), new PrincipalImpl("p2"));
         ExternalGroupPrincipalProvider p = new ExternalGroupPrincipalProvider(root,
-                getSecurityProvider().getConfiguration(UserConfiguration.class), NamePathMapper.DEFAULT,
-                ImmutableMap.of(idp.getName(), getAutoMembership()), ImmutableMap.of(idp.getName(), getAutoMembershipConfig())) {
+                getUserConfiguration(), NamePathMapper.DEFAULT, idp.getName(), syncConfig, getIdpNamesWithDynamicGroups(), false) {
             @NotNull
             @Override
             public Iterator<? extends Principal> findPrincipals(@Nullable String nameHint, int searchType) {
@@ -552,9 +599,10 @@
     }
 
     @Test
-    public void testFindPrincipalsWithLimit() throws Exception {
+    public void testFindPrincipalsWithLimit() {
         Set<? extends Principal> result = ImmutableSet.copyOf(principalProvider.findPrincipals(null, false, PrincipalManager.SEARCH_TYPE_GROUP, 0, 1));
-        assertEquals(1, result.size());
+        int expectedSize = (hasDynamicGroups()) ? 0 : 1;
+        assertEquals(expectedSize, result.size());
     }
 
     @Test
@@ -578,7 +626,8 @@
         long offset = all.size()-1;
         long limit = all.size();
         Set<? extends Principal> result = ImmutableSet.copyOf(principalProvider.findPrincipals(null, false, PrincipalManager.SEARCH_TYPE_GROUP, offset, limit));
-        assertEquals(1, result.size());
+        int expectedSize = (hasDynamicGroups()) ? 0 : 1;
+        assertEquals(expectedSize, result.size());
     }
 
     @Test
@@ -587,7 +636,7 @@
         when(qe.executeQuery(anyString(), anyString(), any(Map.class), any(Map.class))).thenThrow(new ParseException("fail", 0));
 
         Root r = when(mock(Root.class).getQueryEngine()).thenReturn(qe).getMock();
-        ExternalGroupPrincipalProvider pp = new ExternalGroupPrincipalProvider(r, getUserConfiguration(), getNamePathMapper(), Collections.emptyMap(), Collections.emptyMap());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(r, getUserConfiguration());
 
         assertNull(pp.getPrincipal("a"));
         assertFalse(pp.findPrincipals(PrincipalManager.SEARCH_TYPE_GROUP).hasNext());
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalTest.java
index fb2893b..abd5567 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalGroupPrincipalTest.java
@@ -123,7 +123,7 @@
         when(um.getAuthorizable(any(Principal.class))).thenThrow(new RepositoryException());
 
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
 
         ExternalIdentityRef ref = idp.getUser(USER_ID).getDeclaredGroups().iterator().next();
         String groupName = idp.getIdentity(ref).getPrincipalName();
@@ -156,7 +156,7 @@
         when(um.getAuthorizableByPath(userPath)).thenReturn(null);
 
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
 
         ExternalIdentityRef ref = idp.getUser(USER_ID).getDeclaredGroups().iterator().next();
         String groupName = idp.getIdentity(ref).getPrincipalName();
@@ -173,7 +173,7 @@
         when(um.getAuthorizableByPath(userPath)).thenThrow(new RepositoryException());
 
         UserConfiguration uc = when(mock(UserConfiguration.class).getUserManager(root, getNamePathMapper())).thenReturn(um).getMock();
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
 
         ExternalIdentityRef ref = idp.getUser(USER_ID).getDeclaredGroups().iterator().next();
         String groupName = idp.getIdentity(ref).getPrincipalName();
@@ -190,7 +190,7 @@
 
         Root r = spy(root);
         when(r.getQueryEngine()).thenReturn(qe);
-        ExternalGroupPrincipalProvider pp = new ExternalGroupPrincipalProvider(r, getUserConfiguration(), getNamePathMapper(), ImmutableMap.of(idp.getName(), getAutoMembership()), ImmutableMap.of(idp.getName(), getAutoMembershipConfig()));
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(r, getUserConfiguration());
 
         Principal gp = pp.getMembershipPrincipals(getUserManager(root).getAuthorizable(USER_ID).getPrincipal()).iterator().next();
         assertTrue(gp instanceof GroupPrincipal);
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfigurationTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfigurationTest.java
index c90c78a..7812b4b 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfigurationTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalPrincipalConfigurationTest.java
@@ -72,7 +72,19 @@
 public class ExternalPrincipalConfigurationTest extends AbstractExternalAuthTest {
 
     private void registerDynamicSyncHandler() {
-        context.registerService(SyncHandler.class, new DefaultSyncHandler(), ImmutableMap.of(DefaultSyncConfigImpl.PARAM_USER_DYNAMIC_MEMBERSHIP, true));
+        registerSyncHandler(true, false);
+    }
+
+    private void registerSyncHandler(boolean dynamicMembership, boolean dynamicGroups) {
+        ImmutableMap.Builder<String, Object> config = new ImmutableMap.Builder<>();
+        config.put(DefaultSyncConfigImpl.PARAM_NAME, DefaultSyncConfig.DEFAULT_NAME);
+        if (dynamicMembership) {
+            config.put(DefaultSyncConfigImpl.PARAM_USER_DYNAMIC_MEMBERSHIP, true);
+        } 
+        if (dynamicGroups) {
+            config.put(DefaultSyncConfigImpl.PARAM_GROUP_DYNAMIC_GROUPS, true);
+        }
+        registerSyncHandler(config.build(), idp.getName());
     }
 
     private void assertIsEnabled(ExternalPrincipalConfiguration externalPrincipalConfiguration, boolean expected) {
@@ -219,6 +231,39 @@
         assertFalse(validatorProviders.get(0) instanceof ExternalUserValidatorProvider);
     }
 
+    @Test
+    public void testGetValidatorsDynamicGroupsEnabledWithoutDynamicMembership() {
+        ContentSession cs = root.getContentSession();
+        String workspaceName = cs.getWorkspaceName();
+
+        // dynamic groups only effective if dynamic-membership is enabled as well
+        registerSyncHandler(false, true);
+
+        List<? extends ValidatorProvider> validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, cs.getAuthInfo().getPrincipals(), new MoveTracker());
+        assertEquals(1, validatorProviders.size());
+        assertTrue(validatorProviders.get(0) instanceof ExternalIdentityValidatorProvider);
+    }
+
+    @Test
+    public void testGetValidatorsDynamicGroupsEnabled() {
+        ContentSession cs = root.getContentSession();
+        String workspaceName = cs.getWorkspaceName();
+        
+        // upon registering a synchhandler with dynamic-membership the dynamic-group-validator must be present as well
+        registerSyncHandler(true, true);
+
+        List<? extends ValidatorProvider> validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, cs.getAuthInfo().getPrincipals(), new MoveTracker());
+        assertEquals(2, validatorProviders.size());
+        assertTrue(validatorProviders.get(0) instanceof ExternalIdentityValidatorProvider);
+        assertTrue(validatorProviders.get(1) instanceof DynamicGroupValidatorProvider);
+
+        externalPrincipalConfiguration.setParameters(ConfigurationParameters.of(
+                ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDENTITIES, ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED));
+        
+        validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, cs.getAuthInfo().getPrincipals(), new MoveTracker());
+        assertEquals(3, validatorProviders.size());
+        assertTrue(validatorProviders.get(1) instanceof DynamicGroupValidatorProvider);
+    }
     
     @Test
     public void testGetProtectedItemImporters() {
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/PrincipalProviderAutoMembershipTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/PrincipalProviderAutoMembershipTest.java
index 32e5005..5f33a4e 100644
--- a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/PrincipalProviderAutoMembershipTest.java
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/PrincipalProviderAutoMembershipTest.java
@@ -20,9 +20,12 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.Lists;
+import org.apache.jackrabbit.api.security.principal.GroupPrincipal;
+import org.apache.jackrabbit.api.security.principal.ItemBasedPrincipal;
 import org.apache.jackrabbit.api.security.principal.PrincipalManager;
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.api.security.user.User;
 import org.apache.jackrabbit.api.security.user.UserManager;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.AutoMembershipConfig;
@@ -30,6 +33,7 @@
 import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
 import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -63,11 +67,12 @@
 @RunWith(Parameterized.class)
 public class PrincipalProviderAutoMembershipTest extends ExternalGroupPrincipalProviderTest {
 
-    @Parameterized.Parameters(name = "name={1}")
+    @Parameterized.Parameters(name = "name={2}")
     public static Collection<Object[]> parameters() {
         return Lists.newArrayList(
-                new Object[] { true, "Nested automembership = true" },
-                new Object[] { false, "Nested automembership = false" });
+                new Object[] { true, false, "Nested automembership = true, Dynamic Groups = false" },
+                new Object[] { false, false, "Nested automembership = false, Dynamic Groups = false" },
+                new Object[] { false, true, "Nested automembership = false, Dynamic Groups = true" });
     }
 
     private static final String USER_AUTO_MEMBERSHIP_GROUP_ID = "testGroup-" + UUID.randomUUID();
@@ -86,6 +91,7 @@
             .thenReturn(Collections.singleton(CONFIG_AUTO_MEMBERSHIP_GROUP_ID)).getMock();
 
     private final boolean nestedAutomembership;
+    private final boolean dynamicGroups;
     
     private Group userAutoMembershipGroup;
     private Group groupAutoMembershipGroup;
@@ -93,8 +99,9 @@
     private Group baseGroup;
     private Group baseGroup2;
 
-    public PrincipalProviderAutoMembershipTest(boolean nestedAutomembership, @NotNull String name) {
+    public PrincipalProviderAutoMembershipTest(boolean nestedAutomembership, boolean dynamicGroups, @NotNull String name) {
         this.nestedAutomembership = nestedAutomembership;
+        this.dynamicGroups = dynamicGroups;
     }
     
     @Override
@@ -115,7 +122,8 @@
         }
         root.commit();
 
-        verify(amc, times(2)).getAutoMembership(any(Authorizable.class));
+        int expectedTimes = (dynamicGroups) ? 6 : 3;
+        verify(amc, times(expectedTimes)).getAutoMembership(any(Authorizable.class));
         clearInvocations(amc);
     }
 
@@ -123,10 +131,13 @@
     @NotNull
     protected DefaultSyncConfig createSyncConfig() {
         DefaultSyncConfig syncConfig = super.createSyncConfig();
-        syncConfig.user().setAutoMembership(USER_AUTO_MEMBERSHIP_GROUP_ID, NON_EXISTING_GROUP_ID, USER_ID);
-        syncConfig.group().setAutoMembership(GROUP_AUTO_MEMBERSHIP_GROUP_ID, NON_EXISTING_GROUP_ID2);
-        syncConfig.user().setAutoMembershipConfig(getAutoMembershipConfig());
-
+        syncConfig.user()
+                .setAutoMembership(USER_AUTO_MEMBERSHIP_GROUP_ID, NON_EXISTING_GROUP_ID, USER_ID)
+                .setAutoMembershipConfig(getAutoMembershipConfig());
+        syncConfig.group()
+                .setDynamicGroups(dynamicGroups)
+                .setAutoMembership(GROUP_AUTO_MEMBERSHIP_GROUP_ID, NON_EXISTING_GROUP_ID2)
+                .setAutoMembershipConfig(getAutoMembershipConfig());
         return syncConfig;
     }
 
@@ -136,6 +147,15 @@
     }
 
     @Override
+    @NotNull Set<String> getIdpNamesWithDynamicGroups() {
+        if (dynamicGroups) {
+            return Collections.singleton(idp.getName());
+        } else {
+            return super.getIdpNamesWithDynamicGroups();
+        }
+    }
+
+    @Override
     @NotNull
     Set<Principal> getExpectedGroupPrincipals(@NotNull String userId) throws Exception {
         ImmutableSet.Builder<Principal> builder = ImmutableSet.<Principal>builder()
@@ -157,8 +177,12 @@
     @Override
     @NotNull
     Set<Principal> getExpectedAllSearchResult(@NotNull String userId) throws Exception {
-        // not automembership principals expected when searching for principals => call super method
-        return super.getExpectedGroupPrincipals(userId);
+        if (dynamicGroups) {
+            return Collections.emptySet();
+        } else {
+            // not automembership principals expected when searching for principals => call super method
+            return super.getExpectedGroupPrincipals(userId);
+        }
     }
 
     @Test
@@ -214,12 +238,51 @@
         when(um.getAuthorizable(USER_AUTO_MEMBERSHIP_GROUP_ID)).thenReturn(gr);
         when(uc.getUserManager(root, NamePathMapper.DEFAULT)).thenReturn(um);
 
-        ExternalGroupPrincipalProvider pp = createPrincipalProvider(uc, getAutoMembership(), getAutoMembershipConfig());
+        ExternalGroupPrincipalProvider pp = createPrincipalProvider(root, uc);
         Set<Principal> result = pp.getMembershipPrincipals(um.getAuthorizable(USER_ID).getPrincipal());
         assertTrue(result.stream().map(Principal::getName).noneMatch(USER_AUTO_MEMBERSHIP_GROUP_PRINCIPAL_NAME::equals));
     }
 
     @Test
+    public void testGetMembershipPrincipalsUnknownGroupPrincipal() throws Exception {
+        GroupPrincipal gp = when(mock(GroupPrincipal.class).getName()).thenReturn(idp.getGroup("a").getPrincipalName()).getMock();
+
+        assertTrue(principalProvider.getMembershipPrincipals(gp).isEmpty());
+        verifyNoInteractions(gp);
+    }
+
+    @Test
+    public void testGetMembershipPrincipalsExternalGroupPrincipal() throws Exception {
+        UserManager um = getUserManager(root);
+        User extuser = um.getAuthorizable(USER_ID, User.class);
+        assertNotNull(extuser);
+        
+        Principal externalGroupPrincipal = getExternalGroupPrincipal(extuser.getPrincipal());
+        assertNotNull(externalGroupPrincipal);
+
+        Set<Principal> dynamicGroupMembership = principalProvider.getMembershipPrincipals(externalGroupPrincipal);
+        if (dynamicGroups) {
+            Set<Principal> expected = ImmutableSet.of(groupAutoMembershipGroup.getPrincipal(), configAutoMembershipGroup.getPrincipal());
+            assertEquals(expected, dynamicGroupMembership);        
+        } else {
+            // dynamic-groups not enabled -> group automembership not resolved.
+            assertTrue(dynamicGroupMembership.isEmpty());
+        }
+    }
+    
+    private @Nullable Principal getExternalGroupPrincipal(@NotNull Principal extUserPrincipal) {
+        Iterator<Principal> it = principalProvider.getMembershipPrincipals(extUserPrincipal).iterator();
+        assertTrue(it.hasNext());
+        while (it.hasNext()) {
+            Principal p = it.next();
+            if (!(p instanceof ItemBasedPrincipal)) {
+                return p;
+            }
+        }
+        return null;
+    }
+
+    @Test
     public void testGetPrincipals() throws Exception {
         Set<Principal> expected = getExpectedGroupPrincipals(USER_ID);
 
@@ -253,4 +316,5 @@
             assertFalse(Iterators.contains(res, new PrincipalImpl(NON_EXISTING_GROUP_ID)));
         }
     }
+
 }
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authentication/token/TokenProviderImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authentication/token/TokenProviderImpl.java
index 82b2e56..0ea39ff 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authentication/token/TokenProviderImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authentication/token/TokenProviderImpl.java
@@ -42,7 +42,9 @@
 import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
+import org.apache.jackrabbit.oak.namepath.PathMapper;
 import org.apache.jackrabbit.oak.plugins.identifier.IdentifierManager;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.spi.namespace.NamespaceConstants;
 import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
 import org.apache.jackrabbit.oak.spi.security.authentication.ImpersonationCredentials;
@@ -381,7 +383,7 @@
             String userPath = user.getPath();
             parentPath = userPath + '/' + TOKENS_NODE_NAME;
 
-            Tree userNode = root.getTree(userPath);
+            Tree userNode = getTree(user, userPath);
             tokenParent = TreeUtil.getOrAddChild(userNode, TOKENS_NODE_NAME, TOKENS_NT_NAME);
 
             root.commit();
@@ -403,6 +405,14 @@
         return tokenParent;
     }
 
+    public @NotNull Tree getTree(@NotNull User user, @NotNull String userPath) {
+        if (user instanceof TreeAware) {
+            return ((TreeAware) user).getTree();
+        } else {
+            return root.getTree(userPath);
+        }
+    }
+
     /**
      * Create a new token node below the specified {@code parent}.
      *
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/AuthorizableImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/AuthorizableImpl.java
index 1369abb..7e44186 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/AuthorizableImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/AuthorizableImpl.java
@@ -23,6 +23,7 @@
 import org.apache.jackrabbit.commons.iterator.RangeIteratorAdapter;
 import org.apache.jackrabbit.oak.api.PropertyState;
 import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.security.user.monitor.UserMonitor;
 import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
 import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider;
@@ -43,7 +44,7 @@
 /**
  * Base class for {@code User} and {@code Group} implementations.
  */
-abstract class AuthorizableImpl implements Authorizable, UserConstants {
+abstract class AuthorizableImpl implements Authorizable, UserConstants, TreeAware {
 
     /**
      * logger instance
@@ -179,10 +180,11 @@
         String typeStr = (isGroup()) ? "Group '" : "User '";
         return new StringBuilder().append(typeStr).append(id).append('\'').toString();
     }
-
-    //--------------------------------------------------------------------------
+    
+    //----------------------------------------------------------< TreeAware >---
+    @Override
     @NotNull
-    Tree getTree() {
+    public Tree getTree() {
         if (tree.exists()) {
             return tree;
         } else {
@@ -190,6 +192,8 @@
         }
     }
 
+    //--------------------------------------------------------------------------
+
     @Nullable 
     String getPrincipalNameOrNull() {
         if (principalName == null) {
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuthentication.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuthentication.java
index 8d0828d..09b35cf 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuthentication.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserAuthentication.java
@@ -239,12 +239,7 @@
 
     @Nullable
     private Long getPasswordLastModified(@NotNull User user) throws RepositoryException {
-        Tree userTree;
-        if (user instanceof UserImpl) {
-            userTree = ((UserImpl) user).getTree();
-        } else {
-            userTree = root.getTree(user.getPath());
-        }
+        Tree userTree = Utils.getTree(user, root);
         PropertyState property = userTree.getChild(REP_PWD).getProperty(REP_PASSWORD_LAST_MODIFIED);
         return (property != null) ? property.getValue(Type.LONG) : null;
     }
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImporter.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImporter.java
index 1b36403..90a0af8 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImporter.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImporter.java
@@ -640,7 +640,7 @@
             if (!nonExisting.isEmpty()) {
                 Stopwatch watch = Stopwatch.createStarted();
                 log.debug("ImportBehavior.BESTEFFORT: Found {} entries of rep:members pointing to non-existing authorizables. Adding to rep:members.", nonExisting.size());
-                Tree groupTree = root.getTree(gr.getPath());
+                Tree groupTree = Utils.getTree(gr, root);
 
                 MembershipProvider membershipProvider = userManager.getMembershipProvider();
 
@@ -741,7 +741,7 @@
             // 2. adjust set of impersonators
             List<String> nonExisting = updateImpersonators(a, imp, toRemove, toAdd);
             if (!nonExisting.isEmpty()) {
-                Tree userTree = checkNotNull(root.getTree(a.getPath()));
+                Tree userTree = Utils.getTree(a, root);
                 // copy over all existing impersonators to the nonExisting list
                 PropertyState impersonators = userTree.getProperty(REP_IMPERSONATORS);
                 if (impersonators != null) {
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/Utils.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/Utils.java
index f0f1903..197f661 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/Utils.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/Utils.java
@@ -18,8 +18,10 @@
 
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.User;
+import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
 import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
 import org.apache.jackrabbit.oak.spi.security.principal.EveryonePrincipal;
@@ -103,4 +105,13 @@
             return null;
         }
     }
+    
+    @NotNull
+    static Tree getTree(@NotNull Authorizable authorizable, @NotNull Root root) throws RepositoryException {
+        if (authorizable instanceof TreeAware) {
+            return ((TreeAware) authorizable).getTree();
+        } else {
+            return root.getTree(authorizable.getPath());
+        }
+    }
 }
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UtilsTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UtilsTest.java
index 362d782..6462817 100644
--- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UtilsTest.java
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UtilsTest.java
@@ -21,8 +21,10 @@
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.Group;
 import org.apache.jackrabbit.oak.AbstractSecurityTest;
+import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.spi.nodetype.NodeTypeConstants;
 import org.apache.jackrabbit.oak.spi.security.principal.EveryonePrincipal;
 import org.jetbrains.annotations.NotNull;
@@ -35,8 +37,13 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
 
 public class UtilsTest extends AbstractSecurityTest {
 
@@ -149,4 +156,34 @@
         when(a.isGroup()).thenReturn(true);
         assertFalse(Utils.isEveryone(a));
     }
+    
+    @Test
+    public void testGetTreeFromTreeAware() throws Exception {
+        Tree t = mock(Tree.class);
+        Root r = mock(Root.class);
+        
+        Authorizable a = mock(Authorizable.class, withSettings().extraInterfaces(TreeAware.class));
+        when(((TreeAware) a).getTree()).thenReturn(t);
+        
+        assertSame(t, Utils.getTree(a, r));
+        
+        verifyNoInteractions(r);
+        verify((TreeAware) a).getTree();
+        verifyNoMoreInteractions(a);
+    }
+
+    @Test
+    public void testGetTree() throws Exception {
+        Tree t = mock(Tree.class);
+        Root r = when(mock(Root.class).getTree("/user/path")).thenReturn(t).getMock();
+        
+        Authorizable a = mock(Authorizable.class);
+        when(a.getPath()).thenReturn("/user/path");
+        
+        assertSame(t, Utils.getTree(a, r));
+        
+        verify(r).getTree(anyString());
+        verify(a).getPath();
+        verifyNoMoreInteractions(a, r);
+    }
 }
diff --git a/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md b/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md
index 8b5b145..8b13c8d 100644
--- a/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md
+++ b/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md
@@ -65,9 +65,8 @@
 handling  for external groups in case the [Dynamic Group Membership](#dynamic_membership) 
 option is enabled in the [Configuration](#configuration).
 
-In addition to the properties mentioned above this implementation will additionally create 
-a multivalued STRING property that caches the group principal names of the external 
-user accounts:
+In addition to the properties mentioned above this implementation will create a multivalued STRING property that caches 
+the group principal names of the external user accounts:
 
 - `rep:externalPrincipalNames` : Optional system-maintained property related to [Dynamic Group Membership](#dynamic_membership)
 
@@ -93,7 +92,16 @@
 groups are synchronized (see also [OAK-4101]).
  
 The details and effects on other security related modules are described in 
-section [Dynamic Membership](dynamic.html). 
+section [Dynamic Membership and Dynamic Groups](dynamic.html). 
+
+<a name="dynamic_groups"></a>
+### Dynamic Groups
+
+As of Oak 1.46.0 there exists the option to leverage [Dynamic Membership](#dynamic_membership) in combination with a 
+new `Dynamic Groups` configuration option (see also [OAK-9803]). If both options are enabled external groups will continue 
+to be synchronized into the repository making sure the user-group relationship can still be inspected using Jackrabbit 
+User Management API without losing the benefits of the dynamic membership.
+See section [Dynamic Membership and Dynamic Groups](dynamic.html) for details and comparison.
 
 <a name="xml_import"></a>
 #### XML Import
@@ -164,9 +172,27 @@
 | 0076 | Attempt to delete property '%s' from protected external identity node '%s' |
 | 0076 | Attempt to add protected external identity '%s'                            |
 | 0076 | Attempt to add node '%s' to protected external identity node '%s'          |
-| 0076 | Attempt to remove protected external identity '%s'                      |
+| 0076 | Attempt to remove protected external identity '%s'                         |
 | 0076 | Attempt to remove node '%s' from protected external identity               |
 
+##### Enforcing dynamic groups
+
+If `user.dynamicMembership` is enabled together with `group.dynamicGroups` a separate validator will be present to
+make sure no members are added to the dynamic groups through regular API calls (`Group.addMember(Authorizable)` and 
+`Group.addMembers(String...`).
+
+Note, that groups that have been synchronized prior to dynamic synchronization also subject
+to this validator and can no longer have new members added. They will eventually become 
+dynamic upon synchronization of their members, which will wipe out previously written 
+membership information.
+
+The following constraint violation exceptions will be raised upon persisting changes when new members have been added 
+to a dynamic external group:
+
+| Code | Message                                                                    |
+|------|----------------------------------------------------------------------------|
+| 0077 | "Attempt to add members to dynamic group '%s' at '%s'"                     |
+
 <a name="configuration"></a>
 ### Configuration
 
@@ -190,6 +216,7 @@
 | Group Expiration Time         | `group.expirationTime`        | Duration until a synced group expires (eg. '1h 30m' or '1d'). |
 | Group Path Prefix             | `group.pathPrefix`            | The path prefix used when creating new groups. |
 | Group property mapping        | `group.propertyMapping`       | List mapping definition of local properties from external ones. |
+| Group 'Dynamic Groups'        | `group.dynamicGroups`         | Only takes effect in combination with `user.dynamicMembership` and will result in external groups being synced as dynamic groups. |
 | | | |
 
 #### Automatic Membership with AutoMembershipConfig
@@ -238,4 +265,5 @@
 [OAK-4101]: https://issues.apache.org/jira/browse/OAK-4101
 [OAK-2687]: https://issues.apache.org/jira/browse/OAK-2687
 [OAK-4301]: https://issues.apache.org/jira/browse/OAK-4301
-[OAK-9463]: https://issues.apache.org/jira/browse/OAK-9463
\ No newline at end of file
+[OAK-9463]: https://issues.apache.org/jira/browse/OAK-9463
+[OAK-9803]: https://issues.apache.org/jira/browse/OAK-9803
\ No newline at end of file
diff --git a/oak-doc/src/site/markdown/security/authentication/external/dynamic.md b/oak-doc/src/site/markdown/security/authentication/external/dynamic.md
index 8ab37c7..c0c039d 100644
--- a/oak-doc/src/site/markdown/security/authentication/external/dynamic.md
+++ b/oak-doc/src/site/markdown/security/authentication/external/dynamic.md
@@ -15,12 +15,12 @@
    limitations under the License.
 -->
 
-User and Group Synchronization : Dynamic Membership
----------------------------------------------------
+User and Group Synchronization : Dynamic Membership and Dynamic Groups
+----------------------------------------------------------------------
 
 As of Oak 1.5.3 the default sync handler comes with an additional configuration 
 option (see section [Configuration](defaultusersync.html#configuration) 
-that allows to enable dynamic group membership resolution for external users. 
+that allows enabling dynamic group membership resolution for external users. 
 
 Enabling dynamic membership in the [DefaultSyncConfig] will change the way external
 groups are synchronized (see [OAK-4101]) and how automatic group membership 
@@ -32,22 +32,26 @@
 - avoid storing/updating auto-membership which is assigned to all external users
 - ease principal resolution upon repository login
 
-#### SyncContext with Dynamic Membership
+### SyncContext with Dynamic Membership
 
 With the default `SyncHandler` this configuration option will show the following 
 effects:
 
-##### External Groups
+#### External Groups
 
 - If enabled the handler will use an alternative [SyncContext] to synchronize external groups (`DynamicSyncContext`).
-- Instead of synchronizing groups into the user management, this `DynamicSyncContext`
-  will additionally set the property `rep:externalPrincipalNames` on the synchronized external user
+- Instead of synchronizing membership information alongside the group accounts, this `DynamicSyncContext`
+  will set the property `rep:externalPrincipalNames` on the synchronized external user
 - `rep:externalPrincipalNames` is a system maintained multivalued property of type 
   'STRING' storing the names of the `java.security.acl.Group`-principals a given 
   external user is member of (both declared and inherited according to the configured
   membership nesting depth)
-- External groups will no longer be synchronized into the repository's user management 
+- By default, external groups will no longer be synchronized into the repository's user management 
   but will only be available as `Principal`s (see section _User Management_ below).
+- If the `Dynamic Groups` option is enabled together with the `Dynamic Membership`, external groups will be 
+  synchronized into the user management but marked as _dynamic_. User-Group relationship for these dynamic external  
+  groups will be determined by a dedicated `DynamicMembershipService` that is registered if both options are enabled
+  for a given `SyncHandler` mapping.
   
 Note: as a further improvement the [PrincipalNameResolver] interface was introduced 
 in Oak 1.6.1 to allow for optimized resolution of a principal names from a given 
@@ -55,12 +59,12 @@
 of `ExternalIdentityProvider` needs to also implement `PrincipalNameResolver`.
 See also [OAK-5210].
 
-##### Automatic Membership
+#### Automatic Membership
 
 - If enabled automatic membership assignment for existing, local groups will not longer be written to the repository
-- Instead the `ExternalPrincipalConfiguration` _("Apache Jackrabbit Oak External PrincipalConfiguration")_ will keep 
-  track of the mapping between registered [SyncHandler]s (i.e. auto-membership configuration) and [ExternalIdentityProvider]s.
-  This allows to determine auto-membership based on the `rep:externalId` stored with the user accounts.
+- Instead, the `ExternalPrincipalConfiguration` _("Apache Jackrabbit Oak External PrincipalConfiguration")_ will keep 
+  track of the mapping between registered [SyncHandler]s (i.e. auto-membership configuration) and [ExternalIdentityProvider]s 
+  and determine auto-membership based on the `rep:externalId` stored with the user accounts.
 - The `PrincipalProvider` associated with this dedicated principal configuration 
   will expand the collection of `Principal`s generated for the following calls 
   with the automatically assigned principals:
@@ -77,11 +81,11 @@
   configuration is respected (see also [OAK-5194] and [OAK-5195])
 - With [OAK-9462] an implementation of `DynamicMembershipProvider` will be registered 
   and reflect autoMembership for synchronized external users in the User Management API (see below).
-  The same applies for the conditional auto-membership as introduced with [OAK-9463]
+  The same applies for the conditional auto-membership as introduced with [OAK-9463].
   
-#### Effect of Dynamic Membership on other Security Modules
+### Effect of Dynamic Membership on other Security Modules
   
-##### Principal Management
+#### Principal Management
 
 The dynamic (principal) membership features comes with a dedicated `PrincipalConfiguration` 
 implementation (i.e. [ExternalPrincipalConfiguration]) that is in charge of securing  
@@ -91,7 +95,7 @@
 Additionally, the [ExternalPrincipalConfiguration] provides a `PrincipalProvider` 
 implementation which makes external (group) principals available to the repository's 
 authentication and authorization using the `rep:externalPrincipalNames` as a 
-persistent cache to avoid expensive lookup on the IDP.
+persistent cache to avoid an expensive lookup on the IDP.
 This also makes external `Principal`s retrievable and searchable through the 
 Jackrabbit principal management API (see section [Principal Management](../../principal.html)
 for a comprehensive description).
@@ -101,19 +105,33 @@
 if it can be read from any of the `rep:externalPrincipalNames` properties 
 present using a dedicated query.
 
-##### User Management
+##### API Overview
 
-As described above the dynamic membership option will effectively disable the
-synchronization of the complete external group account information into the repository's
-user management feature but limit the synchronized information to the principal 
-names and the membership relation between a given `java.security.acl.Group` principal 
-and external user accounts.
+`extUserName`       : the principal name of an external user<br>
+`extGroupName`      : the principal name of an external group<br>
+`extUserPrincipal`  : the principal associated with a synchronized external user<br>
+`extGroupPrincipal` : the principal associated with a synchronized external group<br>
+
+| API Call                                                 | Default Sync | Dynamic Membership | Dynamic Membership + Dynamic Groups | Comment |
+-----------------------------------------------------------|--------------|--------------------|------------------------|---------|
+| `PrincipalManager.getPrincipal(extUserName)`             | ok           | ok                 | ok                     | |
+| `PrincipalManager.getPrincipal(extGroupName)`            | ok           | (ok) <sup>1</sup>  | ok                     | <sup>1</sup> If the editing session can read any `rep:externalPrincipalNames` property containing the group principal name |
+| `PrincipalManager.getGroupMembership(extUserPrincipal)`  | ok           | ok                 | ok                     | Dynamic group principals include both declared external groups and configured auto-membership principals (including inherited principals).|
+| `PrincipalManager.getGroupMembership(extGroupPrincipal)` | ok           | - <sup>2</sup>     | - <sup>2,3</sup>       | <sup>2</sup> Group membership gets flattened and stored with the external user. Group-group relationship is not preserved.<br><sup>3</sup> For dynamic groups synced into the repository the configured auto-membership principals are resolved, see also user management API below.  |
+
+#### User Management
+##### User Management without Dynamic Groups Option
+
+Unless the 'Dynamic Groups' option is set additionally, the dynamic membership option will effectively disable the
+synchronization of the external group account information into the repository's user management feature.
+It will instead limit the synchronized information to the group principal names and the membership relation between a 
+given `java.security.acl.Group` principal and external user accounts.
 
 The user management API will consequently no longer be knowledgeable of **external group identities**.
 
 For groups that have been synchronized before dynamic membership got enabled, the following rules will 
 apply:
-- if option `user.enforceDynamicMembership` is disabled (default), previously synced groups and their member information will continue to be synchronized according to the sync configuration.
+- if option `user.enforceDynamicMembership` is disabled (default), previously synced groups, and their member information will continue to be synchronized according to the sync configuration.
 - if option `user.enforceDynamicMembership` is enabled, previously synced membership will be migrated to become dynamic upon user synchronization. The synchronized group will be removed once it not longer has any declared members.
 
 While this behavior does not affect default authentication and authorization modules 
@@ -128,7 +146,56 @@
 `Group.isDeclaredMember`, `Group.getMembers`, `Group.getDeclaredMembers` as well as `Authorizable.memberOf`
 and `Authorizable.declaredMemberOf()`.
 
-##### Authentication
+##### User Management with Dynamic Groups Option enabled
+
+If the 'Dynamic Groups' flag is turned on in addition, external group accounts will continue to be synchronized into the 
+repository's user management. However, membership information will not be stored together with the groups but instead will 
+be dynamically calculated from the `rep:externalPrincipalNames` property caching the membership information with the user 
+accounts. This is achieved by means of a dedicated implementation of the `DynamicMembershipProvider` interface.
+
+For groups that have been synchronized prior to enabling dynamic membership, the following rules will 
+apply:
+- if option `user.enforceDynamicMembership` is disabled (default), previously synced groups, and their member information will continue to be synchronized according to the sync configuration.
+- if option `user.enforceDynamicMembership` is enabled, previously synced membership will be migrated to become dynamic upon user synchronization. The synchronized group will _not_ be removed once it not longer has any declared members.
+ 
+Note, that manually adding members to these dynamic external groups using `Group.addMember`, `Group.addMembers` or 
+equivalent Oak API operations will be prevented by a dedicated validator that is enabled as soon as the _Dynamic Groups_
+option is present together with _Dynamic Membership_.
+
+##### API Overview
+
+`extUserId`  : the ID of a synchronized external user<br>
+`extGroupId` : the ID of a synchronized external group<br>
+`extUser`    : a synchronized external user as `org.apache.jackrabbit.api.security.user.User`<br>
+`extGroup`   : a synchronized external group as `org.apache.jackrabbit.api.security.user.Group`<br>
+`autoGroup`  : a local group configured in the auto-membership option of the `DefaultSyncConfig`
+
+| API Call                                                 | Default Sync | Dynamic Membership | Dynamic Membership + Dynamic Groups | Comment |
+-----------------------------------------------------------|--------------|--------------------|------------------------|---------|
+| `UserManager.getAuthorizable(extUserId)`                 | ok           | ok                 | ok                     | Same applies for<br>`UserManager.getAuthorizable(extUserId, User.class)`,<br>`UserManager.getAuthorizable(extUserPrincipal)`,<br>`UserManager.getAuthorizableByPath(extUserPath)` |
+| `UserManager.getAuthorizable(extGroupId)`                | ok           | -                  | ok                     | Same applies for<br>`UserManager.getAuthorizable(extGroupId, Group.class)`,<br>`UserManager.getAuthorizable(extGroupPrincipal)`,<br>`UserManager.getAuthorizableByPath(extGroupPath)`        |
+| `extUser.declaredMemberOf()`                             | ok           | - <sup>3</sup>     | (ok) <sup>4</sup>      | <sup>3</sup> Only auto-membership to local groups, external groups not synced.<br><sup>4</sup> Same as `User.memberOf()` as nested group membership gets flattened upon dynamic sync. Configured auto-membership is reflected through dynamic `AutoMembershipProvider`. |
+| `extUser.memberOf()`                                     | ok           | - <sup>3</sup>     | ok                     | |
+| `extGroup.declaredMemberOf()`                            | ok           | - <sup>5</sup>     | - <sup>6</sup>         | <sup>5</sup> External groups not synced!<br><sup>6</sup> Only (conditional) automembership as upon dynamic sync nested group membership gets flattened |
+| `extGroup.memberOf()`                                    | ok           | - <sup>5</sup>     | - <sup>6</sup>         | |
+| `extGroup.getDeclaredMembers()`                          | ok           | - <sup>5</sup>     | (ok) <sup>7</sup>      | <sup>7</sup> Same as `Group.getMembers()` | 
+| `extGroup.getMembers()`                                  | ok           | - <sup>5</sup>     | (ok) <sup>8</sup>      | <sup>8</sup> Only includes external users as nested membership gets flattened upon dynamic sync. | 
+| `extGroup.isDeclaredMember(extUser)`                     | ok           | - <sup>5</sup>     | (ok) <sup>9</sup>      | <sup>9</sup> Same as `Group.isMember(extUser)` |
+| `extGroup.isMember(extUser)`                             | ok           | - <sup>5</sup>     | ok                     |  | 
+| `extGroup.isDeclaredMember(extGroup)`                    | ok           | - <sup>5</sup>     | - <sup>10</sup>        | <sup>10</sup> No group-group relations as nested membership gets flattened  | 
+| `extGroup.isMember(extGroup)`                            | ok           | - <sup>5</sup>     | - <sup>10</sup>        |  | 
+| `extGroup.addMember(Authorizable)`                       | ok           | - <sup>5</sup>     | - <sup>11</sup>        | <sup>11</sup> Adding members to dynamic groups will fail upon commit. | 
+| `extGroup.addMembers(String...)`                         | ok           | - <sup>5</sup>     | - <sup>11</sup>        |  | 
+| `extGroup.removeMember(Authorizable)`                    | ok           | - <sup>5</sup>     | ok                     |  | 
+| `extGroup.removeMembers(String...)`                      | ok           | - <sup>5</sup>     | ok                     |  | 
+| `autoGroup.isDeclaredMember(extUser)`                    | ok           | ok <sup>12</sup>   | ok <sup>12</sup>       | <sup>12</sup> Through `AutoMembershipProvider` but not stored with local group node that is listed in 'auto-membership' config. |
+| `autoGroup.isMember(extUser)`                            | ok           | ok <sup>12</sup>   | ok <sup>12</sup>       |  |
+| `autoGroup.isDeclaredMember(extGroup)`                   | ok           | - <sup>5</sup>     | ok <sup>12</sup>       |  | 
+| `autoGroup.isMember(extGroup)`                           | ok           | - <sup>5</sup>     | ok <sup>12</sup>       |  |
+| `autoGroup.getDeclaredMembers()`                         | ok           | (ok) <sup>5,12</sup>| ok <sup>12</sup>      |  |
+| `autoGroup.getMembers()`                                 | ok           | (ok) <sup>5,12</sup>| ok <sup>12</sup>      |  |
+
+#### Authentication
 
 The authentication setup provided by Oak is not affected by the dynamic membership 
 handling as long as the configured `LoginModule` implementations rely on the 
@@ -136,7 +203,7 @@
 _("Apache Jackrabbit Oak External PrincipalConfiguration")_ is properly registered 
 with the `SecurityProvider` (see section [Configuration](defaultusersync.html#configuration)).
 
-##### Authorization
+#### Authorization
 
 The authorization modules shipped with Oak only depend on `Principal`s (and not on
 user management functionality) and are therefore not affected by the dynamic 
diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/TreeAware.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/TreeAware.java
new file mode 100644
index 0000000..db24b0f
--- /dev/null
+++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/TreeAware.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.plugins.tree;
+
+import org.apache.jackrabbit.oak.api.Tree;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Oak internal utility interface to avoid repeated retrieval of an underlying {@link Tree}.
+ * Note, that is is the responsibility of the caller to make user that the content session and root 
+ * used to retrieve {@link TreeAware} object remains the same.
+ */
+public interface TreeAware {
+
+    @NotNull
+    Tree getTree();
+    
+}
\ No newline at end of file
diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/package-info.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/package-info.java
index 221dffa..6141ac0 100644
--- a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/package-info.java
+++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/plugins/tree/package-info.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@Version("3.2.1")
+@Version("3.3.0")
 package org.apache.jackrabbit.oak.plugins.tree;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java
index 3efcbad..014e953 100644
--- a/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java
+++ b/oak-security-spi/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java
@@ -21,7 +21,9 @@
 
 import org.apache.jackrabbit.api.security.user.User;
 import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
 import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil;
 import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
@@ -55,7 +57,8 @@
 
     //------------------------------------------------------------< private >---
     @Nullable
-    private String getPasswordHash(@NotNull Root root, @NotNull User user) throws RepositoryException {
-        return TreeUtil.getString(root.getTree(user.getPath()), UserConstants.REP_PASSWORD);
+    private static String getPasswordHash(@NotNull Root root, @NotNull User user) throws RepositoryException {
+        Tree tree = (user instanceof TreeAware) ? ((TreeAware)user).getTree() : root.getTree(user.getPath());
+        return TreeUtil.getString(tree, UserConstants.REP_PASSWORD);
     }
 }
diff --git a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java
index 784712e..aaf0567 100644
--- a/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java
+++ b/oak-security-spi/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java
@@ -21,6 +21,7 @@
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.plugins.memory.PropertyStates;
+import org.apache.jackrabbit.oak.plugins.tree.TreeAware;
 import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
 import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
 import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
@@ -28,19 +29,23 @@
 import org.jetbrains.annotations.Nullable;
 import org.junit.Before;
 import org.junit.Test;
-import org.mockito.Mockito;
 
 import javax.jcr.nodetype.ConstraintViolationException;
 
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
 
 public class PasswordChangeActionTest {
 
     private static final String USER_PATH = "/userpath";
 
-    private final NamePathMapper namePathMapper = Mockito.mock(NamePathMapper.class);
+    private final NamePathMapper namePathMapper = mock(NamePathMapper.class);
 
     private PasswordChangeAction pwChangeAction;
 
@@ -49,19 +54,19 @@
     @Before
     public void before() throws Exception {
         pwChangeAction = new PasswordChangeAction();
-        pwChangeAction.init(Mockito.mock(SecurityProvider.class), ConfigurationParameters.EMPTY);
+        pwChangeAction.init(mock(SecurityProvider.class), ConfigurationParameters.EMPTY);
 
-        user = Mockito.mock(User.class);
+        user = mock(User.class);
         when(user.getPath()).thenReturn(USER_PATH);
     }
 
     private static Root createRoot(@Nullable String pw) throws Exception {
-        Tree userTree = Mockito.mock(Tree.class);
+        Tree userTree = mock(Tree.class);
         if (pw != null) {
             String pwHash = PasswordUtil.buildPasswordHash(pw);
             when(userTree.getProperty(UserConstants.REP_PASSWORD)).thenReturn(PropertyStates.createProperty(UserConstants.REP_PASSWORD, pwHash));
         }
-        Root root = Mockito.mock(Root.class);
+        Root root = mock(Root.class);
         when(root.getTree(USER_PATH)).thenReturn(userTree);
         return root;
     }
@@ -89,4 +94,20 @@
         verify(user).getPath();
         verifyNoMoreInteractions(user);
     }
+    
+    @Test
+    public void testUserIsTreeAware() throws Exception {
+        Root r = createRoot("pw");
+        Tree t = r.getTree(USER_PATH);
+        
+        User u = mock(User.class, withSettings().extraInterfaces(TreeAware.class));
+        when(((TreeAware) u).getTree()).thenReturn(t);
+
+        pwChangeAction.onPasswordChange(u, "changedPassword", r, namePathMapper);
+        
+        verify(u, never()).getPath();
+        verify(((TreeAware) u)).getTree();
+        verify(r).getTree(USER_PATH);
+        verifyNoMoreInteractions(r, u);
+    }
 }