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

Valera V Harseko
23 hours ago de42bdce8ff329998ad4eef467503ea3a4fafd89
GHSA-6f6q-q9gw-4jr4 Sort and VLV controls let authenticated users learn password hashes and other attributes hidden by access control
6 files modified
3 files added
1881 ■■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java 15 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java 18 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java 8 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java 267 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java 45 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java 3 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java 706 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java 777 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java 42 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.api;
@@ -347,6 +348,10 @@
   * Indicates whether the provided operation search filter is allowed
   * based on the access control configuration. This method should not
   * alter the provided operation in any way.
   * <p>
   * A sorted search also calls it, with a presence filter on an attribute
   * type, to decide whether the values of that type may take part in the
   * sort key of an entry, which it may never return.
   *
   * @param operation
   *          The operation for which to make the determination.
@@ -371,6 +376,10 @@
   * Indicates whether the provided search result entry may be sent to
   * the client. Implementations <b>must not under any
   * circumstances</b> modify the search entry in any way.
   * <p>
   * A sorted search also calls it for every entry it sorts, to leave out
   * of the sort the entries the client may not see, including entries it
   * never returns.
   *
   * @param operation
   *          The operation currently being processed (this will
@@ -391,6 +400,12 @@
   * Filter the contents of the provided entry such that it no longer
   * contains any attributes or values that the client is not
   * permitted to access.
   * <p>
   * The unfiltered entry is expected to be the very object for which
   * {@link #maySend(Operation, SearchResultEntry)} has just returned
   * {@code true}. An implementation may reuse what it decided there
   * for that object only: any other object, such as a copy of the
   * entry or the entry read again, is checked without it.
   *
   * @param operation
   *          The operation currently being processed (this will
opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.authorization.dseecompat;
@@ -215,19 +216,18 @@
        }
        //If an ACI evaluated because of an Targetattr="*", then the
        //AciHandler.maySend method signaled this via adding this attachment
        //string.
        String allUserAttrs=
                  (String)operation.getAttachment(ALL_USER_ATTRS_MATCHED);
        if(allUserAttrs != null)
        //AciHandler.maySend method signaled this via adding this attachment,
        //holding the entry it evaluated: it only holds for that very entry.
        if (entry != null
            && operation.getAttachment(ALL_USER_ATTRS_MATCHED) == entry)
        {
          evalAllAttributes |= ACI_USER_ATTR_STAR_MATCHED;
        }
        //If an ACI evaluated because of an Targetattr="+", then the
        //AciHandler.maySend method signaled this via adding this attachment
        //string.
        String allOpAttrs=(String)operation.getAttachment(ALL_OP_ATTRS_MATCHED);
        if(allOpAttrs != null)
        //AciHandler.maySend method signaled this via adding this attachment,
        //holding the entry it evaluated: it only holds for that very entry.
        if (entry != null
            && operation.getAttachment(ALL_OP_ATTRS_MATCHED) == entry)
        {
          evalAllAttributes |= ACI_OP_ATTR_PLUS_MATCHED;
        }
opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java
@@ -80,12 +80,14 @@
  /**
   * String used to indicate that the evaluating ACI had a all
   * operational attributes targetattr match (targetattr="+").
   * The attachment's value is the entry it holds for.
   */
  static final String ALL_OP_ATTRS_MATCHED = "allOpAttrsMatched";
  /**
   * String used to indicate that the evaluating ACI had a all user
   * attributes targetattr match (targetattr="*").
   * The attachment's value is the entry it holds for.
   */
  static final String ALL_USER_ATTRS_MATCHED = "allUserAttrsMatched";
@@ -456,13 +458,15 @@
      return false;
    }
    // Each shortcut holds the very entry it was decided for, so that filterEntry() applies it to that entry alone
    // and to no other entry of the operation (GHSA-6f6q-q9gw-4jr4).
    if (!container.hasEvalUserAttributes())
    {
      operation.setAttachment(ALL_USER_ATTRS_MATCHED, ALL_USER_ATTRS_MATCHED);
      operation.setAttachment(ALL_USER_ATTRS_MATCHED, entry);
    }
    if (!container.hasEvalOpAttributes())
    {
      operation.setAttachment(ALL_OP_ATTRS_MATCHED, ALL_OP_ATTRS_MATCHED);
      operation.setAttachment(ALL_OP_ATTRS_MATCHED, entry);
    }
    return true;
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
@@ -33,10 +33,12 @@
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -51,6 +53,7 @@
import org.forgerock.opendj.config.server.ConfigurationAddListener;
import org.forgerock.opendj.config.server.ConfigurationChangeListener;
import org.forgerock.opendj.config.server.ConfigurationDeleteListener;
import org.forgerock.opendj.ldap.AttributeDescription;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
@@ -64,6 +67,7 @@
import org.forgerock.opendj.server.config.server.PluggableBackendCfg;
import org.forgerock.util.Pair;
import org.opends.messages.CoreMessages;
import org.opends.server.api.AccessControlHandler;
import org.opends.server.api.ClientConnection;
import org.opends.server.api.EntryCache;
import org.opends.server.api.VirtualAttributeProvider;
@@ -85,6 +89,7 @@
import org.opends.server.controls.SubtreeDeleteControl;
import org.opends.server.controls.VLVRequestControl;
import org.opends.server.controls.VLVResponseControl;
import org.opends.server.core.AccessControlConfigManager;
import org.opends.server.core.AddOperation;
import org.opends.server.core.DeleteOperation;
import org.opends.server.core.DirectoryServer;
@@ -103,6 +108,7 @@
import org.opends.server.types.Operation;
import org.opends.server.types.Privilege;
import org.opends.server.types.SearchFilter;
import org.opends.server.types.SearchResultEntry;
import org.opends.server.types.VirtualAttributeRule;
import org.opends.server.util.ServerConstants;
import org.opends.server.util.StaticUtils;
@@ -955,16 +961,45 @@
          EntryIDSet candidateEntryIDs = null;
          boolean candidatesAreInScope = false;
          final AccessControlHandler<?> aciHandler = sortRequest != null ? sortAccessControl(searchOperation) : null;
          boolean sortedByVLVIndex = false;
          if (sortRequest != null)
          {
            for (VLVIndex vlvIndex : vlvIndexMap.values())
            {
              try
              {
                candidateEntryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, vlvRequest, debugBuffer);
                if (aciHandler == null)
                {
                  candidateEntryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, vlvRequest, debugBuffer);
                  sortedByVLVIndex = candidateEntryIDs != null;
                  if (sortedByVLVIndex)
                  {
                    addServerSideSortControl(searchOperation, SUCCESS);
                  }
                }
                else if (vlvIndex.isApplicable(searchOperation, sortRequest))
                {
                  // A VLV index answers from its stored keys alone, so it cannot tell which entries and sort
                  // values the caller may read. For a caller subject to access control, only take the entries of
                  // the index from it, and let the sort below order them through the caller's access controls
                  // (GHSA-6f6q-q9gw-4jr4).
                  if (refuseOverEntryLimit(vlvIndex.getEntryCount(txn), searchOperation, vlvRequest))
                  {
                    return null;
                  }
                  candidateEntryIDs = getEntriesToSort(txn, vlvIndex, searchOperation, sortRequest, debugBuffer);
                  // The index may have grown since it was counted: the sort must not read more entries than may be
                  // examined. An index grown over the most entries read from it at once gives an undefined set, which
                  // no limit admits.
                  if (candidateEntryIDs != null
                      && refuseOverEntryLimit(candidateEntryIDs.size(), searchOperation, vlvRequest))
                  {
                    return null;
                  }
                }
                if (candidateEntryIDs != null)
                {
                  addServerSideSortControl(searchOperation, SUCCESS);
                  candidatesAreInScope = true;
                  break;
                }
@@ -1009,53 +1044,50 @@
                candidatesAreInScope = true;
              }
            }
          }
            if (sortRequest != null)
            {
              // If the sort key is not present, the sorting will generate the
              // default ordering. VLV search request goes through as if
              // this sort key was not found in the user entry.
              try
              {
                List<SortKey> sortKeys = sortRequest.getSortKeys();
                reorderedCandidateEntryIDs = sort(txn, candidateEntryIDs, searchOperation, sortKeys, vlvRequest);
              }
              catch (DirectoryException de)
              {
                reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
                serverSideSortControlError(searchOperation, sortRequest, de);
              }
              try
              {
                if (sortRequest.containsSortKeys())
                {
                  addServerSideSortControl(searchOperation, SUCCESS);
                }
                else
                {
                  /*
                   * There is no sort key associated with the sort control.
                   * Since it came here it means that the criticality is false
                   * so let the server return all search results unsorted and
                   * include the sortKeyResponseControl in the searchResultDone
                   * message.
                   */
                  addServerSideSortControl(searchOperation, NO_SUCH_ATTRIBUTE);
                }
              }
              catch (DirectoryException de)
              {
                serverSideSortControlError(searchOperation, sortRequest, de);
              }
            }
            else
            {
              reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
            }
          if (sortedByVLVIndex || sortRequest == null)
          {
            reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
          }
          else
          {
            reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
            // If the sort key is not present, the sorting will generate the
            // default ordering. VLV search request goes through as if
            // this sort key was not found in the user entry.
            try
            {
              List<SortKey> sortKeys = sortRequest.getSortKeys();
              reorderedCandidateEntryIDs =
                  sort(txn, candidateEntryIDs, searchOperation, sortKeys, vlvRequest, aciHandler);
            }
            catch (DirectoryException de)
            {
              reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
              serverSideSortControlError(searchOperation, sortRequest, de);
            }
            try
            {
              if (sortRequest.containsSortKeys())
              {
                addServerSideSortControl(searchOperation, SUCCESS);
              }
              else
              {
                /*
                 * There is no sort key associated with the sort control.
                 * Since it came here it means that the criticality is false
                 * so let the server return all search results unsorted and
                 * include the sortKeyResponseControl in the searchResultDone
                 * message.
                 */
                addServerSideSortControl(searchOperation, NO_SUCH_ATTRIBUTE);
              }
            }
            catch (DirectoryException de)
            {
              serverSideSortControlError(searchOperation, sortRequest, de);
            }
          }
          // If requested, construct and return a fictitious entry containing
@@ -1134,6 +1166,64 @@
          addPagedResultsControl(searchOperation, pageRequest, null);
        }
        /**
         * Refuses the search, and returns {@code true}, if the entries of a VLV index, as counted or as read, are more
         * than may be examined for a caller subject to access control: sorting them through its access controls reads
         * every one of them.
         */
        private boolean refuseOverEntryLimit(long entryCount, SearchOperation searchOperation,
            VLVRequestControl vlvRequest)
        {
          final int lookthroughLimit = searchOperation.getClientConnection().getLookthroughLimit();
          final int entryLimit =
              lookthroughLimit > 0 ? Math.min(lookthroughLimit, CURSOR_ENTRY_LIMIT) : CURSOR_ENTRY_LIMIT;
          if (entryCount <= entryLimit)
          {
            return false;
          }
          searchOperation.setResultCode(ResultCode.ADMIN_LIMIT_EXCEEDED);
          searchOperation.appendErrorMessage(NOTE_LOOKTHROUGH_LIMIT_EXCEEDED.get(entryLimit));
          addServerSideSortControl(searchOperation, ADMIN_LIMIT_EXCEEDED);
          if (vlvRequest != null)
          {
            addVLVResponseControl(searchOperation, 0, 0, ADMIN_LIMIT_EXCEEDED);
          }
          return true;
        }
        /**
         * Returns the entries of a VLV index, in entry ID order, for the sort of a caller subject to access control.
         * An index grown over the most entries read from it at once since it was checked gives an undefined set,
         * which the caller refuses as it refuses an index already counting that many. An index that gives no entries
         * otherwise, having stopped being applicable or no longer holding the entries it counts, gives {@code null}:
         * the search then goes on as without it.
         */
        private EntryIDSet getEntriesToSort(ReadableTransaction txn, VLVIndex vlvIndex,
            SearchOperation searchOperation, ServerSideSortRequestControl sortRequest, StringBuilder debugBuffer)
            throws DirectoryException
        {
          // Without a VLV request, the index gives all its entries, but nothing when it holds none.
          final EntryIDSet entryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, null, debugBuffer);
          if (entryIDs == null)
          {
            if (!vlvIndex.isApplicable(searchOperation, sortRequest))
            {
              return null;
            }
            final int entryCount = vlvIndex.getEntryCount(txn);
            if (entryCount == 0)
            {
              return EntryIDSet.newDefinedSet();
            }
            return entryCount > CURSOR_ENTRY_LIMIT ? EntryIDSet.newUndefinedSet() : null;
          }
          // They come in the order of the stored keys, which the caller may not read: no road below, the fallback
          // after a sort error included, may hand them out in that order.
          final long[] ids = entryIDs.toLongArray();
          Arrays.sort(ids);
          return EntryIDSet.newDefinedSet(ids);
        }
        private void serverSideSortControlError(final SearchOperation searchOperation,
            ServerSideSortRequestControl sortRequest, DirectoryException de) throws DirectoryException
        {
@@ -2788,8 +2878,17 @@
    return baseEntry;
  }
  /**
   * Sorts the candidate entries of a search, then applies its VLV request if any.
   * <p>
   * Unless {@code aciHandler} is {@code null}, the sort goes through the caller's access controls: an entry the
   * caller may not see takes no part in it, and a sort attribute the caller may not read on an entry sorts as if
   * the entry had no value for it, so that neither the order nor the VLV target position or content count disclose
   * them (GHSA-6f6q-q9gw-4jr4).
   */
  private long[] sort(ReadableTransaction txn, EntryIDSet entryIDSet, SearchOperation searchOperation,
      List<SortKey> sortKeys, VLVRequestControl vlvRequest) throws DirectoryException
      List<SortKey> sortKeys, VLVRequestControl vlvRequest, AccessControlHandler<?> aciHandler)
      throws DirectoryException
  {
    if (!entryIDSet.isDefined())
    {
@@ -2806,9 +2905,14 @@
      try
      {
        Entry e = getEntry(txn, id);
        if (e.matchesBaseAndScope(baseDN, scope) && filter.matchesEntry(e))
        // The entry may have been deleted since the candidates were read, as searchIndexed() also expects.
        if (e != null && e.matchesBaseAndScope(baseDN, scope) && filter.matchesEntry(e))
        {
          sortMap.put(encodeVLVKey(sortKeys, e, id.longValue()), id);
          final ByteString vlvKey = encodeVisibleVLVKey(searchOperation, aciHandler, sortKeys, e, id.longValue());
          if (vlvKey != null)
          {
            sortMap.put(vlvKey, id);
          }
        }
      }
      catch (Exception e)
@@ -2832,6 +2936,73 @@
    return sortByGreaterThanOrEqualAssertion(searchOperation, vlvRequest, sortKeys, sortMap);
  }
  /**
   * Returns the access controls the sort of a search must go through, or {@code null} if they do not apply to it:
   * access control is disabled, or the caller holds the bypass-acl privilege, as internal operations do.
   */
  private static AccessControlHandler<?> sortAccessControl(SearchOperation searchOperation)
  {
    final AccessControlConfigManager accessControl = AccessControlConfigManager.getInstance();
    if (!accessControl.isAccessControlEnabled()
        || searchOperation.getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, searchOperation))
    {
      return null;
    }
    return accessControl.getAccessControlHandler();
  }
  /**
   * Returns the VLV key of an entry as the caller may see it, or {@code null} if the caller may not see the entry.
   * <p>
   * Without access controls, the key is that of the entry. Otherwise the entry must pass the check a returned entry
   * passes, and the values of the attribute types the caller may not read on it are left out of the key. The entry
   * is the one read from the backend, with its virtual attributes, as a returned entry is.
   */
  private static ByteString encodeVisibleVLVKey(SearchOperation searchOperation, AccessControlHandler<?> aciHandler,
      List<SortKey> sortKeys, Entry entry, long entryID)
  {
    if (aciHandler == null)
    {
      return encodeVLVKey(sortKeys, entry, entryID);
    }
    if (!aciHandler.maySend(searchOperation, new SearchResultEntry(entry)))
    {
      return null;
    }
    // A sort key takes its values from its attribute type and from the subtypes of it on the entry, and access
    // control applies to each of these types on its own.
    final Set<AttributeType> checkedTypes = new HashSet<>();
    final Set<AttributeType> unreadableTypes = new HashSet<>();
    for (SortKey sortKey : sortKeys)
    {
      final AttributeType sortType = AttributeDescription.valueOf(sortKey.getAttributeDescription()).getAttributeType();
      for (Attribute attribute : entry.getAllAttributes(sortType))
      {
        final AttributeType type = attribute.getAttributeDescription().getAttributeType();
        if (checkedTypes.add(type) && !mayRead(searchOperation, aciHandler, entry, type))
        {
          unreadableTypes.add(type);
        }
      }
    }
    return encodeVLVKey(sortKeys, entry, entryID, unreadableTypes);
  }
  /** Indicates whether the caller may read an attribute type on an entry, checked as the assertion control does. */
  private static boolean mayRead(SearchOperation searchOperation, AccessControlHandler<?> aciHandler, Entry entry,
      AttributeType attributeType)
  {
    try
    {
      return aciHandler.isAllowed(searchOperation, entry, SearchFilter.createPresenceFilter(attributeType));
    }
    catch (DirectoryException e)
    {
      logger.traceException(e);
      return false;
    }
  }
  private static final long[] toArray(Collection<EntryID> entryIDs)
  {
    final long[] array = new long[entryIDs.size()];
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
@@ -31,6 +31,7 @@
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.forgerock.i18n.LocalizableMessage;
@@ -588,15 +589,22 @@
    return i.hasNext() ? i.next() : null;
  }
  /** Indicates whether this index holds the entries of the provided search, ordered by its sort keys. */
  boolean isApplicable(final SearchOperation searchOperation, final ServerSideSortRequestControl sortControl)
      throws DirectoryException
  {
    return trusted
        && searchOperation.getBaseDN().equals(baseDN)
        && searchOperation.getScope().equals(scope)
        && searchOperation.getFilter().equals(filter)
        && sortControl.getSortKeys().equals(sortKeys);
  }
  EntryIDSet evaluate(final ReadableTransaction txn, final SearchOperation searchOperation,
      final ServerSideSortRequestControl sortControl, final VLVRequestControl vlvRequest,
      final StringBuilder debugBuilder) throws DirectoryException, StorageRuntimeException
  {
    if (!trusted ||
        !searchOperation.getBaseDN().equals(baseDN) ||
        !searchOperation.getScope().equals(scope) ||
        !searchOperation.getFilter().equals(filter) ||
        !sortControl.getSortKeys().equals(sortKeys))
    if (!isApplicable(searchOperation, sortControl))
    {
      return null;
    }
@@ -638,7 +646,7 @@
  }
  /** Returns the total number of entries (a.k.a records, a.k.a keys) indexed by this VLV index. */
  private int getEntryCount(final ReadableTransaction txn)
  int getEntryCount(final ReadableTransaction txn)
  {
    return (int) counter.getCount(txn, COUNT_KEY);
  }
@@ -899,17 +907,28 @@
  static ByteString encodeVLVKey(final List<SortKey> sortKeys, final Entry entry, final long entryID)
  {
    return encodeVLVKey(sortKeys, entry, entryID, Collections.<AttributeType> emptySet());
  }
  /**
   * Encodes the VLV key of an entry, leaving out the values of the attribute types in {@code unreadableTypes}, as
   * if the entry did not have them.
   */
  static ByteString encodeVLVKey(final List<SortKey> sortKeys, final Entry entry, final long entryID,
      final Set<AttributeType> unreadableTypes)
  {
    final ByteStringBuilder builder = new ByteStringBuilder();
    encodeVLVKey0(sortKeys, entry, builder);
    encodeVLVKey0(sortKeys, entry, unreadableTypes, builder);
    builder.appendLong(entryID);
    return builder.toByteString();
  }
  private static void encodeVLVKey0(final List<SortKey> sortKeys, final Entry entry, final ByteStringBuilder builder)
  private static void encodeVLVKey0(final List<SortKey> sortKeys, final Entry entry,
      final Set<AttributeType> unreadableTypes, final ByteStringBuilder builder)
  {
    for (final SortKey sortKey : sortKeys)
    {
      ByteString sortValue = getLowestAttributeValue(entry, sortKey);
      ByteString sortValue = getLowestAttributeValue(entry, sortKey, unreadableTypes);
      encodeVLVKeyValue(sortValue, builder, sortKey.isReverseOrder());
    }
  }
@@ -918,13 +937,19 @@
   * The RFC states that the lowest value of a multi-valued attribute should be used,
   * regardless of the sort order.
   */
  private static ByteString getLowestAttributeValue(final Entry entry, final SortKey sortKey)
  private static ByteString getLowestAttributeValue(final Entry entry, final SortKey sortKey,
      final Set<AttributeType> unreadableTypes)
  {
    final AttributeDescription attrDesc = AttributeDescription.valueOf(sortKey.getAttributeDescription());
    final MatchingRule matchingRule = getEffectiveOrderingRule(sortKey);
    ByteString sortValue = null;
    // The values may come from subtypes of the sort attribute, each of which may be unreadable on its own.
    for (Attribute a : entry.getAllAttributes(attrDesc.getAttributeType()))
    {
      if (unreadableTypes.contains(a.getAttributeDescription().getAttributeType()))
      {
        continue;
      }
      for (ByteString v : a)
      {
        try
opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java
@@ -99,7 +99,8 @@
   */
  public boolean isAccessControlEnabled()
  {
    return currentConfiguration.isEnabled();
    // Until initializeAccessControl() has run, getAccessControlHandler() permits everything.
    return currentConfiguration != null && currentConfiguration.isEnabled();
  }
  /**
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java
New file
@@ -0,0 +1,706 @@
/*
 * 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.backends.pluggable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.opends.server.backends.pluggable.IndexFilter.CURSOR_ENTRY_LIMIT;
import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection;
import static org.opends.server.protocols.internal.Requests.newSearchRequest;
import static org.opends.server.protocols.ldap.LDAPResultCode.SUCCESS;
import static org.opends.server.util.CollectionUtils.newTreeSet;
import static org.opends.server.util.ServerConstants.OID_SERVER_SIDE_SORT_RESPONSE_CONTROL;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.forgerock.opendj.config.server.ConfigException;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType;
import org.forgerock.opendj.server.config.meta.BackendVLVIndexCfgDefn.Scope;
import org.forgerock.opendj.server.config.server.BackendIndexCfg;
import org.forgerock.opendj.server.config.server.BackendVLVIndexCfg;
import org.forgerock.opendj.server.config.server.PDBBackendCfg;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
import org.opends.server.api.ClientConnection;
import org.opends.server.backends.pdb.PDBStorage;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.Importer;
import org.opends.server.backends.pluggable.spi.ReadOperation;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.Storage;
import org.opends.server.backends.pluggable.spi.StorageStatus;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteOperation;
import org.opends.server.controls.ServerSideSortRequestControl;
import org.opends.server.controls.ServerSideSortResponseControl;
import org.opends.server.controls.VLVRequestControl;
import org.opends.server.core.AddOperation;
import org.opends.server.core.ServerContext;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.protocols.internal.InternalSearchOperation;
import org.opends.server.protocols.internal.SearchRequest;
import org.opends.server.types.AuthenticationInfo;
import org.opends.server.types.BackupConfig;
import org.opends.server.types.BackupDirectory;
import org.opends.server.types.Control;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.RestoreConfig;
import org.opends.server.types.SearchResultEntry;
import org.opends.server.workflowelement.localbackend.LocalBackendSearchOperation;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
/**
 * Regression tests for GHSA-6f6q-q9gw-4jr4: a sorted search of a caller subject to access control
 * takes the entries of a VLV index, then reads and sorts them through the caller's access controls.
 * The JE backend reads without a snapshot, so what the storage holds may change between those reads:
 * an entry may be deleted, and the index may stop being applicable or grow. The search must then
 * leave the entry out, or go on as without an index no longer applicable, rather than fail or come
 * back empty. An index that counts more than the most entries read from an index at once is refused,
 * whether it did when checked or grew over it since, and so is one that grows over the caller's
 * lookthrough limit between its count and the read of its entries. An index that applies and holds
 * no entry gives none.
 * <p>
 * The tests run on PDB, whose reads see a snapshot: the storage below makes each change land right
 * after the read it must follow, and answers the reads of the search after it as the JE backend
 * would.
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public class VLVSortConcurrentChangeTest extends DirectoryServerTestCase
{
  private static final String BACKEND_ID = "VLVSortConcurrentChangeTest";
  private static final DN BASE_DN = DN.valueOf("dc=vlvsort,dc=com");
  private static final DN PEOPLE_DN = DN.valueOf("ou=people,dc=vlvsort,dc=com");
  /** Held in lower case, which is how an entry container keys the VLV indexes it holds. */
  private static final String VLV_INDEX_NAME = "people-by-sn";
  /** Added in this order, other than the order of their "sn" values: alice, bob, charlie. */
  private static final DN CHARLIE_DN = DN.valueOf("uid=charlie,ou=people,dc=vlvsort,dc=com");
  private static final DN ALICE_DN = DN.valueOf("uid=alice,ou=people,dc=vlvsort,dc=com");
  private static final DN BOB_DN = DN.valueOf("uid=bob,ou=people,dc=vlvsort,dc=com");
  /** Added only by the test that needs a fourth person. */
  private static final DN DAVE_DN = DN.valueOf("uid=dave,ou=people,dc=vlvsort,dc=com");
  private ServerContext serverContext;
  /** A user without the bypass-acl privilege, whom the global ACIs let read every entry and "sn". */
  private InternalClientConnection actorConn;
  private SteeringBackend backend;
  private EntryContainer entryContainer;
  private VLVIndex vlvIndex;
  @BeforeClass
  public void startServer() throws Exception
  {
    TestCaseUtils.startServer();
    serverContext = TestCaseUtils.getServerContext();
    actorConn = new InternalClientConnection(new AuthenticationInfo(TestCaseUtils.makeEntry(
        "dn: uid=actor,dc=vlvsort,dc=com",
        "objectClass: top",
        "objectClass: person",
        "sn: Or",
        "cn: Act Or"), false));
  }
  @BeforeMethod
  public void openBackend() throws Exception
  {
    backend = new SteeringBackend();
    backend.setBackendID(BACKEND_ID);
    backend.configureBackend(backendCfg(), serverContext);
    // Start from a pristine on-disk state, whatever the previous test changed in it.
    backend.storage.removeStorageFiles();
    backend.openBackend();
    final List<Entry> entries = TestCaseUtils.makeEntries(
        "dn: " + BASE_DN,
        "objectClass: top",
        "objectClass: domain",
        "dc: vlvsort",
        "",
        "dn: " + PEOPLE_DN,
        "objectClass: top",
        "objectClass: organizationalUnit",
        "ou: people",
        "",
        "dn: " + CHARLIE_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: charlie",
        "sn: Xavier",
        "cn: Charlie Xavier",
        "",
        "dn: " + ALICE_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: alice",
        "sn: Baker",
        "cn: Alice Baker",
        "",
        "dn: " + BOB_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: bob",
        "sn: Monroe",
        "cn: Bob Monroe");
    for (Entry entry : entries)
    {
      backend.addEntry(entry, mock(AddOperation.class));
    }
    entryContainer = backend.getRootContainer().getEntryContainer(BASE_DN);
    vlvIndex = entryContainer.getVLVIndex(VLV_INDEX_NAME);
    // Created with the backend empty, so trusted: otherwise no search would take it.
    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted").isTrue();
  }
  @AfterMethod(alwaysRun = true)
  public void closeBackend()
  {
    if (backend != null && backend.getRootContainer() != null)
    {
      backend.finalizeBackend();
    }
    backend = null;
    try
    {
      // What a test that failed while the backend opened may have left in the server wide registry.
      serverContext.getBackendConfigManager().deregisterBaseDN(BASE_DN);
    }
    catch (Exception alreadyGone)
    {
      // Which is what closing the backend does.
    }
  }
  /**
   * An entry deleted once the VLV index gave the candidates, and before the sort reads it, takes no
   * part in the sort: the search must not fail on it.
   */
  @Test
  public void sortOverVLVIndexLeavesOutEntryDeletedSinceItsCandidatesWereRead() throws Exception
  {
    backend.storage.deleteForTheNextReads(entryContainer.getID2Entry().getName(), entryIDKey(BOB_DN));
    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL)).containsExactly(ALICE_DN, CHARLIE_DN);
    assertThat(backend.storage.readsOfDeleted).as("reads of the deleted entry").isPositive();
  }
  /**
   * The same on the road of the attribute indexes, which every caller takes when no VLV index serves
   * the search.
   */
  @Test
  public void sortOverAttributeIndexLeavesOutEntryDeletedSinceItsCandidatesWereRead() throws Exception
  {
    backend.storage.deleteForTheNextReads(entryContainer.getID2Entry().getName(), entryIDKey(BOB_DN));
    assertThat(sortedPeople(getRootConnection(), BASE_DN, SearchScope.WHOLE_SUBTREE))
        .containsExactly(ALICE_DN, CHARLIE_DN);
    assertThat(backend.storage.readsOfDeleted).as("reads of the deleted entry").isPositive();
  }
  /**
   * A change of the definition of the VLV index between the check that it serves the search and the
   * read of its entries makes it no longer applicable: the search must go on as without it.
   */
  @Test
  public void sortGoesOnWithoutVLVIndexRedefinedSinceItWasChecked() throws Exception
  {
    backend.storage.afterFirstRead(counterTreeName(),
        () -> concurrently(() -> vlvIndex.applyConfigurationChange(vlvIndexCfg("-sn"))));
    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted after the change of its definition").isFalse();
  }
  /**
   * A rebuild of the VLV index starting between the check and the read of its entries leaves it
   * untrusted and, for a read of the JE backend, empty: the search must go on as without it rather
   * than take the empty index for an empty result.
   */
  @Test
  public void sortGoesOnWithoutVLVIndexWhoseRebuildStartedSinceItWasChecked() throws Exception
  {
    final TreeName deleted = emptyTree("vlv." + VLV_INDEX_NAME + ".deleted");
    backend.storage.afterFirstRead(counterTreeName(), () ->
    {
      concurrently(() ->
      {
        backend.getRootContainer().getStorage().write(txn -> vlvIndex.setTrusted(txn, false));
        return null;
      });
      backend.storage.answer(vlvIndex.getName(), deleted);
      backend.storage.answer(counterTreeName(), deleted);
    });
    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted once its rebuild started").isFalse();
  }
  /**
   * The VLV index growing over the most entries read from an index at once between its count and the
   * read of its entries makes it give none: the search must be refused, as it is when the index
   * already counts that many, rather than go on as without it and sort as many entries from the
   * attribute indexes.
   */
  @Test
  public void sortOverVLVIndexGrownOverTheEntryLimitSinceItWasCountedIsRefused() throws Exception
  {
    final TreeName grown = counterHolding("counter.vlv." + VLV_INDEX_NAME + ".grown", CURSOR_ENTRY_LIMIT + 1);
    backend.storage.afterFirstRead(counterTreeName(), () -> backend.storage.answer(counterTreeName(), grown));
    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
    assertThat(search.getSearchEntries()).isEmpty();
    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
  }
  /**
   * A VLV index that stops being applicable is left aside whatever it counts: redefined between the
   * check and the read of its entries while it also grows over the most entries read from an index at
   * once, the search must still go on as without it rather than be refused.
   */
  @Test
  public void sortGoesOnWithoutVLVIndexRedefinedSinceItWasCheckedThoughItGrewOverTheEntryLimit() throws Exception
  {
    final TreeName grown = counterHolding("counter.vlv." + VLV_INDEX_NAME + ".grown", CURSOR_ENTRY_LIMIT + 1);
    backend.storage.afterFirstRead(counterTreeName(), () ->
    {
      concurrently(() -> vlvIndex.applyConfigurationChange(vlvIndexCfg("-sn")));
      backend.storage.answer(counterTreeName(), grown);
    });
    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
  }
  /**
   * An applicable VLV index whose tree gives none of the entries its count still holds, a state no
   * read should see, is neither refused nor taken for empty: the search must go on as without it.
   */
  @Test
  public void sortGoesOnWithoutVLVIndexHoldingNoneOfTheEntriesItCounts() throws Exception
  {
    backend.storage.answer(vlvIndex.getName(), emptyTree("vlv." + VLV_INDEX_NAME + ".emptied"));
    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
  }
  /**
   * Sorting the entries of a VLV index through the caller's access controls reads every one of them:
   * a caller without a lookthrough limit, as an internal one, may still not read more than the most
   * entries read from an index at once, so an index counting more is refused.
   */
  @Test
  public void sortOverVLVIndexCountingOverTheEntryLimitIsRefused() throws Exception
  {
    backend.storage.answer(counterTreeName(),
        counterHolding("counter.vlv." + VLV_INDEX_NAME + ".over", CURSOR_ENTRY_LIMIT + 1));
    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
    assertThat(search.getSearchEntries()).isEmpty();
  }
  /**
   * The VLV index growing between its count, checked against the caller's lookthrough limit, and the
   * read of its entries leaves it holding more entries than the caller may examine: the search must
   * be refused rather than sort them all, even though the VLV page it asks for is smaller.
   */
  @Test
  public void sortOverVLVIndexGrownOverTheLookthroughLimitSinceItWasCountedIsRefused() throws Exception
  {
    // The index holds a fourth person, as if added right after its count read three.
    backend.addEntry(TestCaseUtils.makeEntry(
        "dn: " + DAVE_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: dave",
        "sn: Delaney",
        "cn: Dave Delaney"), mock(AddOperation.class));
    backend.storage.answer(counterTreeName(), counterHolding("counter.vlv." + VLV_INDEX_NAME + ".before", 3));
    backend.storage.afterFirstRead(counterTreeName(),
        () -> backend.storage.answer(counterTreeName(), counterTreeName()));
    final InternalSearchOperation search = searchPeople(
        actorLimitedTo(3), PEOPLE_DN, SearchScope.SINGLE_LEVEL, new VLVRequestControl(0, 0, 1, 0));
    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
    assertThat(search.getSearchEntries()).isEmpty();
    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
  }
  /**
   * An applicable VLV index holding no entry answers the search with none, sorted: the search must
   * neither go on as without it, through the attribute indexes, which here still hold the people, nor
   * be refused.
   */
  @Test
  public void sortOverEmptyVLVIndexGivesNoEntry() throws Exception
  {
    final TreeName empty = emptyTree("vlv." + VLV_INDEX_NAME + ".empty");
    backend.storage.answer(vlvIndex.getName(), empty);
    backend.storage.answer(counterTreeName(), empty);
    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
    assertThat(search.getSearchEntries()).isEmpty();
    assertThat(sortResultCode(search)).as("result code of the sort response control").isEqualTo(SUCCESS);
  }
  /** The actor, held to the provided lookthrough limit, which an internal connection never holds otherwise. */
  private InternalClientConnection actorLimitedTo(int lookthroughLimit) throws Exception
  {
    final InternalClientConnection conn = new InternalClientConnection(actorConn.getAuthenticationInfo());
    // An internal connection ignores the setter of the limit, which its connection keeps in a field of its own.
    final Field lookthroughLimitField = ClientConnection.class.getDeclaredField("lookthroughLimit");
    lookthroughLimitField.setAccessible(true);
    lookthroughLimitField.setInt(conn, lookthroughLimit);
    return conn;
  }
  /** Sorts the people on "sn", with a critical sort control and the provided controls, straight through the backend. */
  private InternalSearchOperation searchPeople(InternalClientConnection conn, DN baseDN, SearchScope scope,
      Control... controls) throws Exception
  {
    final SearchRequest request =
        newSearchRequest(baseDN, scope, "(sn=*)").addControl(new ServerSideSortRequestControl(true, "sn"));
    for (Control control : controls)
    {
      request.addControl(control);
    }
    final InternalSearchOperation search = new InternalSearchOperation(conn, -1, -1, request);
    backend.search(new LocalBackendSearchOperation(search));
    return search;
  }
  private List<DN> sortedPeople(InternalClientConnection conn, DN baseDN, SearchScope scope) throws Exception
  {
    final List<DN> dns = new ArrayList<>();
    for (SearchResultEntry entry : searchPeople(conn, baseDN, scope).getSearchEntries())
    {
      dns.add(entry.getName());
    }
    return dns;
  }
  /** The result code of the one sort response control of the search. */
  private static int sortResultCode(InternalSearchOperation search)
  {
    final List<Integer> resultCodes = new ArrayList<>();
    for (Control control : search.getResponseControls())
    {
      if (OID_SERVER_SIDE_SORT_RESPONSE_CONTROL.equals(control.getOID()))
      {
        resultCodes.add(((ServerSideSortResponseControl) control).getResultCode());
      }
    }
    assertThat(resultCodes).as("result codes of the sort response controls").hasSize(1);
    return resultCodes.get(0);
  }
  private ByteString entryIDKey(DN dn) throws Exception
  {
    return backend.getRootContainer().getStorage().read(txn -> entryContainer.getDN2ID().get(txn, dn)).toByteString();
  }
  /** The tree the VLV index counts its entries in, named as the VLV index names it. */
  private TreeName counterTreeName()
  {
    return new TreeName(entryContainer.getTreePrefix(), "counter.vlv." + VLV_INDEX_NAME);
  }
  private TreeName emptyTree(String name) throws Exception
  {
    final TreeName tree = new TreeName(entryContainer.getTreePrefix(), name);
    backend.getRootContainer().getStorage().write(txn -> txn.openTree(tree, true));
    return tree;
  }
  /** A tree counting entries as the VLV index counts its own, holding the provided count. */
  private TreeName counterHolding(String name, long count) throws Exception
  {
    final TreeName tree = emptyTree(name);
    // The key the VLV index keeps its count under is its own.
    final Field countKeyField = VLVIndex.class.getDeclaredField("COUNT_KEY");
    countKeyField.setAccessible(true);
    final ByteString countKey = (ByteString) countKeyField.get(null);
    final ShardedCounter counter = new ShardedCounter(tree);
    backend.getRootContainer().getStorage().write(txn -> counter.addCount(txn, countKey, count));
    return tree;
  }
  /** Makes a change from another thread, as a concurrent operation does, and waits until it is done. */
  private static void concurrently(Callable<?> change)
  {
    final FutureTask<?> task = new FutureTask<>(change);
    new Thread(task, "concurrent change").start();
    try
    {
      task.get(30, TimeUnit.SECONDS);
    }
    catch (Exception e)
    {
      throw new AssertionError("the concurrent change failed", e);
    }
  }
  private PDBBackendCfg backendCfg() throws ConfigException
  {
    final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class);
    when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"));
    when(cfg.getBackendId()).thenReturn(BACKEND_ID);
    when(cfg.getDBDirectory()).thenReturn(BACKEND_ID);
    when(cfg.getDBDirectoryPermissions()).thenReturn("755");
    when(cfg.getDBCacheSize()).thenReturn(0L);
    when(cfg.getDBCachePercent()).thenReturn(20);
    when(cfg.getBaseDN()).thenReturn(newTreeSet(BASE_DN));
    // Built before they are handed over: stubbing a mock from inside a when() of another mock leaves
    // that when() unfinished.
    final BackendIndexCfg snIndexCfg = snPresenceIndexCfg();
    final BackendVLVIndexCfg vlvIndexCfg = vlvIndexCfg("sn");
    // The index a search goes on with, once it goes on without the VLV index.
    when(cfg.listBackendIndexes()).thenReturn(new String[] { "sn" });
    when(cfg.getBackendIndex("sn")).thenReturn(snIndexCfg);
    when(cfg.listBackendVLVIndexes()).thenReturn(new String[] { VLV_INDEX_NAME });
    when(cfg.getBackendVLVIndex(VLV_INDEX_NAME)).thenReturn(vlvIndexCfg);
    return cfg;
  }
  private BackendIndexCfg snPresenceIndexCfg()
  {
    final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
    when(cfg.getIndexType()).thenReturn(newTreeSet(IndexType.PRESENCE));
    when(cfg.getAttribute()).thenReturn(serverContext.getSchema().getAttributeType("sn"));
    when(cfg.getIndexEntryLimit()).thenReturn(4000);
    when(cfg.getSubstringLength()).thenReturn(6);
    return cfg;
  }
  /** The people sorted on the provided order, which a search of them sorted on "sn" matches as "sn". */
  private BackendVLVIndexCfg vlvIndexCfg(String sortOrder)
  {
    final BackendVLVIndexCfg cfg = mock(BackendVLVIndexCfg.class);
    when(cfg.getName()).thenReturn(VLV_INDEX_NAME);
    when(cfg.getBaseDN()).thenReturn(PEOPLE_DN);
    when(cfg.getScope()).thenReturn(Scope.SINGLE_LEVEL);
    when(cfg.getFilter()).thenReturn("(sn=*)");
    when(cfg.getSortOrder()).thenReturn(sortOrder);
    return cfg;
  }
  /** A PDB backend whose storage is a {@link SteeringStorage}. */
  private static final class SteeringBackend extends BackendImpl<PDBBackendCfg>
  {
    private SteeringStorage storage;
    @Override
    protected Storage configureStorage(PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException
    {
      storage = new SteeringStorage(new PDBStorage(cfg, serverContext));
      return storage;
    }
  }
  /**
   * Decorates a {@link Storage} so that its reads see what a concurrent operation changed after the
   * read began, as reads of the JE backend do: a record deleted, a tree left in another state, which
   * the reads then get from another tree holding that state. A change may also be set to land right
   * after the first cursor a read opens on a given tree.
   */
  private static final class SteeringStorage implements Storage
  {
    private final Storage delegate;
    private final Map<TreeName, TreeName> answeredFrom = new HashMap<>();
    private TreeName deletedFrom;
    private ByteString deletedKey;
    private int readsOfDeleted;
    private TreeName changedAfter;
    private Runnable change;
    private int changes;
    SteeringStorage(Storage delegate)
    {
      this.delegate = delegate;
    }
    void deleteForTheNextReads(TreeName treeName, ByteString key)
    {
      deletedFrom = treeName;
      deletedKey = key;
    }
    /** Answers the reads of a tree from now on from another tree, which holds the state it was left in. */
    void answer(TreeName treeName, TreeName from)
    {
      answeredFrom.put(treeName, from);
    }
    void afterFirstRead(TreeName treeName, Runnable change)
    {
      this.changedAfter = treeName;
      this.change = change;
    }
    private TreeName answering(TreeName treeName)
    {
      final TreeName from = answeredFrom.get(treeName);
      return from != null ? from : treeName;
    }
    @Override
    public <T> T read(final ReadOperation<T> readOperation) throws Exception
    {
      return delegate.read(txn -> readOperation.run(new ReadableTransaction()
      {
        @Override
        public ByteString read(TreeName treeName, ByteSequence key)
        {
          if (treeName.equals(deletedFrom) && deletedKey.equals(key))
          {
            readsOfDeleted++;
            return null;
          }
          return txn.read(answering(treeName), key);
        }
        @Override
        public Cursor<ByteString, ByteString> openCursor(TreeName treeName)
        {
          final Cursor<ByteString, ByteString> cursor = txn.openCursor(answering(treeName));
          if (change != null && treeName.equals(changedAfter))
          {
            final Runnable landing = change;
            change = null;
            changes++;
            landing.run();
          }
          return cursor;
        }
        @Override
        public long getRecordCount(TreeName treeName)
        {
          return txn.getRecordCount(answering(treeName));
        }
        @Override
        public boolean treeExists(TreeName treeName)
        {
          return txn.treeExists(answering(treeName));
        }
      }));
    }
    @Override
    public void write(WriteOperation writeOperation) throws Exception
    {
      delegate.write(writeOperation);
    }
    @Override
    public Importer startImport() throws ConfigException
    {
      return delegate.startImport();
    }
    @Override
    public void open(AccessMode accessMode) throws Exception
    {
      delegate.open(accessMode);
    }
    @Override
    public void removeStorageFiles()
    {
      delegate.removeStorageFiles();
    }
    @Override
    public StorageStatus getStorageStatus()
    {
      return delegate.getStorageStatus();
    }
    @Override
    public boolean supportsBackupAndRestore()
    {
      return delegate.supportsBackupAndRestore();
    }
    @Override
    public void createBackup(BackupConfig backupConfig) throws DirectoryException
    {
      delegate.createBackup(backupConfig);
    }
    @Override
    public void removeBackup(BackupDirectory backupDirectory, String backupID) throws DirectoryException
    {
      delegate.removeBackup(backupDirectory, backupID);
    }
    @Override
    public void restoreBackup(RestoreConfig restoreConfig) throws DirectoryException
    {
      delegate.restoreBackup(restoreConfig);
    }
    @Override
    public Set<TreeName> listTrees()
    {
      return delegate.listTrees();
    }
    @Override
    public void close()
    {
      delegate.close();
    }
  }
}
opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java
New file
@@ -0,0 +1,777 @@
/*
 * 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.controls;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.opends.server.protocols.internal.Requests.*;
import static org.opends.server.util.ServerConstants.*;
import static org.testng.Assert.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.forgerock.opendj.ldap.AttributeDescription;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.DecodeOptions;
import org.forgerock.opendj.ldap.LDAPConnectionFactory;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.ldap.controls.VirtualListViewRequestControl;
import org.forgerock.opendj.ldap.controls.VirtualListViewResponseControl;
import org.forgerock.opendj.ldap.responses.Result;
import org.opends.server.TestCaseUtils;
import org.opends.server.api.AccessControlHandler;
import org.opends.server.core.AccessControlConfigManager;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.protocols.internal.InternalSearchOperation;
import org.opends.server.protocols.internal.SearchRequest;
import org.opends.server.protocols.ldap.LDAPControl;
import org.opends.server.types.Control;
import org.opends.server.types.SearchResultEntry;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
/**
 * Regression test for GHSA-6f6q-q9gw-4jr4: the server-side sort and virtual list view controls
 * disclosed entries and attribute values the caller is not allowed to read, through the order of
 * the results, the VLV target position and the VLV content count.
 * <p>
 * The sort keys used to be built from the raw backend entries, and a VLV index answers from its
 * stored keys, both before the access controls that strip unreadable attributes and entries from
 * the returned results. Sorting on {@code userPassword} with a VLV assertion thus recovered another
 * user's password verifier byte by byte. For a caller subject to access control, an entry it may
 * not see now takes no part in the sort, and the values of an attribute it may not read on an entry
 * take no part in its sort key.
 * <p>
 * The tests hide attributes from an ordinary user with ACIs rather than sorting on a salted
 * {@code userPassword}, whose stored order is random, so that both the leaks and their absence are
 * deterministic: the people are added in an order (charlie, alice, bob) other than the order of
 * their hidden values.
 */
@SuppressWarnings("javadoc")
public class VLVSortAccessControlTest extends ControlsTestCase
{
  private static final String BASE_DN = "dc=example,dc=com";
  private static final String PEOPLE_DN = "ou=people," + BASE_DN;
  private static final String BULK_DN = "ou=bulk," + BASE_DN;
  private static final String STAR_DN = "ou=star," + BASE_DN;
  private static final String ACTOR_DN = "uid=actor," + BASE_DN;
  private static final String LIMITED_DN = "uid=limited," + BASE_DN;
  private static final String EXACT_DN = "uid=exact," + BASE_DN;
  private static final String UNLIMITED_DN = "uid=unlimited," + BASE_DN;
  private static final String PASSWORD = "Actor-Secret-1";
  /**
   * One over the default index entry limit, so that neither the filter of the bulk searches nor
   * their scope yields candidate entries: only the VLV index of the bulk entries can.
   */
  private static final int BULK_SIZE = 4001;
  /** Lookthrough limit of the limited user, under {@link #BULK_SIZE}. */
  private static final int LIMITED_LOOKTHROUGH = 100;
  private static final DN CHARLIE_DN = DN.valueOf("uid=charlie," + PEOPLE_DN);
  private static final DN ALICE_DN = DN.valueOf("uid=alice," + PEOPLE_DN);
  private static final DN BOB_DN = DN.valueOf("uid=bob," + PEOPLE_DN);
  private static final DN HIDDEN_DN = DN.valueOf("uid=hidden," + PEOPLE_DN);
  private static final DN STAR_FIRST_DN = DN.valueOf("uid=first," + STAR_DN);
  private static final DN STAR_SECOND_DN = DN.valueOf("uid=second," + STAR_DN);
  private static final DN STAR_THIRD_DN = DN.valueOf("uid=third," + STAR_DN);
  private static final String VERSIONS_DN = "ou=versions," + BASE_DN;
  /** The global ACIs that name attributes, which keep the "all attributes matched" shortcut off. */
  private static final String[] ATTRIBUTE_GLOBAL_ACIS = {
    "(targetattr!=\"userPassword||authPassword||debugsearchindex||changes||changeNumber||changeType||changeTime"
        + "||targetDN||newRDN||newSuperior||deleteOldRDN\")(version 3.0; acl \"Anonymous read access\"; "
        + "allow (read,search,compare) userdn=\"ldap:///anyone\";)",
    "(targetattr=\"userPassword||authPassword\")(version 3.0; acl \"Self entry read\"; "
        + "allow (read,search,compare) userdn=\"ldap:///self\";)",
    "(targetattr=\"createTimestamp||creatorsName||modifiersName||modifyTimestamp||entryDN||entryUUID"
        + "||subschemaSubentry||etag||governingStructureRule||structuralObjectClass||hasSubordinates"
        + "||numSubordinates||isMemberOf\")(version 3.0; acl \"User-Visible Operational Attributes\"; "
        + "allow (read,search,compare) userdn=\"ldap:///anyone\";)",
  };
  /**
   * A rule on all user and all operational attributes: once it replaces the global ACIs that name
   * attributes, it alone matches an entry without ACIs of its own, which sets both halves of the shortcut.
   */
  private static final String ALL_ATTRIBUTES_GLOBAL_ACI = "(targetattr=\"*||+\")(version 3.0; "
      + "acl \"Read everything\"; allow (read,search,compare) userdn=\"ldap:///anyone\";)";
  private InternalClientConnection actorConn;
  @BeforeClass
  public void setUp() throws Exception
  {
    TestCaseUtils.startServer();
    // A VLV index is trusted, and thus used, only when it is created while the backend is empty.
    TestCaseUtils.clearBackend("userRoot");
    TestCaseUtils.dsconfig("create-backend-vlv-index",
        "--backend-name", "userRoot",
        "--index-name", "people-by-description",
        "--set", "base-dn:" + PEOPLE_DN,
        "--set", "scope:single-level",
        "--set", "filter:(objectClass=person)",
        "--set", "sort-order:description");
    TestCaseUtils.dsconfig("create-backend-vlv-index",
        "--backend-name", "userRoot",
        "--index-name", "bulk-by-sn",
        "--set", "base-dn:" + BULK_DN,
        "--set", "scope:single-level",
        "--set", "filter:(employeeNumber=*)",
        "--set", "sort-order:sn");
    TestCaseUtils.addEntries(
        "dn: " + BASE_DN,
        "objectClass: top",
        "objectClass: domain",
        "dc: example",
        "",
        // Inherited by the people below. The actor may neither read nor search "description"
        // and "cn", and may search but not read "employeeNumber".
        "dn: " + PEOPLE_DN,
        "objectClass: top",
        "objectClass: organizationalUnit",
        "ou: people",
        "aci: (targetattr=\"description||cn\")(version 3.0; acl \"deny description and cn to actor\"; "
            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "aci: (targetattr=\"employeeNumber\")(version 3.0; acl \"deny reading employeeNumber to actor\"; "
            + "deny(read) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "",
        "dn: " + ACTOR_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: actor",
        "sn: Or",
        "cn: Act Or",
        "userPassword: " + PASSWORD,
        "",
        "dn: " + LIMITED_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: limited",
        "sn: Ited",
        "cn: Lim Ited",
        "userPassword: " + PASSWORD,
        "ds-rlim-lookthrough-limit: " + LIMITED_LOOKTHROUGH,
        "",
        "dn: " + EXACT_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: exact",
        "sn: Act",
        "cn: Ex Act",
        "userPassword: " + PASSWORD,
        "ds-rlim-lookthrough-limit: " + BULK_SIZE,
        "",
        "dn: " + UNLIMITED_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: unlimited",
        "sn: Limited",
        "cn: Un Limited",
        "userPassword: " + PASSWORD,
        "ds-rlim-lookthrough-limit: 0",
        "",
        // Hidden values in order charlie, bob, alice for description, cn and employeeNumber alike,
        // sn in order alice, bob, charlie.
        "dn: " + CHARLIE_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: charlie",
        "sn: Xavier",
        "cn: A Charlie",
        "description: aaa",
        "employeeNumber: 1",
        "",
        "dn: " + ALICE_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: alice",
        "sn: Baker",
        "cn: C Alice",
        "description: zzz",
        "employeeNumber: 3",
        "",
        "dn: " + BOB_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: bob",
        "sn: Monroe",
        "cn: B Bob",
        "description: mmm",
        "employeeNumber: 2",
        "",
        // The actor may not see this entry at all.
        "dn: " + HIDDEN_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: hidden",
        "sn: Hidden",
        "cn: Hid Den",
        "description: bbb",
        "aci: (targetattr=\"*\")(version 3.0; acl \"hide entry from actor\"; "
            + "deny(all) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "",
        // The first entry hides its description from the actor, the third one its description and
        // entryUUID; once the global ACIs that name attributes are gone, only rules on all
        // attributes match the second one.
        "dn: " + STAR_DN,
        "objectClass: top",
        "objectClass: organizationalUnit",
        "ou: star",
        "",
        "dn: " + STAR_FIRST_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: first",
        "sn: Alpha",
        "cn: First Alpha",
        "description: secret",
        "aci: (targetattr=\"description\")(version 3.0; acl \"deny description to actor\"; "
            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "",
        "dn: " + STAR_SECOND_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: second",
        "sn: Beta",
        "cn: Second Beta",
        "",
        "dn: " + STAR_THIRD_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: third",
        "sn: Gamma",
        "cn: Third Gamma",
        "description: secret",
        "aci: (targetattr=\"description\")(version 3.0; acl \"deny description to actor\"; "
            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "aci: (targetattr=\"entryUUID\")(version 3.0; acl \"deny entryUUID to actor\"; "
            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "",
        // Its rule hides "description" from the actor on the versions of an entry whose content
        // matches its target filter alone, whatever their DN.
        "dn: " + VERSIONS_DN,
        "objectClass: top",
        "objectClass: organizationalUnit",
        "ou: versions",
        "aci: (targetattr=\"description\")(targetfilter=\"(employeeType=secret)\")(version 3.0; "
            + "acl \"deny description of secret entries to actor\"; "
            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
        "",
        "dn: " + BULK_DN,
        "objectClass: top",
        "objectClass: organizationalUnit",
        "ou: bulk");
    final List<String> bulk = new ArrayList<>();
    for (int i = 0; i < BULK_SIZE; i++)
    {
      bulk.addAll(Arrays.asList(
          "dn: uid=bulk" + i + "," + BULK_DN,
          "objectClass: top",
          "objectClass: person",
          "objectClass: organizationalPerson",
          "objectClass: inetOrgPerson",
          "uid: bulk" + i,
          "sn: " + String.format("Bulk%05d", i),
          "cn: Bulk " + i,
          "employeeNumber: " + i,
          ""));
    }
    TestCaseUtils.addEntries(bulk.toArray(new String[0]));
    actorConn = new InternalClientConnection(DN.valueOf(ACTOR_DN));
  }
  @AfterClass
  public void tearDown() throws Exception
  {
    TestCaseUtils.dsconfig("delete-backend-vlv-index", "--backend-name", "userRoot",
        "--index-name", "people-by-description");
    TestCaseUtils.dsconfig("delete-backend-vlv-index", "--backend-name", "userRoot",
        "--index-name", "bulk-by-sn");
    TestCaseUtils.clearBackend("userRoot", BASE_DN);
  }
  private static List<DN> dns(InternalSearchOperation search)
  {
    final List<DN> dns = new ArrayList<>();
    for (SearchResultEntry e : search.getSearchEntries())
    {
      dns.add(e.getName());
    }
    return dns;
  }
  private static InternalSearchOperation search(InternalClientConnection conn, SearchRequest request)
  {
    final InternalSearchOperation search = conn.processSearch(request);
    assertEquals(search.getResultCode(), ResultCode.SUCCESS, String.valueOf(search.getErrorMessage()));
    return search;
  }
  /** People in the whole subtree, which no VLV index serves: the sort runs in memory. */
  private static List<DN> sortedPeople(InternalClientConnection conn, String sortOrder) throws Exception
  {
    return dns(search(conn, newSearchRequest(PEOPLE_DN, SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
        .addControl(new ServerSideSortRequestControl(sortOrder))));
  }
  private static List<Control> responseControls(InternalSearchOperation search, String oid)
  {
    final List<Control> controls = new ArrayList<>();
    for (Control c : search.getResponseControls())
    {
      if (oid.equals(c.getOID()))
      {
        controls.add(c);
      }
    }
    return controls;
  }
  private static VLVResponseControl vlvResponse(InternalSearchOperation search) throws Exception
  {
    final List<Control> controls = responseControls(search, OID_VLV_RESPONSE_CONTROL);
    assertEquals(controls.size(), 1, "Expected one VLV response control");
    final Control c = controls.get(0);
    return c instanceof LDAPControl
        ? VLVResponseControl.DECODER.decode(c.isCritical(), ((LDAPControl) c).getValue())
        : (VLVResponseControl) c;
  }
  /** The first page of the bulk entries sorted on "sn", which only their VLV index can give. */
  private static org.forgerock.opendj.ldap.requests.SearchRequest bulkPageRequest()
  {
    return org.forgerock.opendj.ldap.requests.Requests.newSearchRequest(
            BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)", "1.1")
        .addControl(org.forgerock.opendj.ldap.controls.ServerSideSortRequestControl.newControl(true, "sn"))
        .addControl(VirtualListViewRequestControl.newOffsetControl(true, 1, 0, 0, 0, null));
  }
  /** Requests {@link #bulkPageRequest()} over LDAP as the provided user, so that its resource limits apply. */
  private static Result bulkPageOverLdap(String bindDN) throws Exception
  {
    final LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
    try (Connection connection = factory.getConnection())
    {
      connection.bind(bindDN, PASSWORD.toCharArray());
      return connection.search(bulkPageRequest(),
          new ArrayList<org.forgerock.opendj.ldap.responses.SearchResultEntry>());
    }
    finally
    {
      factory.close();
    }
  }
  private static void assertBulkPageServed(Result result) throws Exception
  {
    assertEquals(result.getResultCode(), ResultCode.SUCCESS);
    final VirtualListViewResponseControl vlv =
        result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions());
    assertNotNull(vlv, "Expected a VLV response control");
    assertEquals(vlv.getContentCount(), BULK_SIZE);
  }
  private static void replaceAttributeGlobalAcis(String remove, String add)
  {
    final List<String> args = new ArrayList<>(Arrays.asList("set-access-control-handler-prop"));
    for (String aci : ATTRIBUTE_GLOBAL_ACIS)
    {
      args.add(remove);
      args.add("global-aci:" + aci);
    }
    args.add(add);
    args.add("global-aci:" + ALL_ATTRIBUTES_GLOBAL_ACI);
    TestCaseUtils.dsconfig(args.toArray(new String[0]));
  }
  /** A version of an entry under {@link #VERSIONS_DN}, which the backend need not hold, all of them of one DN. */
  private static SearchResultEntry versionOf(String employeeType) throws Exception
  {
    return new SearchResultEntry(TestCaseUtils.makeEntry(
        "dn: uid=versioned," + VERSIONS_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: versioned",
        "sn: Sioned",
        "cn: Ver Sioned",
        "employeeType: " + employeeType,
        "description: classified"));
  }
  /**
   * The actor may not read "description", so sorting on it must not order the people by its values:
   * they come in entry ID order (charlie, alice, bob) instead of value order (charlie, bob, alice).
   */
  @Test
  public void sortOnUnreadableAttributeDoesNotLeakOrder() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "description"), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN),
        "sorting on an attribute the caller cannot read must not reveal the order of its values");
  }
  /** A bypass-acl caller may read everything, so it still gets the value order. */
  @Test
  public void sortOnReadableAttributeIsUnchangedForRoot() throws Exception
  {
    assertEquals(sortedPeople(getRootConnection(), "description"),
        Arrays.asList(CHARLIE_DN, HIDDEN_DN, BOB_DN, ALICE_DN));
  }
  /** The actor may read "sn", so sorting on it still orders by its values. */
  @Test
  public void sortOnReadableAttributeStillWorksForActor() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "sn"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
  }
  /**
   * The actor may read the virtual "entryDN", so sorting on it still orders by its values: the sort
   * keys must see the virtual attributes of an entry, as the returned entry does.
   */
  @Test
  public void sortOnReadableVirtualAttributeStillWorksForActor() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "entryDN:caseIgnoreOrderingMatch"),
        Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
  }
  /**
   * "name" takes its values from its subtypes "cn" and "sn". The actor may read "name" and "sn" but
   * not "cn", so the order must follow "sn" (alice, bob, charlie), not the lower "cn" values
   * (charlie, bob, alice).
   */
  @Test
  public void sortOnSupertypeLeavesOutUnreadableSubtypeValues() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "name"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN),
        "a sort on a supertype must not follow the values of a subtype the caller cannot read");
  }
  /**
   * The actor may search "employeeNumber" but not read it: the sort keys need the right to read,
   * so the order must not follow its values (charlie, bob, alice).
   */
  @Test
  public void sortOnAttributeDeniedOnlyReadDoesNotLeakOrder() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "employeeNumber"), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN));
  }
  /** Each sort key is checked on its own: the unreadable first key leaves the order to "sn". */
  @Test
  public void sortOnSeveralKeysChecksEachKey() throws Exception
  {
    assertEquals(sortedPeople(actorConn, "description,sn"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
  }
  /** The VLV content count must leave out the entry the actor may not see. */
  @Test
  public void vlvContentCountLeavesOutEntryHiddenFromCaller() throws Exception
  {
    final InternalSearchOperation search = search(actorConn,
        newSearchRequest(PEOPLE_DN, SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
            .addControl(new ServerSideSortRequestControl("sn"))
            .addControl(new VLVRequestControl(0, 3, 1, 0)));
    assertEquals(vlvResponse(search).getContentCount(), 3);
    assertEquals(dns(search), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
  }
  /**
   * This search matches the VLV index of the people. Its keys hold the hidden values (aaa, bbb,
   * mmm, zzz), so answering from them places the assertion "b" at position 2 of 4. The actor sees
   * three people and none of their values, so it must get position 1 of 3, with a single sort
   * response control.
   */
  @Test
  public void vlvTargetPositionFromIndexDoesNotDependOnUnreadableValue() throws Exception
  {
    final InternalSearchOperation search = search(actorConn,
        newSearchRequest(PEOPLE_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
            .addControl(new ServerSideSortRequestControl("description"))
            .addControl(new VLVRequestControl(0, 0, ByteString.valueOfUtf8("b"))));
    final VLVResponseControl vlv = vlvResponse(search);
    assertEquals(vlv.getContentCount(), 3, "the content count must leave out the hidden entry");
    assertEquals(vlv.getTargetPosition(), 1, "the target position must not depend on the hidden values");
    assertEquals(responseControls(search, OID_SERVER_SIDE_SORT_RESPONSE_CONTROL).size(), 1);
  }
  /**
   * The VLV index of the people keeps its entries in the order of their hidden values. A negative
   * offset makes the sort fail; the sort control is not critical, so the search goes on with the
   * entries unsorted, and they must come in entry ID order, not in the order of the index.
   */
  @Test
  public void sortErrorOverIndexDoesNotReturnEntriesInHiddenValueOrder() throws Exception
  {
    final InternalSearchOperation search = search(actorConn,
        newSearchRequest(PEOPLE_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
            .addControl(new ServerSideSortRequestControl("description"))
            .addControl(new VLVRequestControl(0, 3, -1, 0)));
    assertEquals(dns(search), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN),
        "after a sort error, the entries of the index must not come in the order of the hidden values");
  }
  /**
   * Neither the filter nor the scope of this search gives candidate entries, only the VLV index of
   * the bulk entries does: the actor must still get its page, sorted through its access controls.
   */
  @Test
  public void vlvOverIndexOnlyEntriesIsStillServedToCaller() throws Exception
  {
    final InternalSearchOperation search = search(actorConn,
        newSearchRequest(BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)")
            .addControl(new ServerSideSortRequestControl("sn"))
            .addControl(new VLVRequestControl(0, 0, 1, 0)));
    assertEquals(vlvResponse(search).getContentCount(), BULK_SIZE);
    assertEquals(dns(search), Arrays.asList(DN.valueOf("uid=bulk0," + BULK_DN)));
  }
  /**
   * Sorting the entries of a VLV index through the caller's access controls reads all of them, so
   * it must not read more than the caller's lookthrough limit; the refusal answers both controls.
   */
  @Test
  public void vlvOverIndexHonoursCallerLookthroughLimit() throws Exception
  {
    try
    {
      bulkPageOverLdap(LIMITED_DN);
      fail("Expected the lookthrough limit of " + LIMITED_LOOKTHROUGH + " to be exceeded");
    }
    catch (LdapException e)
    {
      final Result result = e.getResult();
      assertEquals(result.getResultCode(), ResultCode.ADMIN_LIMIT_EXCEEDED);
      final org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl sort = result.getControl(
          org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl.DECODER, new DecodeOptions());
      assertNotNull(sort, "Expected a sort response control");
      assertEquals(sort.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
      final VirtualListViewResponseControl vlv =
          result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions());
      assertNotNull(vlv, "Expected a VLV response control");
      assertEquals(vlv.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
    }
  }
  /** The same refusal for a sort without a VLV request answers the sort control alone. */
  @Test
  public void sortOverIndexHonoursCallerLookthroughLimit() throws Exception
  {
    final LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
    try (Connection connection = factory.getConnection())
    {
      connection.bind(LIMITED_DN, PASSWORD.toCharArray());
      connection.search(org.forgerock.opendj.ldap.requests.Requests.newSearchRequest(
              BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)", "1.1")
              .addControl(org.forgerock.opendj.ldap.controls.ServerSideSortRequestControl.newControl(true, "sn")),
          new ArrayList<org.forgerock.opendj.ldap.responses.SearchResultEntry>());
      fail("Expected the lookthrough limit of " + LIMITED_LOOKTHROUGH + " to be exceeded");
    }
    catch (LdapException e)
    {
      final Result result = e.getResult();
      assertEquals(result.getResultCode(), ResultCode.ADMIN_LIMIT_EXCEEDED);
      final org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl sort = result.getControl(
          org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl.DECODER, new DecodeOptions());
      assertNotNull(sort, "Expected a sort response control");
      assertEquals(sort.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
      assertNull(result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions()),
          "A search without a VLV request must not get a VLV response control");
    }
    finally
    {
      factory.close();
    }
  }
  /** A lookthrough limit equal to the size of the VLV index lets the caller read all of it. */
  @Test
  public void vlvOverIndexIsServedAtCallerLookthroughLimit() throws Exception
  {
    assertBulkPageServed(bulkPageOverLdap(EXACT_DN));
  }
  /** No lookthrough limit lets the caller read the whole VLV index. */
  @Test
  public void vlvOverIndexIsServedWithoutCallerLookthroughLimit() throws Exception
  {
    assertBulkPageServed(bulkPageOverLdap(UNLIMITED_DN));
  }
  /**
   * maySend() leaves on the operation a shortcut for the entry about to be returned: when only rules
   * on all attributes matched, the entry's attributes are not checked one by one. Once the global
   * ACIs that name attributes are gone, the second entry, outside the page, sets that shortcut while
   * the sort checks every candidate; the first entry, on the page, must still have its hidden
   * "description" stripped.
   */
  @Test(priority = 1)
  public void vlvPageEntryKeepsItsAttributeChecksAfterSort() throws Exception
  {
    replaceAttributeGlobalAcis("--remove", "--add");
    try
    {
      final InternalSearchOperation search = search(actorConn,
          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
              .addAttribute("description")
              .addControl(new ServerSideSortRequestControl("sn"))
              .addControl(new VLVRequestControl(0, 0, 1, 0)));
      assertEquals(dns(search), Arrays.asList(STAR_FIRST_DN));
      assertFalse(search.getSearchEntries().getFirst().hasAttribute(AttributeDescription.valueOf("description")),
          "the returned entry must not carry the attribute the caller may not read");
    }
    finally
    {
      replaceAttributeGlobalAcis("--add", "--remove");
    }
  }
  /**
   * The same shortcut, without any sort: the second entry is returned before the third one, and the
   * third one must still have its hidden "description" stripped.
   */
  @Test(priority = 1)
  public void eachReturnedEntryKeepsItsAttributeChecks() throws Exception
  {
    replaceAttributeGlobalAcis("--remove", "--add");
    try
    {
      final InternalSearchOperation search = search(actorConn,
          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)").addAttribute("description"));
      assertEquals(search.getSearchEntries().size(), 3);
      for (SearchResultEntry e : search.getSearchEntries())
      {
        assertFalse(e.hasAttribute(AttributeDescription.valueOf("description")),
            e.getName() + " must not carry the attribute the caller may not read");
      }
    }
    finally
    {
      replaceAttributeGlobalAcis("--add", "--remove");
    }
  }
  /**
   * The operational attribute half of the same shortcut: the second entry, matched by rules on all
   * attributes alone, is returned before the third one, which must still have its hidden "entryUUID"
   * stripped.
   */
  @Test(priority = 1)
  public void eachReturnedEntryKeepsItsOperationalAttributeChecks() throws Exception
  {
    replaceAttributeGlobalAcis("--remove", "--add");
    try
    {
      final InternalSearchOperation search = search(actorConn,
          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)").addAttribute("+"));
      assertEquals(dns(search), Arrays.asList(STAR_FIRST_DN, STAR_SECOND_DN, STAR_THIRD_DN));
      final AttributeDescription entryUUID = AttributeDescription.valueOf("entryUUID");
      assertTrue(search.getSearchEntries().get(1).hasAttribute(entryUUID),
          "the caller may read the operational attributes of " + STAR_SECOND_DN);
      assertFalse(search.getSearchEntries().get(2).hasAttribute(entryUUID),
          STAR_THIRD_DN + " must not carry the operational attribute the caller may not read");
    }
    finally
    {
      replaceAttributeGlobalAcis("--add", "--remove");
    }
  }
  /**
   * The shortcut holds the very entry maySend() checked, not its DN: two versions of one entry, which
   * the interleaved persistent search notifications of two modifications of it hand out, keep their
   * own checks. Only the later version is matched by rules on all attributes alone; the earlier one,
   * whose content a target filter rule hides "description" of, must still have it stripped.
   */
  @Test(priority = 1)
  public void entryVersionKeepsItsAttributeChecksAfterAnotherVersionOfItsDN() throws Exception
  {
    replaceAttributeGlobalAcis("--remove", "--add");
    try
    {
      final AccessControlHandler<?> handler = AccessControlConfigManager.getInstance().getAccessControlHandler();
      final InternalSearchOperation search = new InternalSearchOperation(actorConn, nextOperationID(),
          nextMessageID(), newSearchRequest(VERSIONS_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)"));
      final SearchResultEntry secret = versionOf("secret");
      final SearchResultEntry plain = versionOf("plain");
      assertTrue(handler.maySend(search, secret));
      assertTrue(handler.maySend(search, plain));
      final SearchResultEntry filtered = new SearchResultEntry(secret.duplicate(false));
      handler.filterEntry(search, secret, filtered);
      assertTrue(filtered.hasAttribute(AttributeDescription.valueOf("sn")),
          "the caller may read the other attributes of the entry");
      assertFalse(filtered.hasAttribute(AttributeDescription.valueOf("description")),
          "a version of an entry must not carry the attribute the caller may not read on it");
    }
    finally
    {
      replaceAttributeGlobalAcis("--add", "--remove");
    }
  }
  /**
   * With access control disabled, the sort of a non-root caller need not go through it: the VLV
   * index answers, so its size may exceed the caller's lookthrough limit.
   */
  @Test(priority = 2)
  public void vlvOverIndexIsAnsweredByIndexWithAccessControlDisabled() throws Exception
  {
    TestCaseUtils.dsconfig("set-access-control-handler-prop", "--set", "enabled:false");
    try
    {
      assertBulkPageServed(bulkPageOverLdap(LIMITED_DN));
    }
    finally
    {
      TestCaseUtils.dsconfig("set-access-control-handler-prop", "--set", "enabled:true");
    }
  }
}
opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java
New file
@@ -0,0 +1,42 @@
/*
 * 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.core;
import static org.testng.Assert.assertFalse;
import java.lang.reflect.Constructor;
import org.testng.annotations.Test;
@SuppressWarnings("javadoc")
public class AccessControlConfigManagerTest extends CoreTestCase
{
  /**
   * Until initializeAccessControl() has run, the manager holds no configuration and its handler
   * permits everything: access control is not enabled, which a sorted search asks of every caller
   * (GHSA-6f6q-q9gw-4jr4), rather than an error.
   */
  @Test
  public void accessControlIsNotEnabledBeforeItIsInitialized() throws Exception
  {
    // The server's own manager has been initialized: only a manager of its own has not.
    final Constructor<AccessControlConfigManager> constructor =
        AccessControlConfigManager.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    assertFalse(constructor.newInstance().isAccessControlEnabled());
  }
}