You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by an...@apache.org on 2022/06/27 14:15:37 UTC

[jackrabbit-oak] branch trunk updated: OAK-9799 : Optional validator to mark external users/groups as protected (#604)

This is an automated email from the ASF dual-hosted git repository.

angela pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 23df42242a OAK-9799 : Optional validator to mark external users/groups as protected (#604)
23df42242a is described below

commit 23df42242a29cb1c42cfd61692eae2801d405a92
Author: anchela <an...@apache.org>
AuthorDate: Mon Jun 27 16:15:32 2022 +0200

    OAK-9799 : Optional validator to mark external users/groups as protected (#604)
    
    * OAK-9799 : Optional validator to mark external users/groups as protected
    
    * OAK-9799 : Optional validator to mark external users/groups as protected (review findings from jose antonio insua)
---
 .../external/impl/ExternalIdentityConstants.java   |  23 +
 .../principal/ExternalPrincipalConfiguration.java  |  30 +-
 .../principal/ExternalUserValidatorProvider.java   | 296 ++++++++++++
 .../impl/principal/IdentityProtectionType.java     |  46 ++
 .../external/AbstractExternalAuthTest.java         |  17 +-
 .../ExternalPrincipalConfigurationTest.java        |  46 ++
 .../impl/principal/ExternalUserValidatorTest.java  | 527 +++++++++++++++++++++
 .../impl/principal/IdentityProtectionTypeTest.java |  27 ++
 .../authentication/external/defaultusersync.md     |  37 +-
 9 files changed, 1034 insertions(+), 15 deletions(-)

diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/ExternalIdentityConstants.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/ExternalIdentityConstants.java
index d6a5260e9b..2e256b41a2 100644
--- a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/ExternalIdentityConstants.java
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/ExternalIdentityConstants.java
@@ -79,6 +79,29 @@ public interface ExternalIdentityConstants {
      */
     boolean DEFAULT_PROTECT_EXTERNAL_IDS = true;
 
+    /**
+     * Configuration parameter to enable special protection of external users and groups and the subtrees they define.
+     *
+     * @see <a href="https://issues.apache.org/jira/browse/OAK-9799">OAK-9799</a>
+     */
+    String PARAM_PROTECT_EXTERNAL_IDENTITIES = "protectExternalIdentities";
+
+    /**
+     * Default value for the {@link #PARAM_PROTECT_EXTERNAL_IDENTITIES} configuration option that doesn't enforce any
+     * protection (backwards compatible behavior).
+     */
+    String VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE = "None";
+    /**
+     * Value for the {@link #PARAM_PROTECT_EXTERNAL_IDENTITIES} configuration option that will log warnings upon 
+     * modification synchronized external users/groups but doesn't enforce the protection.
+     */
+    String VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN = "Warn";
+    /**
+     * Value for the {@link #PARAM_PROTECT_EXTERNAL_IDENTITIES} configuration option that will enforce protection of 
+     * synchronized external identities.
+     */
+    String VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED = "Protected";
+
     /**
      * Configuration parameter to define names of {@code SystemUserPrincipal}s that should be excluded from the 
      * protection verification for any of the {@code RESERVED_PROPERTY_NAMES} in addition to the built in system principal.
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 13dc1e122f..f3606ac242 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
@@ -16,11 +16,13 @@
  */
 package org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;
 
+import com.google.common.collect.ImmutableList;
 import org.apache.felix.scr.annotations.Activate;
 import org.apache.felix.scr.annotations.Component;
 import org.apache.felix.scr.annotations.Deactivate;
 import org.apache.felix.scr.annotations.Properties;
 import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.PropertyOption;
 import org.apache.felix.scr.annotations.Service;
 import org.apache.jackrabbit.api.security.principal.PrincipalManager;
 import org.apache.jackrabbit.oak.api.Root;
@@ -56,6 +58,10 @@ import java.util.Map;
 import java.util.Set;
 
 import static org.apache.jackrabbit.oak.spi.security.RegistrationConstants.OAK_SECURITY_NAME;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDENTITIES;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN;
 
 /**
  * Implementation of the {@code PrincipalConfiguration} interface that provides
@@ -78,6 +84,14 @@ import static org.apache.jackrabbit.oak.spi.security.RegistrationConstants.OAK_S
                 label = "External Identity Protection",
                 description = "If disabled rep:externalId properties won't be properly protected (backwards compatible behavior). NOTE: for security reasons it is strongly recommend to keep the protection enabled!",
                 boolValue = ExternalIdentityConstants.DEFAULT_PROTECT_EXTERNAL_IDS),
+        @Property(name = PARAM_PROTECT_EXTERNAL_IDENTITIES,
+                label = "External User and Group Protection",
+                description = "If 'None' is selected the synchronized external users/groups won't be protected (backwards compatible behavior) and can be edited like local users/groups. NOTE: in order to avoid having inconsistencies between the IDP that defines the external identities and local synced identities it is recommend to enable the protection. With option 'Warn' the protection is disabled but warnings will be logged.",
+                options = {
+                    @PropertyOption(name = VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE, value = VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE),
+                    @PropertyOption(name = VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN, value = VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN),
+                    @PropertyOption(name = VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED, value = VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED)
+                }),
         @Property(name = ExternalIdentityConstants.PARAM_SYSTEM_PRINCIPAL_NAMES, 
                 label = "System Principal Names", 
                 description = "Names of additional 'SystemUserPrincipal' instances that are excluded from the protection check. Note that this configuration does not grant the required permission to perform the operation.", 
@@ -139,8 +153,16 @@ public class ExternalPrincipalConfiguration extends ConfigurationBase implements
     @NotNull
     @Override
     public List<? extends ValidatorProvider> getValidators(@NotNull String workspaceName, @NotNull Set<Principal> principals, @NotNull MoveTracker moveTracker) {
-        SystemPrincipalConfig spConfig = new SystemPrincipalConfig(getPrincipalNames());
-        return Collections.singletonList(new ExternalIdentityValidatorProvider(spConfig.containsSystemPrincipal(principals), protectedExternalIds()));
+        boolean isSystem = new SystemPrincipalConfig(getPrincipalNames()).containsSystemPrincipal(principals);
+        
+        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);
+        }
     }
 
     @NotNull
@@ -200,6 +222,10 @@ public class ExternalPrincipalConfiguration extends ConfigurationBase implements
     private boolean protectedExternalIds() {
         return getParameters().getConfigValue(ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDS, ExternalIdentityConstants.DEFAULT_PROTECT_EXTERNAL_IDS);
     }
+
+    private @NotNull IdentityProtectionType getIdentityProtectionType() {
+        return IdentityProtectionType.fromLabel(getParameters().getConfigValue(PARAM_PROTECT_EXTERNAL_IDENTITIES, VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE));
+    }
     
     @NotNull
     private Set<String> getPrincipalNames() {
diff --git a/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorProvider.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorProvider.java
new file mode 100644
index 0000000000..fb386f85ec
--- /dev/null
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorProvider.java
@@ -0,0 +1,296 @@
+/*
+ * 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.Iterables;
+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.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.Context;
+import org.apache.jackrabbit.oak.spi.security.SecurityConfiguration;
+import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
+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 org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.apache.jackrabbit.JcrConstants.JCR_MIXINTYPES;
+
+class ExternalUserValidatorProvider extends ValidatorProvider implements ExternalIdentityConstants  {
+
+    private static final Logger log = LoggerFactory.getLogger(ExternalUserValidatorProvider.class);
+
+    private final RootProvider rootProvider;
+    private final TreeProvider treeProvider;
+    private final String authorizableRootPath;
+    private final Context aggregatedCtx;
+    private final IdentityProtectionType protectionType;
+    
+    private Root rootBefore;
+    private Root rootAfter;
+    
+    ExternalUserValidatorProvider(@NotNull RootProvider rootProvider, 
+                                  @NotNull TreeProvider treeProvider, 
+                                  @NotNull SecurityProvider securityProvider,
+                                  @NotNull IdentityProtectionType protectionType) {
+        checkArgument(protectionType != IdentityProtectionType.NONE);
+        this.rootProvider = rootProvider;
+        this.treeProvider = treeProvider;
+        this.protectionType = protectionType;
+
+        this.authorizableRootPath = UserUtil.getAuthorizableRootPath(securityProvider.getParameters(UserConfiguration.NAME), AuthorizableType.AUTHORIZABLE);
+        aggregatedCtx = new AggregatedContext(securityProvider);
+        
+    }
+
+    @Override
+    protected @NotNull Validator getRootValidator(NodeState before, NodeState after, CommitInfo info) {
+        this.rootBefore = rootProvider.createReadOnlyRoot(before);
+        this.rootAfter = rootProvider.createReadOnlyRoot(after);
+        return new SubtreeValidator(new ExternalUserValidator(), Iterables.toArray(PathUtils.elements(authorizableRootPath), String.class));
+    }
+    
+    private class ExternalUserValidator extends DefaultValidator {
+        
+        private Tree parentBefore;
+        private Tree parentAfter;
+        
+        boolean isExternalIdentity = false;
+        
+        private ExternalUserValidator() {}
+        
+        private ExternalUserValidator(@NotNull ExternalUserValidator parentValidator, @NotNull Tree parentBefore, @NotNull Tree parentAfter) {
+            this.parentBefore = parentBefore;
+            this.parentAfter = parentAfter;
+
+            setExternalIdentity(parentValidator, parentBefore);
+        }
+
+        private ExternalUserValidator(@NotNull ExternalUserValidator parentValidator, @NotNull Tree parent, boolean isBefore) {
+            if (isBefore) {
+                this.parentBefore = parent;
+                setExternalIdentity(parentValidator, parentBefore);
+            } else {
+                this.parentAfter = parent;
+                setExternalIdentity(parentValidator, parentAfter);
+            }
+        }
+        
+        @Override
+        public void propertyAdded(PropertyState after) throws CommitFailedException {
+            Tree afterTree = getParentAfter();
+            if (definedSecurityContext(afterTree, after)) {
+                return;
+            }
+
+            if (isModifyingExternalIdentity(isExternalIdentity, after)) {
+                String msg = String.format("Attempt to add property '%s' to protected external identity node '%s'", after.getName(), afterTree.getPath());
+                handleViolation(msg);
+            }
+        }
+
+        @Override
+        public void propertyChanged(PropertyState before, PropertyState after) throws CommitFailedException {
+            Tree beforeTree = getParentBefore();
+            if (definedSecurityContext(beforeTree, before)) {
+                return;
+            }
+            if (isModifyingExternalIdentity(isExternalIdentity, before)) {
+                String msg = String.format("Attempt to modify property '%s' at protected external identity node '%s'", before.getName(), beforeTree.getPath());
+                handleViolation(msg);
+            }
+        }
+
+        @Override
+        public void propertyDeleted(PropertyState before) throws CommitFailedException {
+            Tree beforeTree = getParentBefore();
+            if (definedSecurityContext(beforeTree, before)) {
+                return;
+            }
+            if (isModifyingExternalIdentity(isExternalIdentity, before)) {
+                String msg = String.format("Attempt to delete property '%s' from protected external identity node '%s'", before.getName(), beforeTree.getPath());
+                handleViolation(msg);
+            }
+        }
+
+        @Override
+        public @Nullable Validator childNodeAdded(String name, NodeState after) throws CommitFailedException {
+            Tree afterParent = getParentAfter();
+            Tree afterTree = treeProvider.createReadOnlyTree(afterParent, name, after);
+            if (definedSecurityContext(afterTree, null)) {
+                return null;
+            }
+
+            if (isExternalIdentity(afterTree)) {
+                String msg = String.format("Attempt to add protected external identity '%s'", afterTree.getPath());
+                handleViolation(msg);
+                return null;
+            } else if (isModifyingExternalIdentity(isExternalIdentity, null)) {
+                String msg = String.format("Attempt to add node '%s' to protected external identity node '%s'", name, afterParent.getPath());
+                handleViolation(msg);
+                return null;
+            } else if (UserUtil.isType(afterTree, AuthorizableType.AUTHORIZABLE)) {
+                // user/group creation (not protected) -> no traversal into the subtree needed
+                return null;
+            } else {
+                return new ExternalUserValidator(this, afterTree, false);
+            }
+        }
+
+        @Override
+        public @Nullable Validator childNodeChanged(String name, NodeState before, NodeState after) {
+            Tree beforeTree = treeProvider.createReadOnlyTree(getParentBefore(), name, before);
+            Tree afterTree = treeProvider.createReadOnlyTree(getParentAfter(), name, after);
+            
+            if (definedSecurityContext(beforeTree, null)) {
+                return null;
+            }
+            return new ExternalUserValidator(this, beforeTree, afterTree);
+        }
+
+        @Override
+        public @Nullable Validator childNodeDeleted(String name, NodeState before) throws CommitFailedException {
+            Tree beforeTree = treeProvider.createReadOnlyTree(getParentBefore(), name, before);
+            if (definedSecurityContext(beforeTree, null)) {
+                return null;
+            }
+            
+            if (isExternalIdentity(beforeTree)) {
+                // attempt to remove an external identity
+                String msg = String.format("Attempt to remove protected external identity '%s'", beforeTree.getPath());
+                handleViolation(msg);
+                return null;
+            }
+            
+            if (isModifyingExternalIdentity(isExternalIdentity, null)) {
+                // attempt to remove a node below an external user/group
+                String msg = String.format("Attempt to remove node '%s' from protected external identity", beforeTree.getPath());
+                handleViolation(msg);
+                return null;
+            }
+            
+            // decend into subtree to spot any removal of external user/group or it's subtree
+            return new ExternalUserValidator(this, beforeTree, true);
+        }
+        
+        private void setExternalIdentity(@NotNull ExternalUserValidator parentValidator, @NotNull Tree parent) {
+            if (parentValidator.isExternalIdentity) {
+                this.isExternalIdentity = true;
+            } else {
+                this.isExternalIdentity = isExternalIdentity(parent);
+            }
+        }
+        
+        private boolean isExternalIdentity(@NotNull Tree tree) {
+            return UserUtil.isType(tree, AuthorizableType.AUTHORIZABLE) && tree.hasProperty(REP_EXTERNAL_ID);
+        }
+        
+        private @NotNull Tree getParentBefore() {
+            if (parentBefore == null) {
+                parentBefore = rootBefore.getTree(authorizableRootPath);
+            }
+            return parentBefore;
+        }
+        
+        private @NotNull Tree getParentAfter() {
+            if (parentAfter == null) {
+                parentAfter = rootAfter.getTree(authorizableRootPath);
+            }
+            return parentAfter;
+        }
+        
+        private boolean isModifyingExternalIdentity(boolean insideAuthorizable, @Nullable PropertyState propertyState) {
+            return insideAuthorizable && !isExcludedProperty(propertyState);
+        }
+
+        /**
+         * Adding mixin types that define security-related content as this is not a 
+         * modification of the user/group that is exposed through user-mgt API. Note however, that editing non-security
+         * related mixins would still fail as the child items defined by the mixin type cannot be written.
+         * 
+         * @param propertyState The property to be tested
+         * @return {@code true} if the given property is excluded from protection
+         */
+        private boolean isExcludedProperty(@Nullable PropertyState propertyState) {
+            if (propertyState == null) {
+                return false;
+            } else {
+                return JCR_MIXINTYPES.equals(propertyState.getName());
+            }
+        }
+        
+        private boolean definedSecurityContext(@NotNull Tree tree, @Nullable PropertyState propertyState) {
+            if (propertyState != null) {
+                return aggregatedCtx.definesProperty(tree, propertyState);
+            } else {
+                return aggregatedCtx.definesTree(tree);
+            }
+        }
+        
+        private void handleViolation(@NotNull String msg) throws CommitFailedException {
+            if (protectionType == IdentityProtectionType.WARN) {
+                log.warn(msg);
+            } else {
+                // the validator is never create with IdentityProtectionType.NONE
+                throw new CommitFailedException(CommitFailedException.CONSTRAINT, 76, msg);
+            }
+        }
+    }
+    
+    private static final class AggregatedContext extends Context.Default {
+        
+        List<Context> ctxs;
+        
+        private AggregatedContext(@NotNull SecurityProvider securityProvider) {
+            ImmutableList.Builder<Context> builder = ImmutableList.builder();
+            for (SecurityConfiguration sc : securityProvider.getConfigurations()) {
+                if (!UserConfiguration.NAME.equals(sc.getName())) {
+                    builder.add(sc.getContext());
+                }
+            }
+            ctxs = builder.build();
+        }
+
+        @Override
+        public boolean definesProperty(@NotNull Tree parent, @NotNull PropertyState property) {
+            return ctxs.stream().anyMatch(context -> context.definesProperty(parent, property));
+        }
+
+        @Override
+        public boolean definesTree(@NotNull Tree tree) {
+            return ctxs.stream().anyMatch(context -> context.definesTree(tree));
+        }
+    }
+}
\ 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/IdentityProtectionType.java b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/IdentityProtectionType.java
new file mode 100644
index 0000000000..02dec8f8bc
--- /dev/null
+++ b/oak-auth-external/src/main/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/IdentityProtectionType.java
@@ -0,0 +1,46 @@
+/*
+ * 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.jetbrains.annotations.NotNull;
+
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN;
+
+enum IdentityProtectionType {
+    
+    NONE(VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE),
+    WARN(VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN),
+    PROTECTED(VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED);
+
+    final String label;
+    
+    IdentityProtectionType(@NotNull String label) {
+        this.label = label;
+    }
+    
+    static IdentityProtectionType fromLabel(@NotNull String label) {
+        switch (label) {
+            case VALUE_PROTECT_EXTERNAL_IDENTITIES_NONE: return NONE;
+            case VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN : return WARN;
+            case VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED : return PROTECTED;
+            default: throw new IllegalArgumentException("unsupported label "+label);
+        }
+    }
+    
+}
\ No newline at end of file
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 db58261a8b..80ce6320c9 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
@@ -90,14 +90,11 @@ public abstract class AbstractExternalAuthTest extends AbstractSecurityTest {
             destroyIDP();
             idp = null;
 
-            if (systemSession != null) {
-                systemSession.close();
-            }
-
             // discard any pending changes
-            root.refresh();
+            Root r = (systemRoot != null) ? systemRoot : root;
+            r.refresh();
 
-            UserManager userManager = getUserManager(root);
+            UserManager userManager = getUserManager(r);
             Iterator<String> iter = getAllAuthorizableIds(userManager);
             while (iter.hasNext()) {
                 String id = iter.next();
@@ -108,9 +105,15 @@ public abstract class AbstractExternalAuthTest extends AbstractSecurityTest {
                     }
                 }
             }
-            root.commit();
+            r.commit();
         } finally {
             root.refresh();
+            if (systemRoot != null) {
+                systemRoot.refresh();
+            }
+            if (systemSession != null) {
+                systemSession.close();
+            }
             super.after();
         }
     }
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 4821c8392d..c90c78a436 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
@@ -26,6 +26,7 @@ import org.apache.jackrabbit.oak.spi.commit.ValidatorProvider;
 import org.apache.jackrabbit.oak.spi.lifecycle.WorkspaceInitializer;
 import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
 import org.apache.jackrabbit.oak.spi.security.Context;
+import org.apache.jackrabbit.oak.spi.security.authentication.SystemSubject;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.AbstractExternalAuthTest;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider;
 import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncContext;
@@ -39,6 +40,8 @@ import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.Exter
 import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.monitor.ExternalIdentityMonitorImpl;
 import org.apache.jackrabbit.oak.spi.security.principal.PrincipalConfiguration;
 import org.apache.jackrabbit.oak.spi.security.principal.PrincipalProvider;
+import org.apache.jackrabbit.oak.spi.security.principal.SystemUserPrincipal;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
 import org.apache.jackrabbit.oak.spi.xml.ProtectedItemImporter;
 import org.apache.jackrabbit.oak.stats.Monitor;
 import org.apache.jackrabbit.oak.stats.StatisticsProvider;
@@ -51,11 +54,13 @@ import org.osgi.framework.BundleContext;
 import org.osgi.framework.ServiceRegistration;
 
 import javax.jcr.ValueFactory;
+import java.security.Principal;
 import java.util.Collections;
 import java.util.Dictionary;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -173,7 +178,48 @@ public class ExternalPrincipalConfigurationTest extends AbstractExternalAuthTest
         assertEquals(1, validatorProviders.size());
         assertTrue(validatorProviders.get(0) instanceof ExternalIdentityValidatorProvider);
     }
+    
+    @Test
+    public void testGetValidatorsIdentityProtectionWarn() {
+        externalPrincipalConfiguration.setParameters(ConfigurationParameters.of(ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDENTITIES, ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_WARN));
+
+        ContentSession cs = root.getContentSession();
+        String workspaceName = cs.getWorkspaceName();
+        
+        // access validators for regular session
+        List<? extends ValidatorProvider> validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, cs.getAuthInfo().getPrincipals(), new MoveTracker());
+        assertEquals(2, validatorProviders.size());
+        assertTrue(validatorProviders.get(1) instanceof ExternalUserValidatorProvider);
+        
+        // access validators for system-session
+        validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, SystemSubject.INSTANCE.getPrincipals(), new MoveTracker());
+        assertEquals(1, validatorProviders.size());
+        assertFalse(validatorProviders.get(0) instanceof ExternalUserValidatorProvider);
+    }
+
+    @Test
+    public void testGetValidatorsIdentityProtectionProtect() {
+        String principalName = UserConstants.DEFAULT_ADMIN_ID;
+        externalPrincipalConfiguration.setParameters(ConfigurationParameters.of(
+                ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDENTITIES, ExternalIdentityConstants.VALUE_PROTECT_EXTERNAL_IDENTITIES_PROTECTED,
+                ExternalIdentityConstants.PARAM_SYSTEM_PRINCIPAL_NAMES, new String[] {principalName}));
+
+        ContentSession cs = root.getContentSession();
+        String workspaceName = cs.getWorkspaceName();
+
+        // access validators for regular session (no system user principal)
+        List<? extends ValidatorProvider> validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, cs.getAuthInfo().getPrincipals(), new MoveTracker());
+        assertEquals(2, validatorProviders.size());
+        assertTrue(validatorProviders.get(1) instanceof ExternalUserValidatorProvider);
+
+        // access validators for system-user-principal
+        Set<Principal> systemPrincipals = Collections.singleton((SystemUserPrincipal) () -> principalName); 
+        validatorProviders = externalPrincipalConfiguration.getValidators(workspaceName, systemPrincipals, new MoveTracker());
+        assertEquals(1, validatorProviders.size());
+        assertFalse(validatorProviders.get(0) instanceof ExternalUserValidatorProvider);
+    }
 
+    
     @Test
     public void testGetProtectedItemImporters() {
         List<? extends ProtectedItemImporter> importers = externalPrincipalConfiguration.getProtectedItemImporters();
diff --git a/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorTest.java
new file mode 100644
index 0000000000..c3b48dd7f5
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/ExternalUserValidatorTest.java
@@ -0,0 +1,527 @@
+/*
+ * 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.Lists;
+import org.apache.jackrabbit.api.security.JackrabbitAccessControlList;
+import org.apache.jackrabbit.api.security.JackrabbitAccessControlManager;
+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.commons.jackrabbit.authorization.AccessControlUtils;
+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.commons.PathUtils;
+import org.apache.jackrabbit.oak.commons.UUIDUtils;
+import org.apache.jackrabbit.oak.plugins.tree.RootProvider;
+import org.apache.jackrabbit.oak.plugins.tree.TreeProvider;
+import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
+import org.apache.jackrabbit.oak.security.authentication.token.TokenLoginModule;
+import org.apache.jackrabbit.oak.security.authentication.user.LoginModuleImpl;
+import org.apache.jackrabbit.oak.spi.nodetype.NodeTypeConstants;
+import org.apache.jackrabbit.oak.spi.security.CompositeConfiguration;
+import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
+import org.apache.jackrabbit.oak.spi.security.Context;
+import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalLoginTestBase;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
+import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalLoginModule;
+import org.apache.jackrabbit.oak.spi.security.authentication.token.TokenConfiguration;
+import org.apache.jackrabbit.oak.spi.security.authentication.token.TokenConstants;
+import org.apache.jackrabbit.oak.spi.security.principal.EveryonePrincipal;
+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.SimpleCredentials;
+import javax.jcr.security.AccessControlEntry;
+import javax.jcr.security.Privilege;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.apache.jackrabbit.JcrConstants.JCR_MIXINTYPES;
+import static org.apache.jackrabbit.JcrConstants.JCR_UUID;
+import static org.apache.jackrabbit.JcrConstants.MIX_VERSIONABLE;
+import static org.apache.jackrabbit.JcrConstants.NT_UNSTRUCTURED;
+import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.ID_SECOND_USER;
+import static org.apache.jackrabbit.oak.spi.security.authentication.token.TokenConstants.TOKENS_NODE_NAME;
+import static org.apache.jackrabbit.oak.spi.security.privilege.PrivilegeConstants.JCR_READ;
+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.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@RunWith(Parameterized.class)
+public class ExternalUserValidatorTest extends ExternalLoginTestBase {
+
+    @Parameterized.Parameters(name = "name={2}")
+    public static Collection<Object[]> parameters() {
+        return Lists.newArrayList(
+                new Object[] { IdentityProtectionType.NONE, false, "None, Default Sync" },
+                new Object[] { IdentityProtectionType.WARN, true, "Warn, Dynamic Sync" },
+                new Object[] { IdentityProtectionType.WARN, false, "Warn, Default Sync" },
+                new Object[] { IdentityProtectionType.PROTECTED, true, "Protected, Dynamic Sync" },
+                new Object[] { IdentityProtectionType.PROTECTED, false, "Protected, Default Sync" });
+    }
+    
+    private final IdentityProtectionType type;
+    private final boolean isDynamic;
+    
+    private String localUserPath;
+    private String externalUserPath;
+    private UserManager userManager;
+    
+    private Root sysRoot;
+
+    public ExternalUserValidatorTest(@NotNull IdentityProtectionType type, boolean isDynamic, @NotNull String name) {
+        this.type = type;
+        this.isDynamic = isDynamic;
+    }
+
+    @Override
+    public void before() throws Exception {
+        super.before();
+
+        localUserPath = getTestUser().getPath();
+
+        // force an external user to be synchronized into the repo
+        login(new SimpleCredentials(USER_ID, new char[0])).close();
+        root.refresh();
+
+        Authorizable a = getUserManager(root).getAuthorizable(USER_ID);
+        assertNotNull(a);
+        externalUserPath = a.getPath();
+
+        userManager = getUserManager(root);
+        sysRoot = getSystemRoot();
+    }
+    
+    @Override
+    protected @NotNull Map<String, Object> getExternalPrincipalConfiguration() {
+        return Collections.singletonMap(ExternalIdentityConstants.PARAM_PROTECT_EXTERNAL_IDENTITIES, type.label);
+    }
+
+    @Override
+    @NotNull
+    protected DefaultSyncConfig createSyncConfig() {
+        DefaultSyncConfig config = super.createSyncConfig();
+        config.user().setDynamicMembership(isDynamic);
+        return config;
+    }
+    
+    private boolean exceptionExpected() {
+        return type == IdentityProtectionType.PROTECTED;
+    }
+    
+    private void assertCommit() {
+        try {
+            root.commit();
+            if (exceptionExpected()) {
+                fail("CommitFailedException expected");
+            }
+        } catch (CommitFailedException e) {
+            if (!exceptionExpected()) {
+                fail("No CommitFailedException expected.");
+            } else {
+                assertEquals(76, e.getCode());
+            }
+        } finally {
+            root.refresh();
+        }
+    }
+    
+    @Test
+    public void testModifyLocalUser() throws Exception {
+        Tree userTree = root.getTree(localUserPath);
+        userTree.setProperty("test", "value");
+        TreeUtil.addChild(userTree, "child", NodeTypeConstants.NT_OAK_UNSTRUCTURED);
+        root.commit();
+        assertTrue(userManager.getAuthorizableByPath(localUserPath).hasProperty("test"));
+        
+        userTree.setProperty("test", "modified");
+        root.commit();
+        assertTrue(userManager.getAuthorizableByPath(localUserPath).hasProperty("test"));
+
+        userTree.removeProperty("test");
+        userTree.getChild("child").remove();
+        root.commit();
+        assertFalse(userManager.getAuthorizableByPath(localUserPath).hasProperty("test"));
+    }
+    
+    @Test
+    public void testAddProperty() throws Exception {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        externalUserTree.setProperty("test", "value");
+        
+        assertCommit();
+        assertEquals(!exceptionExpected(), userManager.getAuthorizableByPath(externalUserPath).hasProperty("test"));
+    }
+
+    @Test
+    public void testModifyProperty() throws Exception {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        assertTrue(externalUserTree.hasProperty("name"));
+        externalUserTree.setProperty("name", "newValue");
+
+        assertCommit();
+        String expected = exceptionExpected() ? "Test User" : "newValue";
+        assertEquals(expected, userManager.getAuthorizableByPath(externalUserPath).getProperty("name")[0].getString());
+    }
+    
+    @Test
+    public void testRemoveProperty() throws Exception {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        assertTrue(externalUserTree.hasProperty("email"));
+        externalUserTree.removeProperty("email");
+
+        assertCommit();
+        assertEquals(exceptionExpected(), userManager.getAuthorizableByPath(externalUserPath).hasProperty("email"));
+    }
+
+    @Test
+    public void testAddPropertyInSubtree() throws Exception {
+        Tree profile = root.getTree(externalUserPath).getChild("profile");
+        profile.setProperty("test", "value");
+
+        assertCommit();
+        assertEquals(!exceptionExpected(), userManager.getAuthorizableByPath(externalUserPath).hasProperty("profile/test"));
+    }
+
+    @Test
+    public void testModifyPropertyInSubtree() throws Exception {
+        Tree profile = root.getTree(externalUserPath).getChild("profile");
+        assertTrue(profile.hasProperty("age"));
+        profile.setProperty("age", 90);
+
+        assertCommit();
+        long expected = exceptionExpected() ? 72 : 90;
+        assertEquals(expected, userManager.getAuthorizableByPath(externalUserPath).getProperty("profile/age")[0].getLong());
+    }
+
+    @Test
+    public void testRemovePropertyInSubtree() throws Exception {
+        Tree profile = root.getTree(externalUserPath).getChild("profile");
+        assertTrue(profile.hasProperty("age"));
+        profile.removeProperty("age");
+
+        assertCommit();
+        assertEquals(exceptionExpected(), userManager.getAuthorizableByPath(externalUserPath).hasProperty("profile/age"));
+    }
+    
+    @Test
+    public void testAddChildNode() throws Exception {
+        Tree profile = root.getTree(externalUserPath).getChild("profile");
+        TreeUtil.addChild(profile, "child", NodeTypeConstants.NT_OAK_UNSTRUCTURED);
+
+        assertCommit();
+        assertEquals(!exceptionExpected(), profile.hasChild("child"));
+    }
+
+    @Test
+    public void testRemoveChildNode() throws Exception {
+        Tree profile = root.getTree(externalUserPath).getChild("profile");
+        assertTrue(profile.exists());
+        profile.remove();
+
+        assertCommit();
+        assertEquals(exceptionExpected(), userManager.getAuthorizableByPath(externalUserPath).hasProperty("profile/age"));
+    }
+    
+    @Test
+    public void testReorderChildNodes() throws Exception {
+        Root sr = getSystemRoot();
+        sr.refresh();
+        Tree userTree = sr.getTree(externalUserPath);
+        Tree profile = userTree.getChild("profile");
+        Tree profile2 = TreeUtil.addChild(userTree, "profile2", NT_UNSTRUCTURED, sr.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        sr.commit();
+        
+        root.refresh();
+        Tree t = root.getTree(externalUserPath).getChild("profile2");
+        t.orderBefore("profile");
+        
+        assertCommit();
+        String expectedName = exceptionExpected() ? "profile" : "profile2";
+        assertEquals(expectedName, root.getTree(externalUserPath).getChildren().iterator().next().getName());
+    }
+    
+    @Test
+    public void testModifyGroupMembership() throws Exception {
+        Group g = getUserManager(root).getAuthorizable("a", Group.class);
+        if (g != null) {
+            g.addMember(getTestUser());
+            
+            assertCommit();
+            assertEquals(!exceptionExpected(), g.isMember(getTestUser()));
+        } // else: dynamic membership
+    }
+    
+    @Test
+    public void testAddEditAcContent() throws Exception {
+        JackrabbitAccessControlManager acMgr = getAccessControlManager(root);
+        JackrabbitAccessControlList acl = AccessControlUtils.getAccessControlList(acMgr, externalUserPath);
+        Privilege[] privs = privilegesFromNames(JCR_READ);
+        if (acl != null) {
+            acl.addAccessControlEntry(EveryonePrincipal.getInstance(), privs);
+            acMgr.setPolicy(acl.getPath(), acl);
+            root.commit();
+            assertTrue(acMgr.hasPrivileges(externalUserPath, Collections.singleton(EveryonePrincipal.getInstance()), privs));
+
+            for (AccessControlEntry entry : acl.getAccessControlEntries()) {
+                if (EveryonePrincipal.getInstance().equals(entry.getPrincipal())) {
+                    acl.removeAccessControlEntry(entry);
+                    acMgr.setPolicy(acl.getPath(), acl);
+                    root.commit();
+                }
+            }
+            assertFalse(acMgr.hasPrivileges(externalUserPath, Collections.singleton(EveryonePrincipal.getInstance()), privs));
+        }
+    }
+    
+    @Test
+    public void testCustomSecurityProperty() throws Exception {
+        Context customContext = new Context.Default() {
+            @Override
+            public boolean definesProperty(@NotNull Tree parent, @NotNull PropertyState property) {
+                return "testtoken".equals(property.getName());
+            }
+        };
+        TokenConfiguration customTc = mock(TokenConfiguration.class);
+        when(customTc.getContext()).thenReturn(customContext);
+        when(customTc.getParameters()).thenReturn(ConfigurationParameters.EMPTY);
+        
+        TokenConfiguration tc = getSecurityProvider().getConfiguration(TokenConfiguration.class);
+        assertTrue(tc instanceof CompositeConfiguration);
+        CompositeConfiguration<TokenConfiguration> cc = (CompositeConfiguration<TokenConfiguration>) tc;
+
+        try {
+            cc.addConfiguration(customTc);
+            
+            Tree t = root.getTree(externalUserPath);
+            t.setProperty("testtoken", "value");
+            root.commit();
+
+            t.setProperty("testtoken", "modified");
+            root.commit();
+            
+            t.removeProperty("testtoken");
+            root.commit();
+        } finally {
+            cc.removeConfiguration(customTc);
+        }
+    }
+
+    @Test
+    public void testRemoveTokens() throws Exception {
+        Configuration configuration = getConfiguration();
+        try {
+            Configuration.setConfiguration(getTokenConfiguration());
+
+            // force creation of login token
+            SimpleCredentials sc = new SimpleCredentials(USER_ID, "".toCharArray());
+            sc.setAttribute(TokenConstants.TOKEN_ATTRIBUTE, "");
+            getContentRepository().login(sc, null).close();
+
+            root.refresh();
+            Tree tokens = root.getTree(externalUserPath).getChild(TOKENS_NODE_NAME);
+            assertTrue(tokens.exists());
+            
+            // remove individual token nodes
+            for (Tree t : tokens.getChildren()) {
+                t.remove();
+                root.commit();
+            }
+
+            // remove the .tokens parent
+            tokens.remove();
+            root.commit();
+
+            assertFalse(root.getTree(externalUserPath).hasChild(TOKENS_NODE_NAME));
+        } finally {
+            Configuration.setConfiguration(configuration);
+        }
+    }
+    
+    private Configuration getTokenConfiguration() {
+        return new Configuration() {
+            @Override
+            public AppConfigurationEntry[] getAppConfigurationEntry(String s) {
+                return new AppConfigurationEntry[]{
+                        new AppConfigurationEntry(
+                                TokenLoginModule.class.getName(),
+                                AppConfigurationEntry.LoginModuleControlFlag.SUFFICIENT,
+                                Collections.emptyMap()),
+                        new AppConfigurationEntry(
+                                LoginModuleImpl.class.getName(),
+                                AppConfigurationEntry.LoginModuleControlFlag.SUFFICIENT,
+                                Collections.emptyMap()),
+                        new AppConfigurationEntry(
+                                ExternalLoginModule.class.getName(),
+                                AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
+                                options)
+                };
+            }
+        };
+    }
+    
+    @Test
+    public void testCreateExternalUser() throws Exception {
+        ExternalUser extU = idp.getUser(ID_SECOND_USER);
+        
+        Tree folder = root.getTree(externalUserPath).getParent();
+        Tree t = TreeUtil.addChild(folder, "test", UserConstants.NT_REP_USER, root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        t.setProperty(UserConstants.REP_AUTHORIZABLE_ID, extU.getId());
+        t.setProperty(UserConstants.REP_PRINCIPAL_NAME, extU.getPrincipalName());
+        t.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, extU.getExternalId().getString());
+        t.setProperty(JCR_UUID, UUIDUtils.generateUUID(extU.getId().toLowerCase()));
+        
+        assertCommit();
+        Authorizable u = getUserManager(root).getAuthorizable(extU.getId());
+        if (exceptionExpected()) {
+            assertNull(u);
+        } else {
+            assertNotNull(u);
+        }
+    }
+
+    @Test
+    public void testCreateExternalUserWithSubtree() throws Exception {
+        ExternalUser extU = idp.getUser(ID_SECOND_USER);
+
+        Tree folder = root.getTree(externalUserPath).getParent();
+        Tree t = TreeUtil.addChild(folder, "test", UserConstants.NT_REP_USER, root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        t.setProperty(UserConstants.REP_AUTHORIZABLE_ID, extU.getId());
+        t.setProperty(UserConstants.REP_PRINCIPAL_NAME, extU.getPrincipalName());
+        t.setProperty(ExternalIdentityConstants.REP_EXTERNAL_ID, extU.getExternalId().getString());
+        t.setProperty(JCR_UUID, UUIDUtils.generateUUID(extU.getId().toLowerCase()));
+        Tree profile = TreeUtil.addChild(t, "profile", NT_UNSTRUCTURED, root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        profile.setProperty("name", "test-user");
+        
+        assertCommit();
+        Authorizable u = getUserManager(root).getAuthorizable(extU.getId());
+        if (exceptionExpected()) {
+            assertNull(u);
+        } else {
+            assertNotNull(u);
+        }
+    }
+    
+    @Test
+    public void testAddMixin() throws Exception {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        TreeUtil.addMixin(externalUserTree, MIX_VERSIONABLE, root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+
+        assertCommit();
+        assertEquals(!exceptionExpected(), root.getTree(externalUserPath).hasProperty(JCR_MIXINTYPES));
+    }
+    
+    @Test
+    public void testAddMixinWithProperty() throws Exception {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        TreeUtil.addMixin(externalUserTree, "mix:language", root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        externalUserTree.setProperty("jcr:language", "farsi");
+
+        assertCommit();
+        assertEquals(!exceptionExpected(), root.getTree(externalUserPath).hasProperty(JCR_MIXINTYPES));
+    }
+    
+    @Test
+    public void testRemoveExternalUser() {
+        Tree externalUserTree = root.getTree(externalUserPath);
+        externalUserTree.remove();
+
+        assertCommit();
+        assertEquals(exceptionExpected(), root.getTree(externalUserPath).exists());
+    }
+    
+    @Test
+    public void testDisable() throws Exception {
+        Authorizable extuser = getUserManager(root).getAuthorizableByPath(externalUserPath);
+        assertNotNull(extuser);
+        assertFalse(extuser.isGroup());
+        
+        ((User) extuser).disable("disable");
+        assertCommit();
+        assertEquals(!exceptionExpected(), root.getTree(externalUserPath).hasProperty(UserConstants.REP_DISABLED));
+    }
+
+    @Test
+    public void testChangePassword() throws Exception {
+        Authorizable extuser = getUserManager(root).getAuthorizableByPath(externalUserPath);
+        assertNotNull(extuser);
+        assertFalse(extuser.isGroup());
+        
+        ((User) extuser).changePassword("something");
+        assertCommit();
+        assertEquals(!exceptionExpected(), root.getTree(externalUserPath).hasProperty(UserConstants.REP_PASSWORD));
+    }
+    
+    @Test
+    public void testAddModifyRemoveFolder() throws Exception {
+        Tree t = root.getTree(PathUtils.getParentPath(externalUserPath));
+        Tree folder = TreeUtil.addChild(t, "folder", UserConstants.NT_REP_AUTHORIZABLE_FOLDER);
+        String path = folder.getPath();
+        root.commit();
+        
+        sysRoot.refresh();
+        assertTrue(sysRoot.getTree(path).exists());
+        
+        TreeUtil.addMixin(folder, NodeTypeConstants.MIX_VERSIONABLE, root.getTree(NodeTypeConstants.NODE_TYPES_PATH), "id");
+        root.commit();
+
+        sysRoot.refresh();
+        assertTrue(sysRoot.getTree(path).hasProperty(JCR_MIXINTYPES));
+        
+        folder.remove();
+        root.commit();
+
+        sysRoot.refresh();
+        assertFalse(sysRoot.getTree(path).exists());
+    }
+    
+    @Test
+    public void testValidatorWithTypeNone() {
+        if (type == IdentityProtectionType.NONE) {
+            RootProvider rp = mock(RootProvider.class);
+            TreeProvider tp = mock(TreeProvider.class);
+            SecurityProvider sp = mock(SecurityProvider.class);
+            try {
+                ExternalUserValidatorProvider vp = new ExternalUserValidatorProvider(rp, tp, sp, type);
+                fail("IllegalArgumentException expected");
+            } catch (IllegalArgumentException e) {
+                // success
+            } finally {
+                verifyNoInteractions(rp, tp, sp);
+            }
+        }
+    }
+}
\ 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/IdentityProtectionTypeTest.java b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/IdentityProtectionTypeTest.java
new file mode 100644
index 0000000000..4895f8fd7e
--- /dev/null
+++ b/oak-auth-external/src/test/java/org/apache/jackrabbit/oak/spi/security/authentication/external/impl/principal/IdentityProtectionTypeTest.java
@@ -0,0 +1,27 @@
+/*
+ * 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.junit.Test;
+
+public class IdentityProtectionTypeTest {
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testInvalidLabel() {
+        IdentityProtectionType.fromLabel("invalid");
+    }
+}
\ No newline at end of file
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 d039c2a721..4168391309 100644
--- a/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md
+++ b/oak-doc/src/site/markdown/security/authentication/external/defaultusersync.md
@@ -88,7 +88,7 @@ represented by [ExternalIdentityRef].
 ### Dynamic Group Membership
 
 As of Oak 1.5.3 the default sync handler comes with an addition configuration 
-option that allows to enable dynamic group membership resolution for external users. 
+option 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 also [OAK-4101]).
  
@@ -122,7 +122,7 @@ This prevents users to unintentionally or maliciously manipulating the informati
 linking to the external identity provider in particular their external identity
 and the set of external group principals associated with their account.
 
-Additionally the validator asserts the consistency of the properties defined
+Additionally, the validator asserts the consistency of the properties defined
 with external user/group accounts.
 
 | Code              | Message                                                  |
@@ -142,6 +142,30 @@ validator performs the following checks:
 | 0074              | Attempt to add, modify or remove the system maintained property 'rep:externalId'. |
 | 0075              | Property 'rep:externalId' may only have a single value of type STRING. |
  
+##### Protecting synchronized external users/groups 
+
+If protection of synchronized external users/groups is enabled (since Oak 1.44.0) an additional validator is present
+which either warns upon or prevents creation, modification and removal of external identities that have been synchronized 
+into the repository with the following exception:
+
+- System sessions and configured service user principals (see `systemPrincipalNames` option) are exempted from the protection
+- Other security related content present with a given synced user/group can still be written given the editing session 
+  has the required permissions (e.g. adding access control content or removing token nodes).
+
+The protection aims to prevent inconsistencies between the IDP (source of truth), and the synchronized user/group 
+accounts in particular group membership and properties.
+
+The following constraint violations will be reported:
+
+| Code | Message                                                                    |
+|------|----------------------------------------------------------------------------|
+| 0076 | Attempt to add property '%s' to protected external identity node '%s'      |
+| 0076 | Attempt to modify property '%s' at protected external identity node '%s'   |
+| 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 node '%s' from protected external identity               |
 
 <a name="configuration"></a>
 ### Configuration
@@ -193,10 +217,11 @@ See section [Introduction to Oak Security](../../introduction.html) for further
 
 The `ExternalPrincipalConfiguration` defines the following configuration options:
      
-| Name                         | Property                      | Description                              |
-|------------------------------|-------------------------------|------------------------------------------|
-| External Identity Protection | `protectExternalId`           | Enables protection of the system maintained `rep:externalId` properties |
-| System Principal Names       | `systemPrincipalNames`        | Names of additional 'SystemUserPrincipal' instances that are excluded from the protection check (since Oak 1.44.0) |
+| Name                         | Property                      | Description                              | Values      |
+|------------------------------|-------------------------------|------------------------------------------|-------------|
+| External Identity Protection | `protectExternalId`           | Enables protection of the system maintained `rep:externalId` properties | `true`,`false`|
+| External User and Group Protection | `protectExternalIdentities` | Enables protection of synchronized external users/groups accounts (since Oak 1.44.0). | `None`: no protection (default),<br>`Warn`: log warnings,<br>`Protected`: protection enabled |
+| System Principal Names       | `systemPrincipalNames`        | Names of additional 'SystemUserPrincipal' instances that are excluded from the protection check (since Oak 1.44.0) | `true`,`false` |
 | | | |
 
 <!-- references -->