UnboundID Server SDK

Ping Identity
UnboundID Server SDK Documentation

ExampleProxiedExtendedOperationHandler.java

/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at
 * docs/licenses/cddl.txt
 * or http://www.opensource.org/licenses/cddl1.php.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at
 * docs/licenses/cddl.txt.  If applicable,
 * add the following below this CDDL HEADER, with the fields enclosed
 * by brackets "[]" replaced with your own identifying information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2014-2020 Ping Identity Corporation
 */
package com.unboundid.directory.sdk.examples;



import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.unboundid.directory.sdk.common.operation.ExtendedRequest;
import com.unboundid.directory.sdk.common.schema.AttributeType;
import com.unboundid.directory.sdk.common.types.OperationContext;
import com.unboundid.directory.sdk.proxy.api.ProxiedExtendedOperationHandler;
import com.unboundid.directory.sdk.proxy.config.
            ProxiedExtendedOperationHandlerConfig;
import com.unboundid.directory.sdk.proxy.types.BackendSet;
import com.unboundid.directory.sdk.proxy.types.EntryBalancingRequestProcessor;
import com.unboundid.directory.sdk.proxy.types.ProxyingRequestProcessor;
import com.unboundid.directory.sdk.proxy.types.ProxyServerContext;
import com.unboundid.ldap.sdk.DN;
import com.unboundid.ldap.sdk.ExtendedResult;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.ldap.sdk.extensions.PasswordModifyExtendedRequest;
import com.unboundid.ldap.sdk.unboundidds.controls.NoOpRequestControl;
import com.unboundid.ldap.sdk.unboundidds.controls.PasswordPolicyRequestControl;
import com.unboundid.ldap.sdk.unboundidds.controls.PurgePasswordRequestControl;
import com.unboundid.ldap.sdk.unboundidds.controls.RetirePasswordRequestControl;
import com.unboundid.ldap.sdk.unboundidds.controls.
            SuppressOperationalAttributeUpdateRequestControl;
import com.unboundid.util.ObjectPair;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.args.Argument;
import com.unboundid.util.args.ArgumentException;
import com.unboundid.util.args.ArgumentParser;
import com.unboundid.util.args.StringArgument;



/**
 * This class provides an example of a proxied extended operation handler that
 * can be used to forward password modify extended requests (as defined in
 * <A HREF="http://www.ietf.org/rfc/rfc3062.txt">RFC 3062</A>) through the
 * Directory Proxy Server to one or more backend servers in simple proxy and/or
 * entry-balanced configurations.
 * <BR><BR>
 * This proxied extended operation handler takes the following configuration
 * arguments:
 * <UL>
 *   <LI>username-attribute -- This specifies the name of the attribute that
 *       will be used in an attempt to locate the target entry for an
 *       authentication identity that starts with "u:".  If this is not
 *       specified, a default of "uid" will be used.
 * </UL>
 */
public final class ExampleProxiedExtendedOperationHandler
       extends ProxiedExtendedOperationHandler
{
  /**
   * The name of the argument that will be used to specify the username
   * attribute.
   */
  private static final String ARG_NAME_USERNAME_ATTRIBUTE =
       "username-attribute";



  /**
   * The default value that will be used for the username attribute.
   */
  private static final String DEFAULT_USERNAME_ATTRIBUTE = "uid";



  /**
   * The name of the operation attachment that may be used to hold a pre-decoded
   * version of the request.
   */
  private static final String ATTACHMENT_NAME_DECODED_REQUEST =
       ExampleProxiedExtendedOperationHandler.class.getName() +
            ".decodedRequest";



  /**
   * The name of the operation attachment that may be used to hold a pre-decoded
   * version of the target user DN from the request.
   */
  private static final String ATTACHMENT_NAME_DECODED_USER_DN =
       ExampleProxiedExtendedOperationHandler.class.getName() +
            ".decodedUserDN";



  /**
   * The name of the operation attachment that may be used to hold a pre-decoded
   * version of the target username from the request.
   */
  private static final String ATTACHMENT_NAME_DECODED_USERNAME =
       ExampleProxiedExtendedOperationHandler.class.getName() +
            ".decodedUsername";



  // The name of the attribute that will be used for username lookups.
  private volatile String usernameAttribute = DEFAULT_USERNAME_ATTRIBUTE;



  /**
   * Creates a new instance of this proxied extended operation handler.  All
   * proxied extended operation handler implementations must include a default
   * constructor, but any initialization should generally be done in the
   * {@code initializeProxiedExtendedOperationHandler} method.
   */
  public ExampleProxiedExtendedOperationHandler()
  {
    // No implementation is required.
  }



  /**
   * {@inheritDoc}
   */
  @Override()
  public String getExtensionName()
  {
    return "Password Modify Proxied Extended Operation Handler";
  }



  /**
   * {@inheritDoc}
   */
  @Override()
  public String[] getExtensionDescription()
  {
    return new String[]
    {
      "This example is intended to demonstrate the process for creating a " +
           "custom proxied extended operation handler via the UnboundID " +
           "Server SDK.  In this case, it has the ability to examine a " +
           "password modify extended request to determine which backend " +
           "server(s) should be used to process it.  The userIdentity field " +
           "of the request will be used in conjunction with the " +
           "authentication identity of the underlying client connection in " +
           "order to determine where the request should be sent."
    };
  }



  /**
   * {@inheritDoc}
   */
  @Override()
  public void defineConfigArguments(final ArgumentParser parser)
         throws ArgumentException
  {
    final String usernameAttrArgDescription = "The name of the attribute " +
         "that will be used to look up user entries for a given username.";
    final StringArgument usernameAttrArg = new StringArgument(
         null,                     // The short identifier -- none will be used.
         ARG_NAME_USERNAME_ATTRIBUTE,                    // The long identifier.
         false,                                        // Argument not required.
         1,                              // It may be provided at most one time.
         "{attr}",                      // A placeholder for the argument value.
         usernameAttrArgDescription,                // The argument description.
         DEFAULT_USERNAME_ATTRIBUTE);                      // The default value.
    parser.addArgument(usernameAttrArg);
  }



  /**
   * Initializes this proxied extended operation handler.
   *
   * @param  serverContext  A handle to the server context for the server in
   *                        which this extension is running.
   * @param  config         The general configuration for this extended
   *                        operation handler.
   * @param  parser         The argument parser which has been initialized from
   *                        the configuration for this extended operation
   *                        handler.
   *
   * @throws  LDAPException  If a problem occurs while initializing this
   *                         extended operation handler.
   */
  @Override()
  public void initializeProxiedExtendedOperationHandler(
                   final ProxyServerContext serverContext,
                   final ProxiedExtendedOperationHandlerConfig config,
                   final ArgumentParser parser)
         throws LDAPException
  {
    usernameAttribute = getUsernameAttribute(parser);
  }



  /**
   * Indicates whether the configuration represented by the provided argument
   * parser is acceptable for use by this extension.  The parser will have been
   * used to parse any configuration available for this extension, and any
   * automatic validation will have been performed.  This method may be used to
   * perform any more complex validation which cannot be performed automatically
   * by the argument parser.
   *
   * @param  config               The general configuration for this extension.
   * @param  parser               The argument parser that has been used to
   *                              parse the proposed configuration for this
   *                              extension.
   * @param  unacceptableReasons  A list to which messages may be added to
   *                              provide additional information about why the
   *                              provided configuration is not acceptable.
   *
   * @return  {@code true} if the configuration in the provided argument parser
   *          appears to be acceptable, or {@code false} if not.
   */
  @Override()
  public boolean isConfigurationAcceptable(
                      final ProxiedExtendedOperationHandlerConfig config,
                      final ArgumentParser parser,
                      final List<String> unacceptableReasons)
  {
    boolean acceptable = true;

    // If a username attribute was specified, then make sure it's defined in the
    // server schema.
    final ProxyServerContext serverContext = config.getServerContext();
    final String usernameAttr = getUsernameAttribute(parser);
    final AttributeType attrType =
         serverContext.getSchema().getAttributeType(usernameAttr, false);
    if (attrType == null)
    {
      unacceptableReasons.add("Username attribute '" + usernameAttr +
           "' is not defined in the server schema.");
      acceptable = false;
    }

    return acceptable;
  }



  /**
   * Attempts to apply the configuration from the provided argument parser to
   * this extension.
   *
   * @param  config                The general configuration for this extension.
   * @param  parser                The argument parser that has been used to
   *                               parse the new configuration for this
   *                               extension.
   * @param  adminActionsRequired  A list to which messages may be added to
   *                               provide additional information about any
   *                               additional administrative actions that may
   *                               be required to apply some of the
   *                               configuration changes.
   * @param  messages              A list to which messages may be added to
   *                               provide additional information about the
   *                               processing performed by this method.
   *
   * @return  A result code providing information about the result of applying
   *          the configuration change.  A result of {@code SUCCESS} should be
   *          used to indicate that all processing completed successfully.  Any
   *          other result will indicate that a problem occurred during
   *          processing.
   */
  @Override()
  public ResultCode applyConfiguration(
                         final ProxiedExtendedOperationHandlerConfig config,
                         final ArgumentParser parser,
                         final List<String> adminActionsRequired,
                         final List<String> messages)
  {
    // Update the username attribute.  We'll do this regardless of whether it
    // has actually changed.
    usernameAttribute = getUsernameAttribute(parser);

    return ResultCode.SUCCESS;
  }



  /**
   * Performs any cleanup which may be necessary when this proxied extended
   * operation handler is to be taken out of service.
   */
  @Override()
  public void finalizeProxiedExtendedOperationHandler()
  {
    // No implementation is required.
  }



  /**
   * Retrieves the name of the extended operation with the provided OID.
   *
   * @param  oid  The OID of the extended operation for which to retrieve the
   *              corresponding name.
   *
   * @return  The name of the extended operation with the specified OID, or
   *          {@code null} if the specified OID is not recognized by this
   *          proxied extended operation handler.
   */
  @Override()
  public String getExtendedOperationName(final String oid)
  {
    if (oid.equals(PasswordModifyExtendedRequest.PASSWORD_MODIFY_REQUEST_OID))
    {
      return "Password Modify Extended Request";
    }
    else
    {
      return null;
    }
  }



  /**
   * Retrieves the OIDs of the extended operation types supported by this
   * proxied extended operation handler.
   *
   * @return  The OIDs of the extended operation types supported by this proxied
   *          extended operation handler.  It must not be {@code null} or
   *          empty, and the contents of the set returned must not change over
   *          the life of this proxied extended operation handler.
   */
  @Override()
  public Set<String> getSupportedExtensions()
  {
    // This extended operation handler only supports the password modify
    // operation.
    final LinkedHashSet<String> oids = new LinkedHashSet<String>(1);
    oids.add(PasswordModifyExtendedRequest.PASSWORD_MODIFY_REQUEST_OID);

    return Collections.unmodifiableSet(oids);
  }



  /**
   * Retrieves the OIDs of any controls supported by this proxied extended
   * operation handler.
   *
   * @return  The OIDs of any controls supported by this proxied extended
   *          operation handler.  It may be {@code null} or empty if this
   *          proxied extended operation handler does not support any controls.
   */
  @Override()
  public Set<String> getSupportedControls()
  {
    // A number of controls are supported for use with the password modify
    // extended operation.  Note, however, that this implementation does not
    // supported proxied password modify in conjunction with transactions.
    final LinkedHashSet<String> oids = new LinkedHashSet<String>(5);
    oids.add(NoOpRequestControl.NO_OP_REQUEST_OID);
    oids.add(PasswordPolicyRequestControl.PASSWORD_POLICY_REQUEST_OID);
    oids.add(PurgePasswordRequestControl.PURGE_PASSWORD_REQUEST_OID);
    oids.add(RetirePasswordRequestControl.RETIRE_PASSWORD_REQUEST_OID);
    oids.add(SuppressOperationalAttributeUpdateRequestControl.
         SUPPRESS_OP_ATTR_UPDATE_REQUEST_OID);

    return Collections.unmodifiableSet(oids);
  }



  /**
   * Retrieves the OIDs of any features supported by this proxied extended
   * operation handler that should be advertised in the server root DSE.
   *
   * @return  The OIDs of any features supported by this proxied extended
   *          operation handler.  It may be {@code null} or empty if this
   *          proxied extended operation handler does not support any features.
   */
  @Override()
  public Set<String> getSupportedFeatures()
  {
    // There are no additional features supported by this extension.
    return Collections.emptySet();
  }



  /**
   * Selects the entry-balancing backend set(s) to which the provided extended
   * request should be forwarded.  This method will only be invoked for
   * operations in which the requester's client connection policy includes one
   * or more subtree views which reference an entry-balancing request processor.
   * <BR><BR>
   * This method returns two groups of backend sets, with the first representing
   * an initial guess (e.g., based on information obtained from the
   * entry-balancing global index), and the second representing a fallback if
   * the initial guess was found to be incorrect.
   * <BR><BR>
   * If it can be determined that no backend sets associated with the provided
   * entry-balancing request processor should be used to process the extended
   * operation, then the object returned may have both elements set to
   * {@code null} or empty sets.  If it is possible to definitively determine
   * the set of backend sets to which the operation should be forwarded and no
   * fallback option is required, then the first element of the returned object
   * should be populated with a non-empty set and the second element should be
   * {@code null} or empty.
   * <BR><BR>
   * For the password modify extended operation, since the request should target
   * a single entry, the first set may include the one backend set in which we
   * believe the target entry exists (based on information from the global
   * index), and the second set may include all other backend sets in case it
   * was not found in the first set.  If the entry-balancing global index cannot
   * be used to provide any hint about the location of the entry, then the first
   * set will include all backend sets and the second set will be null to
   * indicate that there is no fallback option.
   *
   * @param  operationContext  The operation context for the extended operation.
   * @param  request           The extended request to be processed.
   * @param  requestProcessor  The entry-balancing request processor in which
   *                           the extended operation should be processed.
   *
   * @return  The set of backend sets to which the request should be forwarded.
   *          It may be {@code null} (or it may be an {@code ObjectPair} with
   *          both elements empty or {@code null}) if the request should not be
   *          forwarded to any backend set for the entry-balancing request
   *          processor.
   *
   * @throws  LDAPException  If the request should not be forwarded to any of
   *                         the backend sets for the provided entry-balancing
   *                         request processor, and the result contained in the
   *                         exception should be used instead.
   */
  @Override()
  public ObjectPair<Set<BackendSet>,Set<BackendSet>> selectBackendSets(
              final OperationContext operationContext,
              final ExtendedRequest request,
              final EntryBalancingRequestProcessor requestProcessor)
         throws LDAPException
  {
    // Extract the relevant details from the request.  Since this extended
    // operation handler could be invoked multiple times for the same request
    // (if the Directory Proxy Server is configured to forward multiple portions
    // of the DIT), we'll use operation attachments to avoid decoding things
    // multiple times.
    final PasswordModifyExtendedRequest passwordModifyRequest =
         getPasswordModifyRequest(operationContext, request);


    // Although a password modify request is allowed to not include a new
    // password (which indicates that the server should generate the new
    // password on behalf of the user), we won't allow that here.  The reason is
    // that there are cases in which the request might need to be forwarded to
    // multiple backend sets (especially if the targeted entry is outside the
    // balancing point) and there's no way to have different servers generate
    // the same new password.  Technically we could allow this for cases in
    // which we know the target entry is below the balancing point, but it's
    // simpler to not allow this at all.
    if (passwordModifyRequest.getRawNewPassword() == null)
    {
      throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM,
           "The password modify proxied extended operation handler cannot be " +
                "used to proxy requests that do not specify an explicit new " +
                "password.");
    }


    // See if the request targets a user by DN (whether implicitly or
    // explicitly) or by username.
    final DN userDN = getTargetUserDN(operationContext, passwordModifyRequest);
    if (userDN == null)
    {
      // This means that the target user was specified as a username rather than
      // a DN.  In this case, we'll always process the request in this
      // entry-balancing request processor, but we can still try to use the
      // global index to figure out where to route the request based on the
      // username attribute.
      final String username =
           getTargetUsername(operationContext, passwordModifyRequest);
      final BackendSet indexedSet = requestProcessor.getGlobalIndexHint(
           usernameAttribute, username);
      if (indexedSet == null)
      {
        // It's not in the global index, so send it everywhere.
        return new ObjectPair<Set<BackendSet>,Set<BackendSet>>(
             new LinkedHashSet<BackendSet>(requestProcessor.getBackendSets()),
             null);
      }
      else
      {
        // Prioritize for the case where the entry is where the global index
        // thinks it is, but fall back to the rest of the servers.
        final LinkedHashSet<BackendSet> firstGuess =
             new LinkedHashSet<BackendSet>(1);
        firstGuess.add(indexedSet);

        final LinkedHashSet<BackendSet> remaining =
             new LinkedHashSet<BackendSet>(requestProcessor.getBackendSets());
        remaining.remove(indexedSet);

        return new ObjectPair<Set<BackendSet>,Set<BackendSet>>(firstGuess,
             remaining);
      }
    }
    else
    {
      // We have a DN, so first see if it's below the request processor.  If
      // not, then we won't process it here at all.
      if (! userDN.isDescendantOf(requestProcessor.getRequestProcessorBaseDN(),
           true))
      {
        return null;
      }

      // If the user DN is not below the entry-balancing base DN, then we'll
      // send it everywhere.
      if (! userDN.isDescendantOf(requestProcessor.getBalancingPointBaseDN(),
           false))
      {
        return new ObjectPair<Set<BackendSet>,Set<BackendSet>>(
             new LinkedHashSet<BackendSet>(requestProcessor.getBackendSets()),
             null);
      }

      // See if we can use the global index to get a hint as to the location of
      // the entry.
      final BackendSet indexedSet = requestProcessor.getGlobalIndexHint(userDN);
      if (indexedSet == null)
      {
        // It's not in the global index, so send it everywhere.
        return new ObjectPair<Set<BackendSet>,Set<BackendSet>>(
             new LinkedHashSet<BackendSet>(requestProcessor.getBackendSets()),
             null);
      }
      else
      {
        // Prioritize for the case where the entry is where the global index
        // thinks it is, but fall back to the rest of the servers.
        final LinkedHashSet<BackendSet> firstGuess =
             new LinkedHashSet<BackendSet>(1);
        firstGuess.add(indexedSet);

        final LinkedHashSet<BackendSet> remaining =
             new LinkedHashSet<BackendSet>(requestProcessor.getBackendSets());
        remaining.remove(indexedSet);

        return new ObjectPair<Set<BackendSet>,Set<BackendSet>>(firstGuess,
             remaining);
      }
    }
  }



  /**
   * Obtains the extended result that should be used as a result of processing
   * an operation in one or more entry-balanced backend sets, or throws an
   * exception to indicate that the request should instead be forwarded to the
   * fallback server set(s).
   * <BR><BR>
   * This method will only be invoked for cases in which the
   * {@link #selectBackendSets} method indicates that multiple backend sets
   * should be accessed in the course of processing an extended request.  For
   * the password modify extended operation, this will be any case in which the
   * global index cannot be used, or for the fallback set of servers in the
   * event that the global index hint is incorrect.
   *
   * @param  operationContext   The operation context for the extended
   *                            operation.
   * @param  request            The extended request that was processed.
   * @param  requestProcessor   The entry-balancing request processor in which
   *                            the extended operation was processed.
   * @param  results            A list of the extended results obtained from
   *                            processing the operation in the selected set of
   *                            backend sets, with each result paired with
   *                            information about the backend set from which it
   *                            was obtained.
   * @param  fallbackAvailable  Indicates whether a fallback group of backend
   *                            sets is available and may be used as a second
   *                            attempt at processing the operation if the
   *                            result from the initial attempt is not
   *                            acceptable.
   *
   * @return  An extended result that represents the merged result from the
   *          provided list of results.  It must not be {@code null}.
   *
   * @throws  LDAPException  To indicate that the initial set of results was not
   *                         acceptable and that the operation should instead be
   *                         forwarded to the fallback group of backend sets.
   *                         If no fallback set of results is available, then an
   *                         extended result will be generated from the content
   *                         of this exception.
   */
  @Override()
  public ExtendedResult mergeEntryBalancedResults(
              final OperationContext operationContext,
              final ExtendedRequest request,
              final EntryBalancingRequestProcessor requestProcessor,
              final List<ObjectPair<ExtendedResult,BackendSet>> results,
              final boolean fallbackAvailable)
         throws LDAPException
  {
    // If there are no results at all, then that's an error.  This should
    // never happen, but we'll check for it just in case.
    if (results.isEmpty())
    {
      throw new LDAPException(ResultCode.OTHER,
           "No results were obtained from the entry-balancing request " +
                "processor with base DN '" +
                requestProcessor.getRequestProcessorBaseDN() + "'.");
    }


    // Look at the set of results and categorize them as either successes or
    // failures.
    final ArrayList<ObjectPair<ExtendedResult,BackendSet>> successes =
         new ArrayList<ObjectPair<ExtendedResult,BackendSet>>(results.size());
    final ArrayList<ObjectPair<ExtendedResult,BackendSet>> failures =
         new ArrayList<ObjectPair<ExtendedResult,BackendSet>>(results.size());
    for (final ObjectPair<ExtendedResult,BackendSet> r : results)
    {
      if (r.getFirst().getResultCode() == ResultCode.SUCCESS)
      {
        successes.add(r);
      }
      else
      {
        failures.add(r);
      }
    }


    // If we only got successes, then return the first success result.
    if (failures.isEmpty())
    {
      return successes.get(0).getFirst();
    }


    // If we only got failures, then we may either return an error result or
    // throw an exception to indicate that the fallback servers should be used.
    if (successes.isEmpty())
    {
      // If an operation fails because the entry doesn't exist, then the result
      // code should be either NO_SUCH_OBJECT or INVALID_CREDENTIALS (if the
      // request included the user's current password and the server shouldn't
      // divulge the reason for the failure).  If we find any failure with a
      // different result, then we'll return that result even if there are
      // fallback servers.
      for (final ObjectPair<ExtendedResult,BackendSet> r : failures)
      {
        switch (r.getFirst().getResultCode().intValue())
        {
          case ResultCode.NO_SUCH_OBJECT_INT_VALUE:
          case ResultCode.INVALID_CREDENTIALS_INT_VALUE:
            break;
          default:
            return r.getFirst();
        }
      }

      // This suggests that all the failures were because either the target
      // entry doesn't exist or, if a current password was provided, then the
      // current password was wrong.  We can't tell which so we'll assume that
      // the entry doesn't exist.  If there are fallback servers, then we'll
      // throw an exception.  Otherwise return the first error result we got.
      if (fallbackAvailable)
      {
        throw new LDAPException(ResultCode.NO_RESULTS_RETURNED,
             "No success results were returned from the entry-balancing " +
                  "request processor with base DN '" +
                  requestProcessor.getRequestProcessorBaseDN() +
                  "'.  Try again with the fallback servers.");
      }
      else
      {
        return results.get(0).getFirst();
      }
    }


    // If we've gotten here, then we got a mix of success and failure results.
    // If we can extract a target DN from the request, then use that to
    // determine whether we expected one or multiple successes.
    final PasswordModifyExtendedRequest passwordModifyRequest =
         getPasswordModifyRequest(operationContext, request);
    final DN userDN = getTargetUserDN(operationContext, passwordModifyRequest);
    if (userDN != null)
    {
      // If the target user DN is subordinate to the balancing point, then we
      // should have gotten either exactly one success or all failures.  If it's
      // not below the balancing point, then we should have gotten either all
      // successes or all failures.
      if (userDN.isDescendantOf(requestProcessor.getBalancingPointBaseDN(),
           false))
      {
        if (successes.size() == 1)
        {
          // We got the one success we expected, so we'll return it.
          return successes.get(0).getFirst();
        }
        else
        {
          // We got multiple successes, which suggests the entry exists in
          // multiple backend sets.  This is an error.
          final String message = "The password modify operation targeting " +
               "entry '" +  userDN + "' (which is below the balancing point) " +
               "succeeded in multiple backend sets, indicating that the " +
               "target entry exists in multiple sets.";
          return generateEntryBalancingErrorResult(message,
               operationContext.getMessageID(), successes, failures);
        }
      }
      else
      {
        // The entry isn't below the balancing point, so we should have gotten
        // the same result everywhere.  Return an error response indicating that
        // we didn't.
        final String message = "The password modify operation targeting " +
             "entry '" + userDN + "' (which is not below the balancing point " +
             "and therefore should exist in all backend sets) succeeded in " +
             "at least one backend set but also failed in at least one set.  " +
             "This means that the entry is inconsistent across the backend " +
             "sets.";
        return generateEntryBalancingErrorResult(message,
             operationContext.getMessageID(), successes, failures);
      }
    }


    // If we've gotten here, then we know that the entry was targeted by
    // username rather than by DN, and that there were a mix of success and
    // failure results.  If there was a single success result, then return it.
    // If there were multiple success results but also one or more failures,
    // then generate an error result.
    if (successes.size() == 1)
    {
      return successes.get(0).getFirst();
    }
    else
    {
      final String username = getTargetUsername(operationContext,
           passwordModifyRequest);
      final String message = "The password modify operating targeting the " +
           "user with username '" + username + "' succeeded in multiple " +
           "backend sets but also failed in one or more sets.  This means " +
           "that the entry is inconsistent across the backend sets.";
      return generateEntryBalancingErrorResult(message,
           operationContext.getMessageID(), successes, failures);
    }
  }



  /**
   * Generates an error result for use in entry-balancing failures.
   *
   * @param  baseMessage  The base message to include in the result.
   * @param  messageID    The message ID for the result.
   * @param  successes    The list of successful backend set operations.
   * @param  failures     The list of failed backend set operations.
   *
   * @return  The generated entry-balancing error result.
   */
  private static ExtendedResult generateEntryBalancingErrorResult(
               final String baseMessage, final int messageID,
               final List<ObjectPair<ExtendedResult,BackendSet>> successes,
               final List<ObjectPair<ExtendedResult,BackendSet>> failures)
  {
    final StringBuilder message = new StringBuilder();
    message.append(baseMessage);
    message.append("  The operation succeeded in the following backend " +
         "sets:  ");

    final Iterator<ObjectPair<ExtendedResult,BackendSet>> sIterator =
         successes.iterator();
    while (sIterator.hasNext())
    {
      message.append('\'');
      message.append(sIterator.next().getSecond().getBackendSetID());
      message.append('\'');
      if (sIterator.hasNext())
      {
        message.append(", ");
      }
    }

    message.append(".  The operation failed in the following backend " +
         "sets:  ");

    final Iterator<ObjectPair<ExtendedResult,BackendSet>> fIterator =
         failures.iterator();
    while (fIterator.hasNext())
    {
      final ObjectPair<ExtendedResult,BackendSet> p = fIterator.next();
      message.append('\'');
      message.append(p.getSecond().getBackendSetID());
      message.append("' (result code ");
      message.append(p.getFirst().getResultCode().intValue());

      final String diagnosticMessage =
           p.getFirst().getDiagnosticMessage();
      if (diagnosticMessage != null)
      {
        message.append(", diagnosticMessage='");
        message.append(diagnosticMessage);
        message.append('\'');
      }
      message.append(')');

      if (fIterator.hasNext())
      {
        message.append(", ");
      }
    }
    message.append('.');

    return new ExtendedResult(messageID, ResultCode.OTHER, message.toString(),
         null, null, null, null, null);
  }



  /**
   * Indicates whether the provided extended request should be forwarded to one
   * of the servers associated with the provided proxying request processor.
   * Note that this method will not be invoked for proxying request processors
   * associated with an entry-balancing request processor.
   *
   * @param  operationContext  The operation context for the extended operation.
   * @param  request           The extended request to be processed.
   * @param  requestProcessor  The proxying request processor for which to
   *                           make the determination.
   *
   * @return  {@code true} if the extended request should be forwarded to one of
   *          the servers associated with the proxying request processor, or
   *          {@code false} if not.
   *
   * @throws  LDAPException  If the request should not be forwarded to a
   *                         backend server associated with the proxying request
   *                         processor, but the result contained in the
   *                         exception should be used instead.
   */
  @Override()
  public boolean shouldForward(final OperationContext operationContext,
                               final ExtendedRequest request,
                               final ProxyingRequestProcessor requestProcessor)
         throws LDAPException
  {
    // Extract the relevant details from the request.  Since this extended
    // operation handler could be invoked multiple times for the same request
    // (if the Directory Proxy Server is configured to forward multiple portions
    // of the DIT), we'll use operation attachments to avoid decoding things
    // multiple times.
    final PasswordModifyExtendedRequest passwordModifyRequest =
         getPasswordModifyRequest(operationContext, request);


    // Although a password modify request is allowed to not include a new
    // password (which indicates that the server should generate the new
    // password on behalf of the user), we won't allow that here.  The reason is
    // that there are cases in which the request might need to be forwarded to
    // multiple request processors and there's no way to have different servers
    // generate the same new password.  Technically we could allow this for
    // cases in which we know the target entry is below the balancing point, but
    // it's simpler to not allow this at all.
    if (passwordModifyRequest.getRawNewPassword() == null)
    {
      throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM,
           "The password modify proxied extended operation handler cannot be " +
                "used to proxy requests that do not specify an explicit new " +
                "password.");
    }


    // If the request specifies a target entry by DN, then see if it's within
    // the scope of this request processor.
    final DN userDN = getTargetUserDN(operationContext, passwordModifyRequest);
    if (userDN == null)
    {
      // This means that the target user was specified as a username rather than
      // a DN.  In this case, we'll always process the request in this
      // proxying request processor.
      return true;
    }
    else
    {
      // We'll only forward the request if the target user DN is below the
      // request processor base DN.
      return userDN.isDescendantOf(requestProcessor.getRequestProcessorBaseDN(),
           true);
    }
  }



  /**
   * Creates the final extended result to return to the client from the provided
   * list of results obtained from the set of entry-balanced and/or proxying
   * request processors to which the request was forwarded.
   *
   * @param  operationContext   The operation context for the extended
   *                            operation.
   * @param  request            The extended request that was processed.
   * @param  results            The results from all request processors to which
   *                            the request was forwarded.  It may be empty if
   *                            the request was not forwarded to any backend
   *                            servers, in which case this method must
   *                            construct an appropriate result.  It may have
   *                            only a single element if the request was only
   *                            forwarded to one server, and in many cases it
   *                            may be desirable to simply use that result as
   *                            the final result.  It may also have multiple
   *                            elements if the request was forwarded to
   *                            multiple backend servers, in which case this
   *                            method must determine whether to return one of
   *                            them to the client, or to construct a new result
   *                            to return instead.
   *
   * @return  The final extended result to be returned to the client.
   */
  @Override()
  public ExtendedResult createFinalResult(
                             final OperationContext operationContext,
                             final ExtendedRequest request,
                             final List<ExtendedResult> results)
  {
    // If there aren't any results, then that means we didn't process the
    // operation in any request processor.  That should only occur if the
    // request targeted an entry by DN and that DN was outside the base DNs of
    // all proxying or entry-balancing request processors.  In that case, return
    // a NO_SUCH_OBJECT result.
    if (results.isEmpty())
    {
      return new ExtendedResult(operationContext.getMessageID(),
           ResultCode.NO_SUCH_OBJECT,
           "The targeted entry does not exist within any proxied or " +
                "entry-balanced request processor.",
           null, null, null, null, null);
    }


    // Iterate over the results to figure out which (if any) to use.  Categorize
    // them as successes or failures.
    final ArrayList<ExtendedResult> successes =
         new ArrayList<ExtendedResult>(results.size());
    final ArrayList<ExtendedResult> failures =
         new ArrayList<ExtendedResult>(results.size());
    for (final ExtendedResult r : results)
    {
      if (r.getResultCode() == ResultCode.SUCCESS)
      {
        successes.add(r);
      }
      else
      {
        failures.add(r);
      }
    }


    // If there was only a single success, then return it.
    if (successes.size() == 1)
    {
      return successes.get(0);
    }


    // If there were multiple successes, then this is an error.  This should
    // only happen for cases in which the request targeted a user by username
    // and there were multiple entries in different portions of the DIT with
    // the same username.
    if (successes.size() >= 2)
    {
      String username = null;
      try
      {
        final PasswordModifyExtendedRequest passwordModifyRequest =
             getPasswordModifyRequest(operationContext, request);
        username = getTargetUsername(operationContext, passwordModifyRequest);
      }
      catch (final Exception e)
      {
        operationContext.getServerContext().debugCaught(e);
      }

      String message = "The password modify operation succeeded in multiple " +
           "request processors, which indicates that multiple entries " +
           "in different portions of the DIT matched the target user " +
           "identity and had their passwords updated.";
      if (username != null)
      {
        message += "  You will likely want to identify all entries with " +
             "username '" + username + "' that are accessible via " +
             "entry-balancing or proxying request processors and determine " +
             "whether any of them has been updated in error.";
      }

      return new ExtendedResult(operationContext.getMessageID(),
           ResultCode.OTHER, message, null, null, null, null, null);
    }


    // If we've gotten here, then there were only failures.  Iterate through
    // them and return the first failure we find with a result other than
    // NO_SUCH_OBJECT or INVALID_CREDENTIALS.  If there aren't any such results,
    // then return the first failure.
    for (final ExtendedResult r : failures)
    {
      switch (r.getResultCode().intValue())
      {
        case ResultCode.NO_SUCH_OBJECT_INT_VALUE:
        case ResultCode.INVALID_CREDENTIALS_INT_VALUE:
          break;
        default:
          return r;
      }
    }

    return failures.get(0);
  }



  /**
   * Retrieves the name of the attribute that should be used for username
   * lookups.
   *
   * @param  parser  The argument parser that should be used to make the
   *                 determination.
   *
   * @return  The name of the attribute that should be used for username
   *          lookups.
   */
  private static String getUsernameAttribute(final ArgumentParser parser)
  {
    final Argument a = parser.getNamedArgument(ARG_NAME_USERNAME_ATTRIBUTE);
    if ((a != null) && (a instanceof StringArgument))
    {
      return ((StringArgument) a).getValue();
    }

    return DEFAULT_USERNAME_ATTRIBUTE;
  }



  /**
   * Extracts a password modify extended request from the provided information.
   * If the request is cached in the operation state (because it has already
   * been decoded for this operation) then use it.  Otherwise, try to decode it
   * from the provided generic request.
   *
   * @param  operationContext  The operation context for the extended operation.
   * @param  request           The extended request to be processed.
   *
   * @return  The extracted password modify extended request.
   *
   * @throws  LDAPException  If it is not possible to decode the provided
   *                         extended request as a password modify extended
   *                         request.
   */
  private static PasswordModifyExtendedRequest getPasswordModifyRequest(
                      final OperationContext operationContext,
                      final ExtendedRequest request)
          throws LDAPException
  {
    // See if there is a cached version attached to the operation.
    final Object attachment = operationContext.getAttachment(
         ATTACHMENT_NAME_DECODED_REQUEST);
    if ((attachment != null) &&
        (attachment instanceof PasswordModifyExtendedRequest))
    {
      return (PasswordModifyExtendedRequest) attachment;
    }


    // Decode the provided extended request as a password modify request and
    // cache it in the operation state before returning it.
    final PasswordModifyExtendedRequest passwordModifyRequest =
         new PasswordModifyExtendedRequest(request.toLDAPSDKRequest());
    operationContext.setAttachment(ATTACHMENT_NAME_DECODED_REQUEST,
         passwordModifyRequest);
    return passwordModifyRequest;
  }



  /**
   * Extracts the DN of the target user from the provided information.  If it
   * has already been extracted, then we may find a cached version in the
   * operation state.
   *
   * @param  operationContext  The operation context for the extended operation.
   * @param  request           The extended request to be processed.
   *
   * @return  The extracted target user DN, or {@code null} if the user is
   *          identified by a username rather than a DN.
   *
   * @throws  LDAPException  If a problem is encountered while trying to decode
   *                         the target user DN.
   */
  private static DN getTargetUserDN(final OperationContext operationContext,
                                    final PasswordModifyExtendedRequest request)
          throws LDAPException
  {
    // See if there is a cached version attached to the operation, then use it.
    final Object attachment = operationContext.getAttachment(
         ATTACHMENT_NAME_DECODED_USER_DN);
    if ((attachment != null) && (attachment instanceof DN))
    {
      return (DN) attachment;
    }


    // Get the user identity from the request.  If there isn't a user identity,
    // then use the authorization DN for the operation.
    final String userIdentity = request.getUserIdentity();
    if (userIdentity == null)
    {
      final DN userDN = new DN(operationContext.getAuthorizationDN());
      operationContext.setAttachment(ATTACHMENT_NAME_DECODED_USER_DN, userDN);
      return userDN;
    }


    // If the user identity starts with "dn:", then try to decode the rest of
    // the string as a DN.
    final String lowerUserIdentity = StaticUtils.toLowerCase(userIdentity);
    if (lowerUserIdentity.startsWith("dn:"))
    {
      final DN userDN = new DN(userIdentity.substring(3));
      operationContext.setAttachment(ATTACHMENT_NAME_DECODED_USER_DN, userDN);
      return userDN;
    }


    // If the user identity starts with "u:", then the user is identified by
    // username rather than DN.
    if (lowerUserIdentity.startsWith("u:"))
    {
      final String username = userIdentity.substring(2);
      operationContext.setAttachment(ATTACHMENT_NAME_DECODED_USERNAME,
           username);
      return null;
    }


    // Try to decode the entire user identity string as a DN.
    final DN userDN = new DN(userIdentity);
    operationContext.setAttachment(ATTACHMENT_NAME_DECODED_USER_DN, userDN);
    return userDN;
  }



  /**
   * Extracts the username of the target user from the provided information.  If
   * it has already been extracted, then we may find a cached version in the
   * operation state.
   *
   * @param  operationContext  The operation context for the extended operation.
   * @param  request           The extended request to be processed.
   *
   * @return  The extracted target username, or {@code null} if the user is
   *          identified by a DN (or defaults to the user's authorization DN)
   *          rather than a username.
   */
  private static String getTargetUsername(
                             final OperationContext operationContext,
                             final PasswordModifyExtendedRequest request)
  {
    // See if there is a cached version attached to the operation, then use it.
    final Object attachment = operationContext.getAttachment(
         ATTACHMENT_NAME_DECODED_USERNAME);
    if ((attachment != null) && (attachment instanceof String))
    {
      return (String) attachment;
    }


    // Get the user identity from the request.  If there isn't a user identity,
    // then the request doesn't target a user by username.
    final String userIdentity = request.getUserIdentity();
    if (userIdentity == null)
    {
      return null;
    }


    // If the user identity starts with "u:", then the user is identified by
    // username rather than DN.
    final String lowerUserIdentity = StaticUtils.toLowerCase(userIdentity);
    if (lowerUserIdentity.startsWith("u:"))
    {
      final String username = userIdentity.substring(2);
      operationContext.setAttachment(ATTACHMENT_NAME_DECODED_USERNAME,
           username);
      return username;
    }


    // The request doesn't target a user by username.
    return null;
  }



  /**
   * {@inheritDoc}
   */
  @Override()
  public Map<List<String>,String> getExamplesArgumentSets()
  {
    final LinkedHashMap<List<String>,String> examples =
         new LinkedHashMap<List<String>,String>(1);

    examples.put(
         Arrays.asList(
              ARG_NAME_USERNAME_ATTRIBUTE + "=mail"),
         "Allow the password modify extended operation to be proxied to " +
              "backend servers, using the mail attribute to perform username " +
              "lookups.");

    return Collections.unmodifiableMap(examples);
  }
}