/*
* 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.
*
* 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.
*
* 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 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 dns(InternalSearchOperation search)
{
final List 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 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 responseControls(InternalSearchOperation search, String oid)
{
final List 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 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());
}
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 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());
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");
}
}
}