From 26defc28efac7cc706352bea7126a19308065468 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 23 Sep 2026 07:41:33 +0000
Subject: [PATCH] [#1021] Reset the objectClass attribute of an entry when its object classes change (#1022)

---
 opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java                                 |   97 +++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java                                     |   12 ++
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java |   70 ++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ModifyConflictTest.java           |   73 ++++++++++++++
 4 files changed, 251 insertions(+), 1 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java b/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
index cbb62d1..a8a25ba 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
@@ -120,6 +120,13 @@
   /** The set of objectclasses for this entry. */
   private Map<ObjectClass,String> objectClasses;
 
+  /**
+   * The set of objectclasses of this entry in its attribute form, built on demand by
+   * {@link #getObjectClassAttribute()}. Every change of {@link #objectClasses} must reset it:
+   * a backend stores the objectclasses themselves but indexes the entry through this attribute,
+   * so an attribute left behind by a change takes the objectClass index out of step with the
+   * stored entry, without anything failing.
+   */
   private Attribute objectClassAttribute;
 
   /** The DN for this entry. */
@@ -295,6 +302,7 @@
     }
 
     objectClasses.put(oc, oc.getNameOrOID());
+    objectClassAttribute = null;
   }
 
 
@@ -1268,6 +1276,7 @@
     if (attributeType.isObjectClass())
     {
       objectClasses.clear();
+      objectClassAttribute = null;
       return true;
     }
     return userAttributes.remove(attributeType) != null
@@ -1320,6 +1329,7 @@
     if (attribute.isEmpty())
     {
       objectClasses.clear();
+      objectClassAttribute = null;
       return true;
     }
 
@@ -1338,6 +1348,7 @@
         if (oc.hasNameOrOID(ocName))
         {
           objectClasses.remove(oc);
+          objectClassAttribute = null;
           matchFound = true;
           break;
         }
@@ -4364,6 +4375,7 @@
     AttributeType attrType = attribute.getAttributeDescription().getAttributeType();
     // We will not do any validation of the object classes - this is
     // left to the caller.
+    objectClassAttribute = null;
     if (replace)
     {
       objectClasses.clear();
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 6706f7b..ae0c5a7 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
@@ -68,6 +68,7 @@
 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.Attribute;
 import org.opends.server.types.BackupConfig;
 import org.opends.server.types.BackupDirectory;
 import org.opends.server.types.DirectoryException;
@@ -104,6 +105,7 @@
 
   private Map<String, IndexType[]> backendIndexes = new HashMap<>();
   {
+    backendIndexes.put("objectClass", new IndexType[] { IndexType.EQUALITY });
     backendIndexes.put("entryUUID", new IndexType[] { IndexType.EQUALITY });
     backendIndexes.put("cn", new IndexType[] { IndexType.SUBSTRING });
     backendIndexes.put("sn", new IndexType[] { IndexType.PRESENCE, IndexType.EQUALITY, IndexType.SUBSTRING });
@@ -751,6 +753,74 @@
     assertThat((Object) returnedEntries.get(0).getName()).isEqualTo(newEntry.getName());
   }
 
+  /**
+   * A modification of the object classes of an entry must reach the objectClass index even when
+   * the objectClass attribute of the modified entry has already been read - which the conflict
+   * resolution of a replayed modification does with every delete of an object class value, before
+   * the core applies the modifications to the entry.
+   */
+  @Test
+  public void testModifyObjectClassOfAnAlreadyReadEntry() throws Exception
+  {
+    final AttributeType objectClassType = CoreSchema.getObjectClassAttributeType();
+    final Entry oldEntry = TestCaseUtils.makeEntry(
+        "dn: uid=user.13,ou=People," + testBaseDN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "givenName: Abbey",
+        "sn: Abbie",
+        "cn: Abbey Abbie",
+        "uid: user.13");
+    backend.addEntry(oldEntry, mock(AddOperation.class));
+    try
+    {
+      final Entry newEntry = oldEntry.duplicate(false);
+      // The read of the entry done by the conflict resolution of a replayed modification.
+      newEntry.hasValue(AttributeDescription.create(objectClassType), ByteString.valueOfUtf8("person"));
+
+      final Attribute addedClass = create(objectClassType, "extensibleObject");
+      final Attribute deletedClass = create(objectClassType, "organizationalPerson");
+      // The core applies the modifications with addAttribute() and removeAttribute().
+      newEntry.addAttribute(addedClass, new LinkedList<ByteString>());
+      newEntry.removeAttribute(deletedClass, new LinkedList<ByteString>());
+
+      ModifyOperation modifyOp = mock(ModifyOperation.class);
+      when(modifyOp.getModifications()).thenReturn(
+          Arrays.asList(new Modification(ADD, addedClass), new Modification(DELETE, deletedClass)));
+      backend.replaceEntry(oldEntry, newEntry, modifyOp);
+
+      // The added object class must be searchable through the index,
+      final List<Entry> returnedEntries = new ArrayList<>();
+      backend.search(createSearchOperation(
+          testBaseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=extensibleObject)", returnedEntries));
+      assertThat(returnedEntries).hasSize(1);
+      assertThat((Object) returnedEntries.get(0).getName()).isEqualTo(newEntry.getName());
+
+      // and the deleted one must be gone from it.
+      assertThat(verifyObjectClassIndex()).isEqualTo(0);
+    }
+    finally
+    {
+      backend.deleteEntry(oldEntry.getName(), mock(DeleteOperation.class));
+    }
+  }
+
+  /** Returns the number of both the missing and the stale records of the objectClass index. */
+  private long verifyObjectClassIndex() throws Exception
+  {
+    VerifyConfig completeness = new VerifyConfig();
+    completeness.setBaseDN(testBaseDN);
+    completeness.addCompleteIndex("objectClass");
+
+    VerifyConfig cleanliness = new VerifyConfig();
+    cleanliness.setBaseDN(testBaseDN);
+    cleanliness.addCleanIndex("objectClass");
+
+    return backend.verifyBackend(completeness) + backend.verifyBackend(cleanliness);
+  }
+
   private SearchOperation createSearchOperation(DN baseDN, SearchScope scope, String searchFilter,
       final List<Entry> returnedEntries) throws DirectoryException
   {
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ModifyConflictTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ModifyConflictTest.java
index 633227b..396a2eb 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ModifyConflictTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ModifyConflictTest.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.replication.plugin;
 
@@ -69,6 +70,8 @@
 public class ModifyConflictTest extends ReplicationTestCase
 {
   private static final String ORGANIZATION = "organization";
+  private static final String OBJECTCLASS = "objectClass";
+  private static final String EXTENSIBLEOBJECT = "extensibleObject";
   private static final String DISPLAYNAME = "displayName";
   private static final String EMPLOYEENUMBER = "employeeNumber";
   private static final String DESCRIPTION = "description";
@@ -1042,6 +1045,76 @@
   }
 
   /**
+   * Test that a replayed modification of the objectClass attribute leaves the entry handing out
+   * the object classes it ends up with.
+   * <p>
+   * Solving the conflicts of a delete reads the entry, and it does so before the core applies the
+   * modifications to it. The read must not fix what the entry answers afterwards: the backend
+   * stores the object classes of the entry but indexes it through its objectClass attribute, so
+   * an attribute left behind by the modification silently takes the objectClass index out of step
+   * with the stored entry.
+   */
+  @Test
+  public void replayObjectClassAddAndDelete() throws Exception
+  {
+    Entry entry = initializeEntry();
+    EntryHistorical hist = EntryHistorical.newInstanceFromEntry(entry);
+
+    List<Modification> mods = newArrayList(
+        newModification(ADD, OBJECTCLASS, EXTENSIBLEOBJECT),
+        newModification(DELETE, OBJECTCLASS, ORGANIZATION));
+
+    // The conflict resolution, which reads the entry to solve the delete.
+    replayModifies(entry, hist, 10, mods);
+    assertThat(mods).hasSize(2);
+
+    // The core, which applies the modifications to the entry.
+    applyModificationsTheWayTheCoreDoes(entry, mods);
+
+    assertThat(entry.getObjectClasses().values()).containsOnly(EXTENSIBLEOBJECT);
+    assertThat(objectClassAttributeOf(entry)).containsOnly(EXTENSIBLEOBJECT);
+  }
+
+  /**
+   * Applies the modifications to the entry the way {@code LocalBackendModifyOperation} does, that
+   * is with {@code addAttribute()} and {@code removeAttribute()} rather than with
+   * {@code Entry.applyModifications()}.
+   */
+  private void applyModificationsTheWayTheCoreDoes(Entry entry, List<Modification> mods)
+  {
+    for (Modification mod : mods)
+    {
+      Attribute attr = mod.getAttribute();
+      switch (mod.getModificationType().asEnum())
+      {
+      case ADD:
+        entry.addAttribute(attr, new LinkedList<ByteString>());
+        break;
+      case DELETE:
+        entry.removeAttribute(attr, new LinkedList<ByteString>());
+        break;
+      default:
+        entry.replaceAttribute(attr);
+        break;
+      }
+    }
+  }
+
+  /** Returns the object classes of the entry as its objectClass attribute reports them. */
+  private List<String> objectClassAttributeOf(Entry entry)
+  {
+    List<String> values = new LinkedList<>();
+    for (Attribute attr : entry.getAllAttributes(getObjectClassAttributeType()))
+    {
+      for (ByteString value : attr)
+      {
+        values.add(value.toString());
+      }
+    }
+    return values;
+  }
+
+  /**
    * Check that the mods given as first parameter match the next parameters.
    *
    * @param mods The mods that must be tested.
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java b/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
index b025a9b..c48dc3c 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
@@ -110,7 +110,7 @@
     TestCaseUtils.startServer();
   }
 
-  /** Returns an entry to delete object class values from. */
+  /** Returns an entry to change the object classes of. */
   private Entry newTestUserEntry() throws Exception
   {
     return TestCaseUtils.makeEntry(
@@ -171,6 +171,101 @@
     assertThat(e.getObjectClasses().values()).containsOnly("top", "person", "organizationalPerson");
   }
 
+  /*
+   * An entry holds its object classes apart from its attributes, and hands them out as an
+   * attribute which it builds on demand and keeps. Every change of the object classes must be
+   * visible to the next read of that attribute: a backend stores the object classes themselves
+   * but indexes the entry through the attribute, so an attribute left behind by a change silently
+   * takes the object class index out of step with the stored entry. The tests below read the
+   * attribute first, the way the server reads an entry before modifying it.
+   */
+
+  /** Returns the object classes of the entry as its object class attribute reports them. */
+  private List<String> objectClassAttributeOf(Entry e)
+  {
+    List<String> values = new ArrayList<>();
+    for (Attribute attr : e.getAllAttributes(getObjectClassAttributeType()))
+    {
+      for (ByteString value : attr)
+      {
+        values.add(value.toString());
+      }
+    }
+    return values;
+  }
+
+  /** An object class added with {@code addAttribute()}, the way the core applies "add: objectClass". */
+  @Test
+  public void testObjectClassAttributeAfterAddAttribute() throws Exception
+  {
+    Entry e = newTestUserEntry();
+    assertThat(objectClassAttributeOf(e)).doesNotContain("extensibleObject");
+
+    e.addAttribute(Attributes.create(getObjectClassAttributeType(), "extensibleObject"),
+        new LinkedList<ByteString>());
+
+    assertThat(objectClassAttributeOf(e))
+        .containsOnly("top", "person", "organizationalPerson", "inetOrgPerson", "extensibleObject");
+  }
+
+  /**
+   * An object class removed with {@code removeAttribute()}, the way the core applies
+   * "delete: objectClass".
+   */
+  @Test
+  public void testObjectClassAttributeAfterRemoveAttribute() throws Exception
+  {
+    Entry e = newTestUserEntry();
+    assertThat(objectClassAttributeOf(e)).contains("organizationalPerson");
+
+    e.removeAttribute(Attributes.create(getObjectClassAttributeType(), "organizationalPerson"),
+        new LinkedList<ByteString>());
+
+    assertThat(objectClassAttributeOf(e)).containsOnly("top", "person", "inetOrgPerson");
+  }
+
+  /**
+   * The object classes replaced with {@code replaceAttribute()}, the way the core applies
+   * "replace: objectClass".
+   */
+  @Test
+  public void testObjectClassAttributeAfterReplaceAttribute() throws Exception
+  {
+    Entry e = newTestUserEntry();
+    assertThat(objectClassAttributeOf(e)).contains("inetOrgPerson");
+
+    e.replaceAttribute(Attributes.create(getObjectClassAttributeType(), "domain"));
+
+    assertThat(objectClassAttributeOf(e)).containsOnly("domain");
+  }
+
+  /** An object class added with {@code addObjectClass()}. */
+  @Test
+  public void testObjectClassAttributeAfterAddObjectClass() throws Exception
+  {
+    Entry e = newTestUserEntry();
+    assertThat(objectClassAttributeOf(e)).doesNotContain("extensibleObject");
+
+    e.addObjectClass(CoreSchema.getExtensibleObjectObjectClass());
+
+    assertThat(objectClassAttributeOf(e))
+        .containsOnly("top", "person", "organizationalPerson", "inetOrgPerson", "extensibleObject");
+  }
+
+  /** All the object classes removed with {@code removeAttribute()}, then a new one added. */
+  @Test
+  public void testObjectClassAttributeAfterRemoveOfTheWholeAttribute() throws Exception
+  {
+    Entry e = newTestUserEntry();
+    assertThat(objectClassAttributeOf(e)).contains("inetOrgPerson");
+
+    e.removeAttribute(getObjectClassAttributeType());
+    assertThat(objectClassAttributeOf(e)).isEmpty();
+
+    e.addObjectClass(CoreSchema.getTopObjectClass());
+    assertThat(objectClassAttributeOf(e)).containsOnly("top");
+  }
+
   /**
    * Test the {@link Entry#parseAttribute(String)} method.
    */

--
Gitblit v1.10.0