From dd94891ad6946da5646fffdf11631144725b5636 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Thu, 24 Sep 2026 08:03:16 +0000
Subject: [PATCH] [#1051] Replicate a change made under the Relax Rules control like any other (#1053)

---
 opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java             |  245 +++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java                                   |    9 
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java |   62 ++-
 opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml                                         |   13 
 opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java                                    |  143 +++++---
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java |   31 +
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java    |   66 +++
 opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc                                        |    7 
 opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java                         |  339 +++++++++++++++++++++
 9 files changed, 822 insertions(+), 93 deletions(-)

diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
index af531d8..be5613e 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
@@ -12,7 +12,7 @@
   information: "Portions copyright [year] [name of copyright owner]".
  
   Copyright 2017 ForgeRock AS.
-  Portions Copyright 2024 3A Systems LLC.
+  Portions Copyright 2024-2026 3A Systems LLC.
 ////
 
 :figure-caption!:
@@ -264,7 +264,10 @@
 Object Identifier: 1.3.6.1.4.1.4203.666.5.12
 
 +
-Internet-Draft: link:https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03[ddraft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control, window=\_top]
+Internet-Draft: link:https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03[draft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control, window=\_top]
+
++
+Lets a client with the `bypass-acl` privilege add or modify attributes marked `NO-USER-MODIFICATION` or `OBSOLETE`, and skips the schema check of the resulting entry. The values the client supplies for `creatorsName`, `createTimestamp`, `modifiersName`, `modifyTimestamp` and `pwdChangedTime` are kept, and a pre-encoded password is accepted. The change is an ordinary one otherwise: it is logged, and it is replicated like any other.
 
 --
 
diff --git a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
index 5e88d56..10679fa 100644
--- a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
+++ b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
@@ -21,7 +21,7 @@
   ! CCPL HEADER END
   !
   !      Copyright 2011 ForgeRock AS
-  !      Portions copyright 2024 3A Systems,LLC.
+  !      Portions copyright 2024-2026 3A Systems,LLC.
   !    
 -->
 <appendix xml:id='appendix-controls'
@@ -457,7 +457,16 @@
     <para>Object Identifier: 1.3.6.1.4.1.4203.666.5.12</para>
     <para>Internet-Draft: <link
             xlink:href='https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03'
-    >ddraft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control</link></para>
+    >draft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control</link></para>
+    <para>Lets a client with the <literal>bypass-acl</literal> privilege add
+    or modify attributes marked <literal>NO-USER-MODIFICATION</literal> or
+    <literal>OBSOLETE</literal>, and skips the schema check of the resulting
+    entry. The values the client supplies for <literal>creatorsName</literal>,
+    <literal>createTimestamp</literal>, <literal>modifiersName</literal>,
+    <literal>modifyTimestamp</literal> and <literal>pwdChangedTime</literal>
+    are kept, and a pre-encoded password is accepted. The change is an
+    ordinary one otherwise: it is logged, and it is replicated like any
+    other.</para>
    </listitem>
   </varlistentry>
  </variablelist>
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java b/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
index c5ea768..d9abe5a 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2007-2010 Sun Microsystems, Inc.
  * Portions Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.core;
 
@@ -288,11 +289,13 @@
           AttributeType attrType = attrDesc.getAttributeType();
 
           // If the attribute type is marked "NO-USER-MODIFICATION" then fail
-          // unless this is an internal operation or is related to
-          // synchronization in some way.
+          // unless this is an internal operation, is related to
+          // synchronization in some way, or the client asked for the rules
+          // to be relaxed.
           if (attrType.isNoUserModification()
               && !isInternalOperation()
-              && !isSynchronizationOperation())
+              && !isSynchronizationOperation()
+              && !isRelaxRulesRequested(this))
           {
             throw new LDAPException(LDAPResultCode.UNWILLING_TO_PERFORM,
                 ERR_ADD_ATTR_IS_NO_USER_MOD.get(entryDN, attrDesc));
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java b/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
index a7970cd..b42d677 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.plugins;
 
@@ -32,6 +33,7 @@
 import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.forgerock.opendj.server.config.meta.PluginCfgDefn;
 import org.forgerock.opendj.server.config.server.LastModPluginCfg;
 import org.forgerock.opendj.server.config.server.PluginCfg;
@@ -47,6 +49,7 @@
 import org.opends.server.types.operation.PreOperationAddOperation;
 import org.opends.server.types.operation.PreOperationModifyDNOperation;
 import org.opends.server.types.operation.PreOperationModifyOperation;
+import org.opends.server.workflowelement.localbackend.LocalBackendWorkflowElement;
 
 /**
  * This class implements a Directory Server plugin that will add the
@@ -117,25 +120,31 @@
                doPreOperation(PreOperationAddOperation addOperation)
   {
     // Create the attribute list for the creatorsName attribute, if appropriate.
-    AttributeBuilder builder = new AttributeBuilder(getCreatorsNameAttributeType());
-    DN creatorDN = addOperation.getAuthorizationDN();
-    if (creatorDN == null)
+    if (!isSuppliedUnderRelaxRules(addOperation, getCreatorsNameAttributeType()))
     {
-      // This must mean that the operation was performed anonymously.
-      // Even so, we still need to update the creatorsName attribute.
-      builder.add(ByteString.empty());
+      AttributeBuilder builder = new AttributeBuilder(getCreatorsNameAttributeType());
+      DN creatorDN = addOperation.getAuthorizationDN();
+      if (creatorDN == null)
+      {
+        // This must mean that the operation was performed anonymously.
+        // Even so, we still need to update the creatorsName attribute.
+        builder.add(ByteString.empty());
+      }
+      else
+      {
+        builder.add(creatorDN.toString());
+      }
+      addOperation.setAttribute(getCreatorsNameAttributeType(), builder.toAttributeList());
     }
-    else
-    {
-      builder.add(creatorDN.toString());
-    }
-    addOperation.setAttribute(getCreatorsNameAttributeType(), builder.toAttributeList());
 
 
     //  Create the attribute list for the createTimestamp attribute.
-    List<Attribute> timeList = Attributes.createAsList(
-        getCreateTimestampAttributeType(), OP_ATTR_CREATE_TIMESTAMP, getGMTTime());
-    addOperation.setAttribute(getCreateTimestampAttributeType(), timeList);
+    if (!isSuppliedUnderRelaxRules(addOperation, getCreateTimestampAttributeType()))
+    {
+      List<Attribute> timeList = Attributes.createAsList(
+          getCreateTimestampAttributeType(), OP_ATTR_CREATE_TIMESTAMP, getGMTTime());
+      addOperation.setAttribute(getCreateTimestampAttributeType(), timeList);
+    }
 
     // We shouldn't ever need to return a non-success result.
     return PluginResult.PreOperation.continueOperationProcessing();
@@ -148,49 +157,55 @@
        doPreOperation(PreOperationModifyOperation modifyOperation)
   {
     // Create the modifiersName attribute.
-    AttributeBuilder builder = new AttributeBuilder(getModifiersNameAttributeType());
-    DN modifierDN = modifyOperation.getAuthorizationDN();
-    if (modifierDN == null)
+    if (!isSuppliedUnderRelaxRules(modifyOperation, getModifiersNameAttributeType()))
     {
-      // This must mean that the operation was performed anonymously.
-      // Even so, we still need to update the modifiersName attribute.
-      builder.add(ByteString.empty());
-    }
-    else
-    {
-      builder.add(modifierDN.toString());
-    }
-    Attribute nameAttr = builder.toAttribute();
-    try
-    {
-      modifyOperation.addModification(new Modification(ModificationType.REPLACE,
-                                                       nameAttr, true));
-    }
-    catch (DirectoryException de)
-    {
-      logger.traceException(de);
+      AttributeBuilder builder = new AttributeBuilder(getModifiersNameAttributeType());
+      DN modifierDN = modifyOperation.getAuthorizationDN();
+      if (modifierDN == null)
+      {
+        // This must mean that the operation was performed anonymously.
+        // Even so, we still need to update the modifiersName attribute.
+        builder.add(ByteString.empty());
+      }
+      else
+      {
+        builder.add(modifierDN.toString());
+      }
+      Attribute nameAttr = builder.toAttribute();
+      try
+      {
+        modifyOperation.addModification(new Modification(ModificationType.REPLACE,
+                                                         nameAttr, true));
+      }
+      catch (DirectoryException de)
+      {
+        logger.traceException(de);
 
-      // This should never happen.
-      return PluginResult.PreOperation.stopProcessing(
-          DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+        // This should never happen.
+        return PluginResult.PreOperation.stopProcessing(
+            DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+      }
     }
 
 
     //  Create the modifyTimestamp attribute.
-    Attribute timeAttr = Attributes.create(getModifyTimestampAttributeType(),
-        OP_ATTR_MODIFY_TIMESTAMP, getGMTTime());
-    try
+    if (!isSuppliedUnderRelaxRules(modifyOperation, getModifyTimestampAttributeType()))
     {
-      modifyOperation.addModification(new Modification(ModificationType.REPLACE,
-                                                       timeAttr, true));
-    }
-    catch (DirectoryException de)
-    {
-      logger.traceException(de);
+      Attribute timeAttr = Attributes.create(getModifyTimestampAttributeType(),
+          OP_ATTR_MODIFY_TIMESTAMP, getGMTTime());
+      try
+      {
+        modifyOperation.addModification(new Modification(ModificationType.REPLACE,
+                                                         timeAttr, true));
+      }
+      catch (DirectoryException de)
+      {
+        logger.traceException(de);
 
-      // This should never happen.
-      return PluginResult.PreOperation.stopProcessing(
-          DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+        // This should never happen.
+        return PluginResult.PreOperation.stopProcessing(
+            DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+      }
     }
 
 
@@ -198,6 +213,36 @@
     return PluginResult.PreOperation.continueOperationProcessing();
   }
 
+  /**
+   * Indicates whether a client relaxing the rules supplies the provided attribute in the entry it
+   * adds: such a value is kept, the way it is kept when the change is replayed on another replica.
+   */
+  private static boolean isSuppliedUnderRelaxRules(PreOperationAddOperation addOperation, AttributeType type)
+  {
+    return LocalBackendWorkflowElement.isRelaxRulesRequested(addOperation)
+        && addOperation.getOperationalAttributes().containsKey(type);
+  }
+
+  /**
+   * Indicates whether a client relaxing the rules modifies the provided attribute: such a value is
+   * kept, the way it is kept when the change is replayed on another replica.
+   */
+  private static boolean isSuppliedUnderRelaxRules(PreOperationModifyOperation modifyOperation, AttributeType type)
+  {
+    if (!LocalBackendWorkflowElement.isRelaxRulesRequested(modifyOperation))
+    {
+      return false;
+    }
+    for (Modification m : modifyOperation.getModifications())
+    {
+      if (m.getAttribute().getAttributeDescription().getAttributeType().equals(type))
+      {
+        return true;
+      }
+    }
+    return false;
+  }
+
 
 
   @Override
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
index 1e405dc..9ec152b 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
@@ -110,8 +110,19 @@
   private Map<AttributeType, List<Attribute>> operationalAttributes;
   /** The set of user attributes for the entry to add. */
   private Map<AttributeType, List<Attribute>> userAttributes;
-  /** Indicates whether the request included the RelaxRules request control. */
-  private boolean RelaxRulesControlRequested=false;
+  /**
+   * Indicates whether the rules are relaxed on this change: the request included the Relax Rules
+   * request control, and the client has the {@code bypass-acl} privilege.
+   * <p>
+   * The checks of the attributes and of the schema run before the request controls are
+   * processed, so this is decided up front, from the request as it came and from the
+   * authentication identity of the client. Once the controls are processed,
+   * {@link #relaxedRulesStillHold()} verifies it against the identity the request runs as.
+   *
+   * @see LocalBackendWorkflowElement#isRelaxRulesRequested(org.opends.server.types.operation.PluginOperation)
+   */
+  private final boolean relaxRules;
+
   /**
    * Creates a new operation that may be used to add a new entry in a
    * local backend of the Directory Server.
@@ -123,11 +134,28 @@
     super(add);
 
     LocalBackendWorkflowElement.attachLocalOperation (add, this);
+    relaxRules = LocalBackendWorkflowElement.isRelaxRulesRequested(this)
+        && getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, this);
   }
 
-  @Override
-  public boolean isSynchronizationOperation() {
-    return super.isSynchronizationOperation()||RelaxRulesControlRequested;
+  /**
+   * Indicates whether the request may go on as far as the Relax Rules control is concerned, once
+   * the request controls the client may not use are removed and the proxied authorization, if
+   * any, is applied.
+   * <p>
+   * A control still there needs the {@code bypass-acl} privilege of the identity the request
+   * runs as, which may not be the one {@link #relaxRules} was decided for. A control which is
+   * gone after the rules were relaxed on the checks already run cannot leave them relaxed. A
+   * control which is gone and relaxed nothing leaves an ordinary request.
+   */
+  private boolean relaxedRulesStillHold()
+  {
+    final boolean controlKept = LocalBackendWorkflowElement.isRelaxRulesRequested(this);
+    if (controlKept)
+    {
+      return getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, this);
+    }
+    return !relaxRules;
   }
 
 
@@ -382,10 +410,11 @@
       }
 
       // If the server is configured to check schema and the
-      // operation is not a synchronization operation,
-      // check to see if the entry is valid according to the server schema,
-      // and also whether its attributes are valid according to their syntax.
-      if (DirectoryServer.getCoreConfigManager().isCheckSchema() && !isSynchronizationOperation())
+      // operation is not a synchronization operation nor one whose rules the
+      // client asked to relax, check to see if the entry is valid according to
+      // the server schema, and also whether its attributes are valid according
+      // to their syntax.
+      if (DirectoryServer.getCoreConfigManager().isCheckSchema() && !isSynchronizationOperation() && !relaxRules)
       {
         checkSchema(parentEntry);
       }
@@ -413,7 +442,7 @@
       // sensitive information to the client.
       try
       {
-        if (!getAccessControlHandler().isAllowed(this) || (RelaxRulesControlRequested && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
+        if (!getAccessControlHandler().isAllowed(this) || !relaxedRulesStillHold())
         {
           setResultCodeAndMessageNoInfoDisclosure(entryDN,
               ResultCode.INSUFFICIENT_ACCESS_RIGHTS,
@@ -530,7 +559,8 @@
     {
       if (at.isNoUserModification()
           && !isInternalOperation()
-          && !isSynchronizationOperation())
+          && !isSynchronizationOperation()
+          && !relaxRules)
       {
         setResultCodeAndMessageNoInfoDisclosure(entryDN,
             ResultCode.CONSTRAINT_VIOLATION,
@@ -698,7 +728,8 @@
           : UserPasswordSyntax.isEncoded(value);
       if (isPreEncoded)
       {
-        if (isInternalOperation() || passwordPolicy.isAllowPreEncodedPasswords())
+        // A client relaxing the rules may bring a password encoded elsewhere, e.g. to migrate it.
+        if (isInternalOperation() || passwordPolicy.isAllowPreEncodedPasswords() || relaxRules)
         {
           builder.add(value);
           continue;
@@ -761,10 +792,15 @@
     entry.replaceAttribute(builder.toAttribute());
 
 
-    // Set the password changed time attribute.
+    // Set the password changed time attribute, unless a client relaxing the
+    // rules supplies the time the password was changed at.
     Attribute changedTime = Attributes.create(
         OP_ATTR_PWPOLICY_CHANGED_TIME, TimeThread.getGeneralizedTime());
-    entry.putAttribute(changedTime.getAttributeDescription().getAttributeType(), newArrayList(changedTime));
+    AttributeType changedTimeType = changedTime.getAttributeDescription().getAttributeType();
+    if (!relaxRules || !entry.hasAttribute(changedTimeType))
+    {
+      entry.putAttribute(changedTimeType, newArrayList(changedTime));
+    }
 
 
     // If we should force change on add, then set the appropriate flag.
@@ -976,7 +1012,7 @@
       }
       else if (RelaxRulesControl.OID.equals(oid))
       {
-        RelaxRulesControlRequested = true;
+        // Already taken into account: see relaxRules.
       }
       else if (TransactionSpecificationRequestControl.OID.equals(oid))
       {
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
index 8b8a931..178946a 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
@@ -126,8 +126,17 @@
   private boolean permissiveModify;
   /** Indicates whether the request included the password policy request control. */
   private boolean pwPolicyControlRequested;
-  /** Indicates whether the request included the RelaxRules request control. */
-  private boolean RelaxRulesControlRequested=false;
+  /**
+   * Indicates whether the request included the Relax Rules request control, and the client
+   * may use it.
+   * <p>
+   * It is set once the request controls the client may not use are removed, before any check
+   * it relaxes runs; {@link #operationIsAllowed()} then requires the {@code bypass-acl}
+   * privilege of the identity the request runs as.
+   *
+   * @see LocalBackendWorkflowElement#isRelaxRulesRequested(org.opends.server.types.operation.PluginOperation)
+   */
+  private boolean relaxRules;
   /** The post-read request control, if present. */
   private LDAPPostReadRequestControl postReadRequest;
   /** The pre-read request control, if present. */
@@ -168,11 +177,6 @@
     LocalBackendWorkflowElement.attachLocalOperation (modify, this);
   }
 
-  @Override
-  public boolean isSynchronizationOperation() {
-    return super.isSynchronizationOperation()||RelaxRulesControlRequested;
-  }
-
   /**
    * Returns whether authentication for this user is managed locally
    * or via Pass-Through Authentication.
@@ -540,7 +544,7 @@
   {
     try
     {
-      if (!getAccessControlHandler().isAllowed(this) || (RelaxRulesControlRequested && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
+      if (!getAccessControlHandler().isAllowed(this) || (relaxRules && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
       {
         setResultCodeAndMessageNoInfoDisclosure(modifiedEntry,
             ResultCode.INSUFFICIENT_ACCESS_RIGHTS,
@@ -699,7 +703,7 @@
       }
       else if (RelaxRulesControl.OID.equals(oid))
       {
-        RelaxRulesControlRequested = true;
+        relaxRules = true;
       }
       else if (TransactionSpecificationRequestControl.OID.equals(oid))
       {
@@ -724,9 +728,10 @@
 
 
       // If the attribute type is marked "NO-USER-MODIFICATION" then fail unless
-      // this is an internal operation or is related to synchronization in some way.
-      final boolean isInternalOrSynchro = isInternalOrSynchro(m);
-      if (t.isNoUserModification() && !isInternalOrSynchro)
+      // this is an internal operation, is related to synchronization in some way,
+      // or the client asked for the rules to be relaxed.
+      final boolean constraintsRelaxed = isInternalOrSynchro(m) || relaxRules;
+      if (t.isNoUserModification() && !constraintsRelaxed)
       {
         throw newDirectoryException(currentEntry,
             ResultCode.CONSTRAINT_VIOLATION,
@@ -734,12 +739,12 @@
       }
 
       // If the attribute type is marked "OBSOLETE" and the modification is
-      // setting new values, then fail unless this is an internal operation or
-      // is related to synchronization in some way.
+      // setting new values, then fail unless this is an internal operation,
+      // is related to synchronization in some way, or the rules are relaxed.
       if (t.isObsolete()
           && !a.isEmpty()
           && m.getModificationType() != ModificationType.DELETE
-          && !isInternalOrSynchro)
+          && !constraintsRelaxed)
       {
         throw newDirectoryException(currentEntry,
             ResultCode.CONSTRAINT_VIOLATION,
@@ -770,6 +775,19 @@
     return isInternalOperation() || m.isInternal() || isSynchronizationOperation();
   }
 
+  /** Indicates whether a modification of the request is on the provided attribute. */
+  private boolean modifiesAttribute(String attributeName)
+  {
+    for (Modification m : getModifications())
+    {
+      if (m.getAttribute().getAttributeDescription().getAttributeType().hasName(attributeName))
+      {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private boolean isPassword(AttributeType t)
   {
     return pwPolicyState != null
@@ -993,8 +1011,10 @@
     {
       if (pwPolicyState.passwordIsPreEncoded(v))
       {
+        // A client relaxing the rules may bring a password encoded elsewhere, e.g. to migrate it.
         if (!isInternalOperation()
-            && !authPolicy.isAllowPreEncodedPasswords())
+            && !authPolicy.isAllowPreEncodedPasswords()
+            && !relaxRules)
         {
           pwpErrorType = PasswordPolicyErrorType.INSUFFICIENT_PASSWORD_QUALITY;
           throw new DirectoryException(ResultCode.CONSTRAINT_VIOLATION,
@@ -1201,7 +1221,7 @@
 
   private boolean mustCheckSchema()
   {
-    return !isSynchronizationOperation() && DirectoryServer.getCoreConfigManager().isCheckSchema();
+    return !isSynchronizationOperation() && !relaxRules && DirectoryServer.getCoreConfigManager().isCheckSchema();
   }
 
   /**
@@ -1524,8 +1544,12 @@
     wasLocked = pwPolicyState.isLocked();
 
     // Update the password policy state attributes in the user's entry.  If the
-    // modification fails, then these changes won't be applied.
-    pwPolicyState.setPasswordChangedTime();
+    // modification fails, then these changes won't be applied. A client relaxing
+    // the rules may supply the time the password was changed at.
+    if (!relaxRules || !modifiesAttribute(OP_ATTR_PWPOLICY_CHANGED_TIME))
+    {
+      pwPolicyState.setPasswordChangedTime();
+    }
     pwPolicyState.clearFailureLockout();
     pwPolicyState.clearGraceLoginTimes();
     pwPolicyState.clearWarnedTime();
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
index b6dfdac..bde925e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
@@ -27,6 +27,7 @@
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
 import org.opends.server.api.AccessControlHandler;
 import org.opends.server.api.LocalBackend;
 import org.opends.server.controls.LDAPPostReadRequestControl;
@@ -56,6 +57,7 @@
 import org.opends.server.types.Privilege;
 import org.opends.server.types.SearchResultEntry;
 import org.opends.server.types.WritabilityMode;
+import org.opends.server.types.operation.PluginOperation;
 
 import static org.opends.messages.CoreMessages.*;
 import static org.opends.messages.ProtocolMessages.ERR_PROXYAUTH_AUTHZ_NOT_PERMITTED;
@@ -220,6 +222,35 @@
   }
 
   /**
+   * Indicates whether the provided operation carries the Relax Rules request control.
+   * <p>
+   * The control relaxes the constraints of the schema on the request - the attributes marked
+   * NO-USER-MODIFICATION or OBSOLETE, and the schema check of the resulting entry - for a
+   * client which has the {@code bypass-acl} privilege, and keeps the values such a client
+   * supplies for the attributes the server maintains (the last modified attributes, a
+   * pre-encoded password and its change time). It does not make the change a synchronization
+   * one: the change is logged, run through the plugins and replicated as any other.
+   * <p>
+   * The method only reads the request controls: before {@code removeAllDisallowedControls} has
+   * run they include a control the client may not use, and whether the client has the privilege
+   * is for the caller to check. By the time the pre-operation plugins run, both are settled.
+   *
+   * @param operation The operation to look at.
+   * @return {@code true} if the request carries the Relax Rules control.
+   */
+  public static boolean isRelaxRulesRequested(PluginOperation operation)
+  {
+    for (Control c : operation.getRequestControls())
+    {
+      if (RelaxRulesControl.OID.equals(c.getOID()))
+      {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
    * Removes all the disallowed request controls from the provided operation.
    * <p>
    * As per RFC 4511 4.1.11, if a disallowed request control is critical, then a
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
new file mode 100644
index 0000000..c537ff9
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
@@ -0,0 +1,245 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.server.replication.plugin;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.opends.messages.ReplicationMessages.ERR_OPERATION_NOT_FOUND_IN_PENDING;
+import static org.opends.server.TestCaseUtils.TEST_ROOT_DN_STRING;
+
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+import org.forgerock.opendj.ldap.requests.Requests;
+import org.forgerock.opendj.ldap.responses.SearchResultEntry;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.replication.ReplicationTestCase;
+import org.opends.server.replication.protocol.AddMsg;
+import org.opends.server.replication.protocol.LDAPUpdateMsg;
+import org.opends.server.replication.protocol.ModifyMsg;
+import org.opends.server.replication.protocol.ReplicationMsg;
+import org.opends.server.replication.service.ReplicationBroker;
+import org.opends.server.types.Modification;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests that a change made under the Relax Rules control replicates like any other: the
+ * control relaxes the constraints of the schema on the request, it does not make the change
+ * a replayed one.
+ * <p>
+ * The suffix is replicated and a broker listens on its replication server: what the change
+ * publishes is read from there.
+ */
+@SuppressWarnings("javadoc")
+public class RelaxRulesReplicationTest extends ReplicationTestCase
+{
+  private static final String MODIFIED_DN = "cn=relaxed modify," + TEST_ROOT_DN_STRING;
+  private static final String ADDED_DN = "cn=relaxed add," + TEST_ROOT_DN_STRING;
+  /** An attribute the schema marks NO-USER-MODIFICATION, which only the control lets a client write. */
+  private static final String RELAXED_ATTRIBUTE = "pwdChangedTime";
+  private static final String RELAXED_VALUE = "20211203224637.000Z";
+  /**
+   * The record the error logger writes carries the id of the message rather than its text, so
+   * what is looked for here does not depend on the locale the tests run under.
+   */
+  private static final String NOT_IN_PENDING =
+      "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal();
+
+  private ReplicationBroker broker;
+  private LDAPConnectionFactory factory;
+  private Connection connection;
+
+  @Override
+  @BeforeClass(alwaysRun = true)
+  public void setUp() throws Exception
+  {
+    super.setUp();
+
+    final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
+    TestCaseUtils.initializeTestBackend(true);
+
+    final int replServerPort = TestCaseUtils.findFreePort();
+    final String replServerLdif =
+        "dn: cn=Replication Server, " + SYNCHRO_PLUGIN_DN + "\n"
+        + "objectClass: top\n"
+        + "objectClass: ds-cfg-replication-server\n"
+        + "cn: Replication Server\n"
+        + "ds-cfg-replication-port: " + replServerPort + "\n"
+        + "ds-cfg-replication-db-directory: RelaxRulesReplicationTest\n"
+        + "ds-cfg-replication-server-id: 107\n";
+    final String synchroServerLdif =
+        "dn: cn=relaxRulesReplicationTest, cn=domains, " + SYNCHRO_PLUGIN_DN + "\n"
+        + "objectClass: top\n"
+        + "objectClass: ds-cfg-replication-domain\n"
+        + "cn: relaxRulesReplicationTest\n"
+        + "ds-cfg-base-dn: " + baseDN + "\n"
+        + "ds-cfg-replication-server: localhost:" + replServerPort + "\n"
+        + "ds-cfg-server-id: 1\n"
+        + "ds-cfg-receive-status: true\n";
+    configureReplication(replServerLdif, synchroServerLdif);
+
+    broker = openReplicationSession(baseDN, 2, 100, replServerPort, 1000);
+
+    factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+    connection = factory.getConnection();
+    connection.bind("cn=Directory Manager", "password".toCharArray());
+  }
+
+  @AfterClass(alwaysRun = true)
+  public void tearDown() throws Exception
+  {
+    if (connection != null)
+    {
+      connection.close();
+    }
+    if (factory != null)
+    {
+      factory.close();
+    }
+    stop(broker);
+  }
+
+  @Test
+  public void aRelaxedModifyIsPublishedLikeAnyOtherChange() throws Exception
+  {
+    TestCaseUtils.addEntry(
+        "dn: " + MODIFIED_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "sn: relaxed",
+        "cn: relaxed modify");
+    assertThat(nextUpdateFor(MODIFIED_DN)).as("the add of the entry to modify was not published")
+        .isInstanceOf(AddMsg.class);
+
+    TestCaseUtils.ERROR_TEXT_WRITER.clear();
+    connection.modify(Requests.newModifyRequest(MODIFIED_DN)
+        .addControl(new RelaxRulesControl())
+        .addModification(ModificationType.REPLACE, RELAXED_ATTRIBUTE, RELAXED_VALUE));
+
+    assertThat(attributeOf(MODIFIED_DN, RELAXED_ATTRIBUTE)).isEqualTo(RELAXED_VALUE);
+
+    final LDAPUpdateMsg published = nextUpdateFor(MODIFIED_DN);
+    assertThat(published).as("the relaxed modify was not published").isInstanceOf(ModifyMsg.class);
+    assertThat(((ModifyMsg) published).getMods())
+        .as("the published change does not carry the relaxed attribute")
+        .anyMatch(this::modifiesTheRelaxedAttribute);
+    // A change with no history cannot be published again from the entry on the next session.
+    assertThat(valuesOf(MODIFIED_DN, "ds-sync-hist"))
+        .as("the relaxed modify left no historical information")
+        .anyMatch(value -> value.toLowerCase().startsWith(RELAXED_ATTRIBUTE.toLowerCase() + ":"));
+    final List<String> records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages());
+    assertThat(records).as("the relaxed modify was taken for a replayed change")
+        .noneMatch(record -> record.contains(NOT_IN_PENDING));
+  }
+
+  @Test
+  public void aRelaxedAddIsPublishedLikeAnyOtherChange() throws Exception
+  {
+    TestCaseUtils.ERROR_TEXT_WRITER.clear();
+    connection.add(Requests.newAddRequest(
+            "dn: " + ADDED_DN,
+            "objectClass: top",
+            "objectClass: person",
+            "sn: relaxed",
+            "cn: relaxed add",
+            RELAXED_ATTRIBUTE + ": " + RELAXED_VALUE)
+        .addControl(new RelaxRulesControl()));
+
+    assertThat(attributeOf(ADDED_DN, RELAXED_ATTRIBUTE)).isEqualTo(RELAXED_VALUE);
+
+    final LDAPUpdateMsg published = nextUpdateFor(ADDED_DN);
+    assertThat(published).as("the relaxed add was not published").isInstanceOf(AddMsg.class);
+    assertThat(((AddMsg) published).getAttributes())
+        .as("the published add does not carry the relaxed attribute")
+        .anyMatch(attr -> attr.getAttributeDescription().getAttributeType().hasName(RELAXED_ATTRIBUTE));
+    // An add with no history cannot be published again from the entry on the next session.
+    assertThat(valuesOf(ADDED_DN, "ds-sync-hist"))
+        .as("the relaxed add left no historical information")
+        .anyMatch(value -> value.startsWith("dn:") && value.endsWith(":add"));
+    final List<String> records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages());
+    assertThat(records).as("the relaxed add was taken for a replayed change")
+        .noneMatch(record -> record.contains(NOT_IN_PENDING));
+  }
+
+  private boolean modifiesTheRelaxedAttribute(Modification mod)
+  {
+    return mod.getAttribute().getAttributeDescription().getAttributeType().hasName(RELAXED_ATTRIBUTE);
+  }
+
+  private String attributeOf(String dn, String attribute) throws Exception
+  {
+    final SearchResultEntry entry = read(dn);
+    return entry.containsAttribute(attribute) ? entry.parseAttribute(attribute).asString() : null;
+  }
+
+  private Set<String> valuesOf(String dn, String attribute) throws Exception
+  {
+    return read(dn).parseAttribute(attribute).asSetOfString();
+  }
+
+  private SearchResultEntry read(String dn) throws Exception
+  {
+    return connection.searchSingleEntry(
+        Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)")
+            .addAttribute("*", "+"));
+  }
+
+  /**
+   * The next update the replication server forwards for the provided entry, or {@code null} if
+   * none comes within a few seconds - long enough for a change published by the operation which
+   * just returned, which the replication server forwards as soon as it has it.
+   * <p>
+   * The updates of other entries are skipped: those a failed case leaves behind on the broker
+   * the cases share must not be taken for the ones of the next case.
+   */
+  private LDAPUpdateMsg nextUpdateFor(String dn) throws Exception
+  {
+    final DN entryDN = DN.valueOf(dn);
+    final long deadline = System.nanoTime() + SECONDS.toNanos(4);
+    while (deadline - System.nanoTime() > 0)
+    {
+      final ReplicationMsg msg;
+      try
+      {
+        msg = broker.receive();
+      }
+      catch (SocketTimeoutException e)
+      {
+        // The broker reads under a timeout of its own, shorter than the budget here.
+        continue;
+      }
+      if (msg == null)
+      {
+        throw new AssertionError("the broker session is gone");
+      }
+      if (msg instanceof LDAPUpdateMsg && ((LDAPUpdateMsg) msg).getDN().equals(entryDN))
+      {
+        return (LDAPUpdateMsg) msg;
+      }
+    }
+    return null;
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
index 703b85d..6ea36a1 100644
--- a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
@@ -11,14 +11,18 @@
  * Header, with the fields enclosed by brackets [] replaced by your own identifying
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
- * Copyright 2024 3A Systems, LLC.
+ * Copyright 2024-2026 3A Systems, LLC.
  */
 package org.openidentityplatform.opendj;
 
 
 import org.forgerock.opendj.adapter.server3x.Adapters;
 import org.forgerock.opendj.ldap.*;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.controls.GenericControl;
+import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl;
 import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+import org.forgerock.opendj.ldap.requests.AddRequest;
 import org.forgerock.opendj.ldap.requests.ModifyRequest;
 import org.forgerock.opendj.ldap.requests.Requests;
 import org.forgerock.opendj.ldap.responses.Result;
@@ -26,6 +30,7 @@
 import org.opends.server.DirectoryServerTestCase;
 import org.opends.server.TestCaseUtils;
 
+import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
@@ -33,7 +38,22 @@
 
 @Test(sequential = true)
 public class RelaxRulesTestCase extends DirectoryServerTestCase {
+    /** A client which may write anything under the suffix, but has no {@code bypass-acl} privilege. */
+    private static final String USER_DN = "uid=relax.user,o=test";
+    /** A client with the {@code bypass-acl} privilege which may act as {@link #USER_DN}. */
+    private static final String PROXY_DN = "uid=relax.proxy,o=test";
+    private static final String ACCESS_HANDLER_DN = "cn=Access Control Handler,cn=config";
+    /** Lets any authenticated client send the Relax Rules control, so that it survives the ACI of the controls. */
+    private static final String RELAX_CONTROL_ACI = "(targetcontrol=\"" + RelaxRulesControl.OID + "\")"
+            + "(version 3.0; acl \"Relax Rules control access\"; allow(read) userdn=\"ldap:///all\";)";
+    /** The global ACI of the test configuration which lets anyone use any control. */
+    private static final String ANY_CONTROL_ACI = "(targetcontrol=\"*\")"
+            + " (version 3.0; acl \"Anonymous control access\"; allow(read) userdn=\"ldap:///anyone\";)";
+    private static final String OLD_TIME = "20211203224637.000Z";
+    private static final String PRE_ENCODED_PASSWORD = "{SSHA}K9Hv0w7Z0Q2yL3ZJmD1m4n8q3mYk1Xn1R0x6Xw==";
+
     Connection connection;
+    private LDAPConnectionFactory factory;
 
     @BeforeClass
     public void startServer() throws Exception {
@@ -53,13 +73,50 @@
             "userPassword:: cGFzc3dvcmQ=",
             "postalAddress: Aarika Atpco$00900 Maple Street$New Orleans, KS  10857",
             "postalCode: 10857",
-            ""
+            "",
+            "dn: " + USER_DN,
+            "objectClass: top",
+            "objectClass: person",
+            "objectClass: inetOrgPerson",
+            "objectClass: organizationalPerson",
+            "cn: Relax User",
+            "sn: User",
+            "uid: relax.user",
+            "userPassword: password",
+            "",
+            "dn: " + PROXY_DN,
+            "objectClass: top",
+            "objectClass: person",
+            "objectClass: inetOrgPerson",
+            "objectClass: organizationalPerson",
+            "cn: Relax Proxy",
+            "sn: Proxy",
+            "uid: relax.proxy",
+            "userPassword: password",
+            "ds-privilege-name: bypass-acl",
+            "ds-privilege-name: proxied-auth"
         );
 
-        final LDAPConnectionFactory factory =new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+        factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
         connection = factory.getConnection();
         connection.bind("cn=Directory Manager", "password".toCharArray());
         assertThat(connection.isValid()).isTrue();
+
+        // The user may write anything under the suffix, operational attributes included: what stops it
+        // below is the Relax Rules control alone.
+        connection.modify(Requests.newModifyRequest("o=test").addModification(ModificationType.ADD, "aci",
+                "(targetattr=\"*||+\")(version 3.0; acl \"Relax Rules test user\"; allow(all) userdn=\"ldap:///"
+                        + USER_DN + "\";)"));
+    }
+
+    @AfterClass(alwaysRun = true)
+    public void closeConnection() {
+        if (connection != null) {
+            connection.close();
+        }
+        if (factory != null) {
+            factory.close();
+        }
     }
 
     @Test
@@ -80,4 +137,280 @@
         assertThat(srEntry.getAttribute("pwdChangedTime").firstValueAsString()).isEqualTo(
                 "20211203224637.000Z");
     }
+
+    @Test
+    public void aRelaxedModifyStillRunsThePreOperationPlugins() throws Exception {
+        final String dn = addPerson("plugins");
+
+        connection.modify(Requests.newModifyRequest(dn)
+                .addControl(new RelaxRulesControl())
+                .addModification(ModificationType.REPLACE, "description", "relaxed"));
+
+        assertThat(valueOf(dn, "modifiersName")).as("the last modified plugin did not run")
+                .isEqualToIgnoringCase("cn=Directory Manager,cn=Root DNs,cn=config");
+    }
+
+    @Test
+    public void aRelaxedModifyKeepsTheLastModifiedValuesTheClientSupplies() throws Exception {
+        final String dn = addPerson("lastmod modify");
+
+        connection.modify(Requests.newModifyRequest(dn)
+                .addControl(new RelaxRulesControl())
+                .addModification(ModificationType.REPLACE, "modifyTimestamp", OLD_TIME)
+                .addModification(ModificationType.REPLACE, "modifiersName", "cn=migrated"));
+
+        assertThat(valueOf(dn, "modifyTimestamp")).isEqualTo(OLD_TIME);
+        assertThat(valueOf(dn, "modifiersName")).isEqualTo("cn=migrated");
+    }
+
+    @Test
+    public void aRelaxedAddKeepsTheCreationValuesTheClientSupplies() throws Exception {
+        final String dn = "cn=lastmod add,o=test";
+
+        connection.add(person(dn)
+                .addAttribute("createTimestamp", OLD_TIME)
+                .addAttribute("creatorsName", "cn=migrated")
+                .addControl(new RelaxRulesControl()));
+
+        assertThat(valueOf(dn, "createTimestamp")).isEqualTo(OLD_TIME);
+        assertThat(valueOf(dn, "creatorsName")).isEqualTo("cn=migrated");
+    }
+
+    @Test
+    public void aRelaxedModifyKeepsAMigratedPasswordAndItsChangeTime() throws Exception {
+        final String dn = addPerson("password modify");
+
+        connection.modify(Requests.newModifyRequest(dn)
+                .addControl(new RelaxRulesControl())
+                .addModification(ModificationType.REPLACE, "userPassword", PRE_ENCODED_PASSWORD)
+                .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME));
+
+        assertThat(valueOf(dn, "userPassword")).isEqualTo(PRE_ENCODED_PASSWORD);
+        assertThat(valueOf(dn, "pwdChangedTime")).isEqualTo(OLD_TIME);
+    }
+
+    @Test
+    public void aRelaxedAddKeepsAMigratedPasswordAndItsChangeTime() throws Exception {
+        final String dn = "cn=password add,o=test";
+
+        connection.add(person(dn)
+                .addAttribute("userPassword", PRE_ENCODED_PASSWORD)
+                .addAttribute("pwdChangedTime", OLD_TIME)
+                .addControl(new RelaxRulesControl()));
+
+        assertThat(valueOf(dn, "userPassword")).isEqualTo(PRE_ENCODED_PASSWORD);
+        assertThat(valueOf(dn, "pwdChangedTime")).isEqualTo(OLD_TIME);
+    }
+
+    @Test
+    public void aRelaxedAddSkipsTheSchemaCheck() throws Exception {
+        // person does not allow mail.
+        assertThat(resultOf(() -> connection.add(person("cn=schema add,o=test").addAttribute("mail", "x@example.com"))))
+                .isEqualTo(ResultCode.OBJECTCLASS_VIOLATION);
+
+        connection.add(person("cn=schema add,o=test")
+                .addAttribute("mail", "x@example.com")
+                .addControl(new RelaxRulesControl()));
+
+        assertThat(valueOf("cn=schema add,o=test", "mail")).isEqualTo("x@example.com");
+    }
+
+    @Test
+    public void aRelaxedModifySkipsTheSchemaCheck() throws Exception {
+        final String dn = addPerson("schema modify");
+        final ModifyRequest addMail = Requests.newModifyRequest(dn)
+                .addModification(ModificationType.ADD, "mail", "x@example.com");
+        assertThat(resultOf(() -> connection.modify(addMail))).isEqualTo(ResultCode.OBJECTCLASS_VIOLATION);
+
+        connection.modify(Requests.copyOfModifyRequest(addMail).addControl(new RelaxRulesControl()));
+
+        assertThat(valueOf(dn, "mail")).isEqualTo("x@example.com");
+    }
+
+    @Test
+    public void aRelaxedModifyMayWriteAnObsoleteAttribute() throws Exception {
+        final String obsoleteType = "( 1.3.6.1.4.1.36733.2.1.999.1053 NAME 'relaxRulesObsoleteTest' OBSOLETE"
+                + " SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-ORIGIN 'RelaxRulesTestCase' )";
+        connection.modify(Requests.newModifyRequest("cn=schema")
+                .addModification(ModificationType.ADD, "attributeTypes", obsoleteType));
+        try {
+            final String dn = "cn=obsolete,o=test";
+            connection.add(Requests.newAddRequest(dn)
+                    .addAttribute("objectClass", "top", "person", "extensibleObject")
+                    .addAttribute("cn", "obsolete")
+                    .addAttribute("sn", "obsolete"));
+            final ModifyRequest writeObsolete = Requests.newModifyRequest(dn)
+                    .addModification(ModificationType.REPLACE, "relaxRulesObsoleteTest", "value");
+            assertThat(resultOf(() -> connection.modify(writeObsolete))).isEqualTo(ResultCode.CONSTRAINT_VIOLATION);
+
+            connection.modify(Requests.copyOfModifyRequest(writeObsolete).addControl(new RelaxRulesControl()));
+
+            assertThat(valueOf(dn, "relaxRulesObsoleteTest")).isEqualTo("value");
+            connection.delete(dn);
+        } finally {
+            connection.modify(Requests.newModifyRequest("cn=schema")
+                    .addModification(ModificationType.DELETE, "attributeTypes", obsoleteType));
+        }
+    }
+
+    /**
+     * A non-critical control the client may not use is dropped (RFC 4511 4.1.11): the request is then an
+     * ordinary one, and succeeds as such.
+     */
+    @Test
+    public void aNonCriticalControlTheClientMayNotUseIsIgnored() throws Exception {
+        final String dn = addPerson("ignored control");
+        try (ControlAccess restricted = restrictControlAccess(null);
+             Connection user = bindAsUser()) {
+            user.modify(Requests.newModifyRequest(dn)
+                    .addControl(nonCriticalRelaxRules())
+                    .addModification(ModificationType.REPLACE, "description", "ordinary"));
+            user.add(person("cn=ignored control add,o=test").addControl(nonCriticalRelaxRules()));
+        }
+
+        assertThat(valueOf(dn, "description")).isEqualTo("ordinary");
+        assertThat(exists("cn=ignored control add,o=test")).isTrue();
+    }
+
+    /** Once the control ACI lets the control through, only the {@code bypass-acl} privilege lets it relax anything. */
+    @Test
+    public void aClientWithoutBypassAclMayNotRelaxTheRules() throws Exception {
+        final String dn = addPerson("unprivileged");
+        try (ControlAccess relaxAllowed = restrictControlAccess(RELAX_CONTROL_ACI);
+             Connection user = bindAsUser()) {
+            assertThat(resultOf(() -> user.add(person("cn=unprivileged add,o=test")
+                    .addAttribute("pwdChangedTime", OLD_TIME)
+                    .addControl(new RelaxRulesControl()))))
+                    .isNotEqualTo(ResultCode.SUCCESS);
+            assertThat(resultOf(() -> user.add(person("cn=unprivileged plain add,o=test")
+                    .addControl(new RelaxRulesControl()))))
+                    .isEqualTo(ResultCode.INSUFFICIENT_ACCESS_RIGHTS);
+            assertThat(resultOf(() -> user.modify(Requests.newModifyRequest(dn)
+                    .addControl(new RelaxRulesControl())
+                    .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME))))
+                    .isEqualTo(ResultCode.INSUFFICIENT_ACCESS_RIGHTS);
+        }
+
+        assertThat(exists("cn=unprivileged add,o=test")).isFalse();
+        assertThat(exists("cn=unprivileged plain add,o=test")).isFalse();
+        assertThat(valueOf(dn, "pwdChangedTime")).isNull();
+    }
+
+    /**
+     * The privilege which lets the rules be relaxed is the one of the identity the request runs as: a
+     * client with {@code bypass-acl} which proxies as an ordinary user relaxes nothing, whether the control
+     * ACI lets the control through for that user or not.
+     */
+    @Test
+    public void aProxiedIdentityWithoutBypassAclMayNotRelaxTheRules() throws Exception {
+        assertAProxiedRelaxedChangeIsRefused("proxied kept");
+        try (ControlAccess restricted = restrictControlAccess(null)) {
+            assertAProxiedRelaxedChangeIsRefused("proxied dropped");
+        }
+    }
+
+    private void assertAProxiedRelaxedChangeIsRefused(String cn) throws Exception {
+        final String dn = addPerson(cn);
+        final String addedDN = "cn=" + cn + " add,o=test";
+        final ProxiedAuthV2RequestControl asUser = ProxiedAuthV2RequestControl.newControl("dn:" + USER_DN);
+
+        try (Connection proxy = factory.getConnection()) {
+            proxy.bind(PROXY_DN, "password".toCharArray());
+            // Without the Relax Rules control, the proxy may act as the user.
+            proxy.modify(Requests.newModifyRequest(dn)
+                    .addControl(asUser)
+                    .addModification(ModificationType.REPLACE, "description", "proxied"));
+
+            assertThat(resultOf(() -> proxy.add(person(addedDN)
+                    .addAttribute("pwdChangedTime", OLD_TIME)
+                    .addControl(asUser)
+                    .addControl(nonCriticalRelaxRules()))))
+                    .isNotEqualTo(ResultCode.SUCCESS);
+            assertThat(resultOf(() -> proxy.modify(Requests.newModifyRequest(dn)
+                    .addControl(asUser)
+                    .addControl(nonCriticalRelaxRules())
+                    .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME))))
+                    .isNotEqualTo(ResultCode.SUCCESS);
+        }
+
+        assertThat(valueOf(dn, "modifiersName")).isEqualToIgnoringCase(USER_DN);
+        assertThat(exists(addedDN)).isFalse();
+        assertThat(valueOf(dn, "pwdChangedTime")).isNull();
+    }
+
+    /**
+     * Removes the global ACI of the test configuration which lets anyone use any control, adding the provided
+     * one instead if any, until closed.
+     */
+    private ControlAccess restrictControlAccess(String aci) throws LdapException {
+        connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+                .addModification(ModificationType.DELETE, "ds-cfg-global-aci", ANY_CONTROL_ACI));
+        if (aci != null) {
+            connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+                    .addModification(ModificationType.ADD, "ds-cfg-global-aci", aci));
+        }
+        return () -> {
+            if (aci != null) {
+                connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+                        .addModification(ModificationType.DELETE, "ds-cfg-global-aci", aci));
+            }
+            connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+                    .addModification(ModificationType.ADD, "ds-cfg-global-aci", ANY_CONTROL_ACI));
+        };
+    }
+
+    private interface ControlAccess extends AutoCloseable {
+        @Override
+        void close() throws LdapException;
+    }
+
+    /** {@link RelaxRulesControl} is always critical. */
+    private static Control nonCriticalRelaxRules() {
+        return GenericControl.newControl(RelaxRulesControl.OID, false);
+    }
+
+    private String addPerson(String cn) throws LdapException {
+        final String dn = "cn=" + cn + ",o=test";
+        connection.add(person(dn));
+        return dn;
+    }
+
+    private static AddRequest person(String dn) {
+        final String cn = DN.valueOf(dn).rdn().getFirstAVA().getAttributeValue().toString();
+        return Requests.newAddRequest(dn)
+                .addAttribute("objectClass", "top", "person")
+                .addAttribute("cn", cn)
+                .addAttribute("sn", cn);
+    }
+
+    private Connection bindAsUser() throws LdapException {
+        final Connection user = factory.getConnection();
+        user.bind(USER_DN, "password".toCharArray());
+        return user;
+    }
+
+    private String valueOf(String dn, String attribute) throws LdapException {
+        final SearchResultEntry entry = connection.searchSingleEntry(
+                Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)").addAttribute("*", "+"));
+        final Attribute attr = entry.getAttribute(attribute);
+        return attr != null ? attr.firstValueAsString() : null;
+    }
+
+    private boolean exists(String dn) {
+        return resultOf(() -> connection.searchSingleEntry(
+                Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)"))) == ResultCode.SUCCESS;
+    }
+
+    private interface Request {
+        Object send() throws LdapException;
+    }
+
+    private static ResultCode resultOf(Request request) {
+        try {
+            request.send();
+            return ResultCode.SUCCESS;
+        } catch (LdapException e) {
+            return e.getResult().getResultCode();
+        }
+    }
 }

--
Gitblit v1.10.0