mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
14 hours ago dd94891ad6946da5646fffdf11631144725b5636
[#1051] Replicate a change made under the Relax Rules control like any other (#1053)
8 files modified
1 files added
915 ■■■■■ changed files
opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc 7 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml 13 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java 9 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java 143 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java 66 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java 62 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java 31 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java 245 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java 339 ●●●●● patch | view | raw | blame | history
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.
--
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>
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));
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
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))
      {
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();
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
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
New file
@@ -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;
  }
}
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();
        }
    }
}