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
| | |
| | | * |
| | | * Copyright 2006-2009 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.api; |
| | | |
| | |
| | | * 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. |
| | |
| | | * 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 |
| | |
| | | * 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 |
| | |
| | | * |
| | | * Copyright 2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.authorization.dseecompat; |
| | | |
| | |
| | | } |
| | | |
| | | //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; |
| | | } |
| | |
| | | /** |
| | | * 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"; |
| | | |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | |
| | | 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; |
| | | } |
| | |
| | | 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 |
| | |
| | | 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 |
| | | { |
| | |
| | | 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()) |
| | | { |
| | |
| | | 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) |
| | |
| | | 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()]; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | /** 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); |
| | | } |
| | |
| | | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | * 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 |
| | |
| | | */ |
| | | public boolean isAccessControlEnabled() |
| | | { |
| | | return currentConfiguration.isEnabled(); |
| | | // Until initializeAccessControl() has run, getAccessControlHandler() permits everything. |
| | | return currentConfiguration != null && currentConfiguration.isEnabled(); |
| | | } |
| | | |
| | | /** |
| New file |
| | |
| | | /* |
| | | * 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(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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"); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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()); |
| | | } |
| | | } |