From c549b645f5300bed76e62614e0137c7eecc2e067 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Thu, 16 Jul 2026 07:48:20 +0000
Subject: [PATCH] [#738] Fix dereferencing an alias that points into another backend (#741)

---
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java                           |   24 +++++
 opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java                           |   77 +++++++++++++++++++
 opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java                              |   80 +++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java |    6 +
 opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java                                   |   11 +-
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java          |   18 ++++
 6 files changed, 204 insertions(+), 12 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java
index fd5f0bc..c3e38a4 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
  */
 package org.opends.server.backends;
 
@@ -494,9 +495,10 @@
     SearchScope  scope  = searchOperation.getScope();
     SearchFilter filter = searchOperation.getFilter();
 
-    // Make sure the base entry exists if it's supposed to be in this backend.
+    // Make sure the base entry exists. A base DN this backend does not hold has no entry
+    // either, so it must be reported as missing rather than searched for.
     Entry baseEntry = entryMap.get(baseDN);
-    if (baseEntry == null && handlesEntry(baseDN))
+    if (baseEntry == null)
     {
       DN matchedDN = serverContext.getBackendConfigManager().getParentDNInSuffix(baseDN);
       while (matchedDN != null)
@@ -515,10 +517,7 @@
               ResultCode.NO_SUCH_OBJECT, message, matchedDN, null);
     }
 
-    if (baseEntry != null)
-    {
-      baseEntry = baseEntry.duplicate(true);
-    }
+    baseEntry = baseEntry.duplicate(true);
 
     // If it's a base-level search, then just get that entry and return it if it
     // matches the filter.
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java
index 50506e3..40b0c0f 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2007-2010 Sun Microsystems, Inc.
  * Portions Copyright 2013-2016 ForgeRock AS.
- * Portions Copyright 2025 3A Systems, LLC
+ * Portions Copyright 2025-2026 3A Systems, LLC
  */
 package org.opends.server.backends.pluggable;
 
@@ -123,12 +123,28 @@
    */
   private EntryContainer accessBegin(Operation operation, DN entryDN) throws DirectoryException
   {
+    return accessBegin(operation, entryDN, ResultCode.UNDEFINED);
+  }
+
+  /**
+   * Begin a Backend API method that accesses the {@link EntryContainer} for <code>entryDN</code>
+   * and returns it.
+   * @param operation requesting the storage
+   * @param entryDN the target DN for the operation
+   * @param noEntryContainerResultCode the result code to report when this backend holds no
+   *                                   entry container for <code>entryDN</code>
+   * @return <code>EntryContainer</code> where <code>entryDN</code> resides
+   */
+  private EntryContainer accessBegin(Operation operation, DN entryDN, ResultCode noEntryContainerResultCode)
+      throws DirectoryException
+  {
     checkRootContainerInitialized();
     rootContainer.checkForEnoughResources(operation);
     EntryContainer ec = rootContainer.getEntryContainer(entryDN);
     if (ec == null)
     {
-      throw new DirectoryException(ResultCode.UNDEFINED, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID()));
+      throw new DirectoryException(
+          noEntryContainerResultCode, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID()));
     }
     threadTotalCount.getAndIncrement();
     return ec;
@@ -557,7 +573,9 @@
   @Override
   public void search(SearchOperation searchOperation) throws DirectoryException, CanceledOperationException
   {
-    EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN());
+    // a base DN held by no entry container of this backend does not exist as far as a client
+    // is concerned: report it as such instead of the UNDEFINED result code used internally.
+    EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN(), ResultCode.NO_SUCH_OBJECT);
 
     ec.sharedLock.lock();
 
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java
index cec674d..8d9db9b 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2008-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions Copyright 2024-2025 3A Systems, LLC.
+ * Portions Copyright 2024-2026 3A Systems, LLC.
  */
 package org.opends.server.workflowelement.localbackend;
 
@@ -216,6 +216,10 @@
           if(!dereferencingDNs.contains(aliasedDn)) { //detect recursive search
             dereferencingDNs.add(aliasedDn);
             setBaseDN(aliasedDn);
+            // the alias may point into another backend: a null backend is reported
+            // as NO_SUCH_OBJECT by the recursive call
+            backend = DirectoryServer.getInstance().getServerContext()
+                    .getBackendConfigManager().findLocalBackendForEntry(aliasedDn);
             try {
               processSearch(executePostOpPlugins);
             } catch (StackOverflowError error) {
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java
new file mode 100644
index 0000000..a593b37
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java
@@ -0,0 +1,77 @@
+/*
+ * 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;
+
+import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection;
+import static org.opends.server.protocols.internal.Requests.newSearchRequest;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.fail;
+
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.protocols.internal.InternalSearchOperation;
+import org.opends.server.protocols.internal.SearchRequest;
+import org.opends.server.types.DirectoryException;
+import org.opends.server.workflowelement.localbackend.LocalBackendSearchOperation;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/** Test cases for the memory backend. */
+@SuppressWarnings("javadoc")
+public class MemoryBackendTestCase extends BackendTestCase
+{
+  private MemoryBackend backend;
+
+  @BeforeClass
+  public void startServer() throws Exception
+  {
+    TestCaseUtils.startServer();
+    TestCaseUtils.initializeTestBackend(true);
+    backend = (MemoryBackend) TestCaseUtils.getServerContext().getBackendConfigManager()
+        .getLocalBackendById(TestCaseUtils.TEST_BACKEND_ID);
+  }
+
+  /** A base DN this backend does not hold has no entry either. */
+  @Test
+  public void testSearchBaseDNNotHeldByBackend() throws Exception
+  {
+    assertSearchFailsWithNoSuchObject(DN.valueOf("o=not-held-by-this-backend"));
+  }
+
+  /** A base DN this backend holds, but which has never been added. */
+  @Test
+  public void testSearchBaseDNWithoutEntry() throws Exception
+  {
+    assertSearchFailsWithNoSuchObject(DN.valueOf("ou=missing,o=test"));
+  }
+
+  private void assertSearchFailsWithNoSuchObject(DN baseDN) throws Exception
+  {
+    final SearchRequest request = newSearchRequest(baseDN, SearchScope.BASE_OBJECT);
+    final InternalSearchOperation search = new InternalSearchOperation(getRootConnection(), -1, -1, request);
+    try
+    {
+      backend.search(new LocalBackendSearchOperation(search));
+      fail("Searching base DN " + baseDN + " should have failed.");
+    }
+    catch (DirectoryException e)
+    {
+      assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT);
+    }
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java
index e226c5f..7568030 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java
@@ -12,7 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2015-2016 ForgeRock AS.
- * Portions Copyright 2023-2025 3A Systems, LLC.
+ * Portions Copyright 2023-2026 3A Systems, LLC.
  */
 package org.opends.server.backends.pluggable;
 
@@ -645,6 +645,22 @@
         "Leaf entry should not have any subordinates.");
   }
 
+  /** A base DN held by no entry container of this backend has no entry either. */
+  @Test
+  public void testSearchBaseDNNotHeldByBackend() throws Exception
+  {
+    try
+    {
+      backend.search(createSearchOperation(
+          DN.valueOf("dc=a"), SearchScope.BASE_OBJECT, "(objectClass=*)", new ArrayList<Entry>()));
+      fail("Searching a base DN not held by this backend should have failed.");
+    }
+    catch (DirectoryException e)
+    {
+      assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT);
+    }
+  }
+
   private List<SearchResultEntry> runSearch(SearchRequest request, boolean useInternalConnection) throws Exception
   {
     InternalClientConnection conn = getRootConnection();
diff --git a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java
index 99376f8..75c2def 100644
--- a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java
@@ -11,7 +11,7 @@
  * Header, with the fields enclosed by brackets [] replaced by your own identifying
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
- * Copyright 2024-2025 3A Systems, LLC.
+ * Copyright 2024-2026 3A Systems, LLC.
  */
 package org.openidentityplatform.opendj;
 
@@ -23,22 +23,29 @@
 import org.opends.server.DirectoryServerTestCase;
 import org.opends.server.TestCaseUtils;
 
+import org.opends.server.backends.MemoryBackend;
 import org.opends.server.types.Entry;
+import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
 import java.util.HashMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
 
 @Test(sequential = true)
 public class AliasTestCase extends DirectoryServerTestCase {
+    /** Backend holding a naming context other than o=test, to check aliases crossing a backend boundary. */
+    static final String OTHER_BACKEND_ID = "test2";
+
     Connection connection;
 
     @BeforeClass
     public void startServer() throws Exception {
         TestCaseUtils.startServer();
         TestCaseUtils.initializeTestBackend(true);
+        TestCaseUtils.initializeMemoryBackend(OTHER_BACKEND_ID, "o=test2", true);
 
         TestCaseUtils.addEntries(
                  "dn: o=MyCompany, o=test",
@@ -116,6 +123,32 @@
                         "objectClass: extensibleObject",
                         "aliasedObjectName: uid=janedoe,ou=students,o=test",
                         "uid: janedoe",
+                        "",
+
+                        //an alias pointing into the o=test2 backend, and one pointing nowhere at all
+                        "dn: ou=people,o=test2",
+                        "objectClass: top",
+                        "objectClass: organizationalUnit",
+                        "ou: people",
+                        "",
+                        "dn: cn=Jane Roe,ou=people,o=test2",
+                        "cn: Jane Roe",
+                        "sn: Roe",
+                        "objectclass: person",
+                        "",
+                        "dn: ou=external,o=test",
+                        "objectClass: alias",
+                        "objectClass: top",
+                        "objectClass: extensibleObject",
+                        "ou: external",
+                        "aliasedObjectName: ou=people,o=test2",
+                        "",
+                        "dn: ou=nowhere,o=test",
+                        "objectClass: alias",
+                        "objectClass: top",
+                        "objectClass: extensibleObject",
+                        "ou: nowhere",
+                        "aliasedObjectName: ou=people,o=missing",
                         ""
         );
 
@@ -125,6 +158,20 @@
         assertThat(connection.isValid()).isTrue();
     }
 
+    @AfterClass
+    public void stopServer() throws Exception {
+        if (connection != null) {
+            connection.close();
+        }
+        final MemoryBackend otherBackend = (MemoryBackend) TestCaseUtils.getServerContext()
+                .getBackendConfigManager().getLocalBackendById(OTHER_BACKEND_ID);
+        if (otherBackend != null) {
+            otherBackend.clearMemoryBackend();
+            otherBackend.finalizeBackend();
+            TestCaseUtils.getServerContext().getBackendConfigManager().deregisterLocalBackend(otherBackend);
+        }
+    }
+
     public HashMap<String,SearchResultEntry> search(SearchScope scope,DereferenceAliasesPolicy policy) throws SearchResultReferenceIOException, LdapException {
         return search("ou=Area1,o=test", scope, policy);
     }
@@ -386,6 +433,37 @@
         assertThat(res.containsKey("uid=janedoe,ou=employees,o=test")).isFalse();
     }
 
+    //    The alias target does not have to live in the backend holding the alias:
+    //    the search must be handed over to the backend holding the target.
+    @Test
+    public void test_cross_backend_alias_base_always() throws LdapException, SearchResultReferenceIOException {
+        HashMap<String, SearchResultEntry> res = search("ou=external,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS);
+
+        assertThat(res.containsKey("ou=external,o=test")).isFalse();
+        assertThat(res.containsKey("ou=people,o=test2")).isTrue();
+        assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isFalse();
+    }
+
+    @Test
+    public void test_cross_backend_alias_sub_always() throws LdapException, SearchResultReferenceIOException {
+        HashMap<String, SearchResultEntry> res = search("ou=external,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
+
+        assertThat(res.containsKey("ou=external,o=test")).isFalse();
+        assertThat(res.containsKey("ou=people,o=test2")).isTrue();
+        assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isTrue();
+    }
+
+    //    No backend at all holds the alias target: this is a base DN that does not exist.
+    @Test
+    public void test_alias_target_without_backend() throws SearchResultReferenceIOException {
+        try {
+            search("ou=nowhere,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS);
+            fail("dereferencing an alias whose target has no backend must fail");
+        } catch (LdapException e) {
+            assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.NO_SUCH_OBJECT);
+        }
+    }
+
     @Test(expectedExceptions = LdapException.class)
     public void test_stackoverflow() throws Exception {
 

--
Gitblit v1.10.0