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