diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/MultimasterReplication.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/MultimasterReplication.java
index 3e1f4a9ca4..49181cf71e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/MultimasterReplication.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/MultimasterReplication.java
@@ -52,6 +52,7 @@
import org.opends.server.api.ImportTaskListener;
import org.opends.server.api.RestoreTaskListener;
import org.opends.server.api.SynchronizationProvider;
+import org.opends.server.core.AccessControlConfigManager;
import org.opends.server.core.BackendConfigManager;
import org.opends.server.core.DirectoryServer;
import org.opends.server.core.ServerContext;
@@ -64,6 +65,7 @@
import org.opends.server.types.LDIFImportConfig;
import org.opends.server.types.Modification;
import org.opends.server.types.Operation;
+import org.opends.server.types.OperationType;
import org.opends.server.types.RestoreConfig;
import org.opends.server.types.SynchronizationProviderResult;
import org.opends.server.types.operation.PluginOperation;
@@ -161,6 +163,15 @@ public static LDAPReplicationDomain findDomain(DN dn, PluginOperation pluginOp)
Control c = it.next();
if (OID_REPLICATION_REPAIR_CONTROL.equals(c.getOID()))
{
+ if (!mayUseRepairControl(dn, op, c))
+ {
+ /*
+ Leave the control on the request: the backend drops it when it is not critical,
+ and refuses the request when it is, as it does with any control the client may
+ not use.
+ */
+ break;
+ }
op.setSynchronizationOperation(true);
op.setDontSynchronize(true);
/*
@@ -196,6 +207,50 @@ public static LDAPReplicationDomain findDomain(DN dn, PluginOperation pluginOp)
return domain;
}
+ /**
+ * Whether the client may use the repair control on this operation, as the access control of
+ * the controls decides it: a client with the {@code bypass-acl} privilege, or one an ACI allows
+ * to use the control. The question is asked at the entry being repaired, on every operation:
+ * on an add as well, where the backend judges the other controls at the parent entry.
+ *
+ * On a modify and a modify DN the backend has already asked it of every control by the time
+ * the replication plugin runs. An add and a delete reach the plugin before the backend checks
+ * their controls, and the plugin takes the control off the request, so on those operations the
+ * answer given here is the only one. They also reach it before the backend has applied a
+ * proxied authorization control: the answer would be given for the bound client rather than
+ * for the one the operation runs as, so a repair is refused on an add or a delete which
+ * carries one.
+ */
+ private static boolean mayUseRepairControl(DN dn, Operation op, Control control)
+ {
+ final OperationType type = op.getOperationType();
+ if ((type == OperationType.ADD || type == OperationType.DELETE) && carriesProxiedAuthorization(op))
+ {
+ return false;
+ }
+ try
+ {
+ return AccessControlConfigManager.getInstance().getAccessControlHandler().isAllowed(dn, op, control);
+ }
+ catch (DirectoryException e)
+ {
+ logger.traceException(e);
+ return false;
+ }
+ }
+
+ private static boolean carriesProxiedAuthorization(Operation op)
+ {
+ for (Control c : op.getRequestControls())
+ {
+ if (OID_PROXIED_AUTH_V1.equals(c.getOID()) || OID_PROXIED_AUTH_V2.equals(c.getOID()))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Creates a new domain from its configEntry, do the
* necessary initialization and starts it so that it is
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlStandaloneTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlStandaloneTest.java
new file mode 100644
index 0000000000..63e42538a8
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlStandaloneTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.replication.plugin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.opends.server.TestCaseUtils.TEST_ROOT_DN_STRING;
+import static org.opends.server.replication.plugin.ReplicationRepairRequestControl.OID_REPLICATION_REPAIR_CONTROL;
+
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.GenericControl;
+import org.forgerock.opendj.ldap.requests.ModifyRequest;
+import org.forgerock.opendj.ldap.requests.Requests;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.replication.ReplicationTestCase;
+import org.testng.annotations.Test;
+
+/**
+ * The replication repair request control on a server with no replication domain configured -
+ * which is where an administrator giving entries back the {@code entryUUID} another directory
+ * assigned them often is, before replication is enabled.
+ *
+ * {@link ReplicationRepairControlTest} configures a domain before any of its cases runs, so it
+ * cannot see whether the control is still honoured when the replication plugin has no domain to
+ * look for.
+ */
+@SuppressWarnings("javadoc")
+public class ReplicationRepairControlStandaloneTest extends ReplicationTestCase
+{
+ private static final String DN = "cn=standalone repair," + TEST_ROOT_DN_STRING;
+ private static final String UUID = "5e7b9c1d-2a4f-4d6e-8b3c-9f0a1b2c3d4e";
+
+ @Test
+ public void aRepairedModifySetsTheEntryUUIDWithNoReplicationDomain() throws Exception
+ {
+ assertThat(MultimasterReplication.getNumberOfDomains())
+ .as("a replication domain is configured, so this case does not run where it means to")
+ .isZero();
+ TestCaseUtils.addEntry(
+ "dn: " + DN,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: standalone",
+ "cn: standalone repair");
+
+ try (LDAPConnectionFactory factory =
+ new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+ Connection connection = factory.getConnection())
+ {
+ connection.bind("cn=Directory Manager", "password".toCharArray());
+
+ try
+ {
+ connection.modify(entryUUIDRequest());
+ throw new AssertionError("entryUUID was modified without the repair control");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION);
+ }
+
+ connection.modify(entryUUIDRequest()
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+
+ assertThat(connection.searchSingleEntry(
+ Requests.newSearchRequest(DN, SearchScope.BASE_OBJECT, "(objectClass=*)").addAttribute("entryUUID"))
+ .parseAttribute("entryUUID").asString()).isEqualTo(UUID);
+ }
+ }
+
+ private static ModifyRequest entryUUIDRequest()
+ {
+ return Requests.newModifyRequest(DN).addModification(ModificationType.REPLACE, "entryUUID", UUID);
+ }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java
index c338df86ac..f16d7434eb 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java
@@ -13,26 +13,169 @@
*
* Copyright 2008-2010 Sun Microsystems, Inc.
* Portions Copyright 2015-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.replication.plugin;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.opends.messages.ReplicationMessages.ERR_OPERATION_NOT_FOUND_IN_PENDING;
+import static org.opends.server.TestCaseUtils.TEST_ROOT_DN_STRING;
+import static org.opends.server.replication.plugin.ReplicationRepairRequestControl.OID_REPLICATION_REPAIR_CONTROL;
+import static org.opends.server.types.NullOutputStream.nullPrintStream;
+import static org.testng.Assert.assertEquals;
+
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.GenericControl;
+import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl;
+import org.forgerock.opendj.ldap.requests.AddRequest;
+import org.forgerock.opendj.ldap.requests.ModifyRequest;
+import org.forgerock.opendj.ldap.requests.Requests;
+import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.opends.server.TestCaseUtils;
import org.opends.server.replication.ReplicationTestCase;
-import com.forgerock.opendj.ldap.tools.LDAPModify;
+import org.opends.server.replication.protocol.DeleteMsg;
+import org.opends.server.replication.protocol.LDAPUpdateMsg;
+import org.opends.server.replication.protocol.ModifyMsg;
+import org.opends.server.replication.protocol.ReplicationMsg;
+import org.opends.server.replication.service.ReplicationBroker;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
-import static org.opends.server.types.NullOutputStream.nullPrintStream;
-import static org.testng.Assert.*;
-import static org.opends.server.TestCaseUtils.*;
+import com.forgerock.opendj.ldap.tools.LDAPModify;
+/**
+ * Tests the replication repair request control, which lets an administrator write the
+ * attributes replication owns - {@code entryUUID}, {@code ds-sync-hist} - on the replica the
+ * request is sent to, and on that replica only.
+ *
+ * The suffix is replicated and a broker listens on its replication server: what a repair
+ * publishes, if anything, is read from there.
+ */
+@SuppressWarnings("javadoc")
public class ReplicationRepairControlTest extends ReplicationTestCase
{
+ private static final String REPAIRED_DN = "cn=repair me," + TEST_ROOT_DN_STRING;
+ private static final String HISTORY = "ds-sync-hist";
+ /**
+ * The values the repair gives the entry - not the ones {@link #testRepairControl()} gives to
+ * an entry of its own: two entries under one {@code entryUUID} is the corruption this control
+ * can cause, and a case reading by UUID would then pick either of them.
+ */
+ private static final String REPAIRED_UUID = "3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77";
+ private static final String REPAIRED_NS_UNIQUE_ID = "3d2a6b4c-7f1e4c62-9a0d5e8b-2c1f4a77";
+ /**
+ * The record the error logger writes carries the id of the message rather than its text, so
+ * what is looked for here does not depend on the locale the tests run under. The publishers
+ * on {@link TestCaseUtils#ERROR_TEXT_WRITER} write the plain ordinal, which the trailing
+ * space - the record always has {@code msg=} after the id - keeps from matching the
+ * ordinals of other messages which begin with the same digits.
+ */
+ private static final String NOT_IN_PENDING =
+ "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal() + " ";
+
+ /** A client which may write anything under the suffix, but has no {@code bypass-acl} privilege. */
+ private static final String USER_DN = "uid=repair.user," + TEST_ROOT_DN_STRING;
+ /** A client which may use any control, and act as {@link #USER_DN} through proxied authorization. */
+ private static final String PROXY_DN = "uid=repair.proxy," + TEST_ROOT_DN_STRING;
+ private static final String ACCESS_HANDLER_DN = "cn=Access Control Handler,cn=config";
+ /** The global ACI of the test configuration which lets anyone use any control. */
+ private static final String ANY_CONTROL_ACI = "(targetcontrol=\"*\")"
+ + " (version 3.0; acl \"Anonymous control access\"; allow(read) userdn=\"ldap:///anyone\";)";
+ /** Lets {@link #USER_DN} use the repair control, and no other control. */
+ private static final String REPAIR_CONTROL_ACI = "(targetcontrol=\"" + OID_REPLICATION_REPAIR_CONTROL + "\")"
+ + "(version 3.0; acl \"Repair control access\"; allow(read) userdn=\"ldap:///" + USER_DN + "\";)";
+
+ private ReplicationBroker broker;
+ private LDAPConnectionFactory factory;
+ private Connection connection;
+
+ @Override
+ @BeforeClass(alwaysRun = true)
+ public void setUp() throws Exception
+ {
+ super.setUp();
+
+ final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
+ TestCaseUtils.initializeTestBackend(true);
+
+ final int replServerPort = TestCaseUtils.findFreePort();
+ final String replServerLdif =
+ "dn: cn=Replication Server, " + SYNCHRO_PLUGIN_DN + "\n"
+ + "objectClass: top\n"
+ + "objectClass: ds-cfg-replication-server\n"
+ + "cn: Replication Server\n"
+ + "ds-cfg-replication-port: " + replServerPort + "\n"
+ + "ds-cfg-replication-db-directory: ReplicationRepairControlTest\n"
+ + "ds-cfg-replication-server-id: 106\n";
+ final String synchroServerLdif =
+ "dn: cn=replicationRepairControlTest, cn=domains, " + SYNCHRO_PLUGIN_DN + "\n"
+ + "objectClass: top\n"
+ + "objectClass: ds-cfg-replication-domain\n"
+ + "cn: replicationRepairControlTest\n"
+ + "ds-cfg-base-dn: " + baseDN + "\n"
+ + "ds-cfg-replication-server: localhost:" + replServerPort + "\n"
+ + "ds-cfg-server-id: 1\n"
+ + "ds-cfg-receive-status: true\n";
+ configureReplication(replServerLdif, synchroServerLdif);
+
+ broker = openReplicationSession(baseDN, 2, 100, replServerPort, 1000);
+
+ factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+ connection = factory.getConnection();
+ connection.bind("cn=Directory Manager", "password".toCharArray());
+
+ // The user may write anything under the suffix, operational attributes included: what
+ // decides whether it may repair is the access control of the controls alone.
+ TestCaseUtils.addEntry(
+ "dn: " + USER_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "uid: repair.user",
+ "sn: User",
+ "cn: Repair User",
+ "userPassword: password");
+ assertThat(nextUpdate()).as("the add of the user was not published").isNotNull();
+ connection.modify(Requests.newModifyRequest(TEST_ROOT_DN_STRING).addModification(ModificationType.ADD, "aci",
+ "(targetattr=\"*||+\")(version 3.0; acl \"Repair control test user\"; allow(all) userdn=\"ldap:///"
+ + USER_DN + "\";)"));
+ assertThat(nextUpdate()).as("the ACI of the user was not published").isNotNull();
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void tearDown() throws Exception
+ {
+ if (connection != null)
+ {
+ connection.close();
+ }
+ if (factory != null)
+ {
+ factory.close();
+ }
+ stop(broker);
+ }
+
@Test
public void testRepairControl()
throws Exception
{
- TestCaseUtils.initializeTestBackend(true);
-
// Test that we can't add an entry with the entryuuid attribute
// without specifying the replication repair control.
String path = TestCaseUtils.createTempFile(
@@ -95,7 +238,7 @@ public void testRepairControl()
"dn: uid=test.repair," + TEST_ROOT_DN_STRING + "\n" +
"changetype: add\n" +
"objectClass: top\n" +
- "objectClass: person" +
+ "objectClass: person\n" +
"objectClass: organizationalPerson\n" +
"objectClass: inetOrgPerson\n" +
"uid: test.repair\n" +
@@ -117,5 +260,497 @@ public void testRepairControl()
};
assertEquals(LDAPModify.run(nullPrintStream(), nullPrintStream(), args2), 0);
+
+ // A repair is a change to this replica alone: the add was not published.
+ assertThat(nextUpdate()).as("the repaired add was published to the topology").isNull();
+ }
+
+ /**
+ * An administrator setting the entryUUID of an entry - to give it back the value another
+ * directory had, say - does it with the repair control, on every replica in turn: the
+ * change is not published, and replication does not treat it as a change of its own either.
+ */
+ @Test
+ public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception
+ {
+ TestCaseUtils.addEntry(
+ "dn: " + REPAIRED_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: repair",
+ "cn: repair me");
+ assertThat(nextUpdate()).as("the add of the entry to repair was not published").isNotNull();
+
+ assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(REPAIRED_UUID);
+
+ // Without the control, entryUUID is NO-USER-MODIFICATION for an administrator too.
+ try
+ {
+ connection.modify(repairRequest(false));
+ throw new AssertionError("entryUUID was modified without the repair control");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION);
+ }
+
+ // The add of the entry was a replicated change, so the entry carries a history already:
+ // what the repair must leave alone is something rather than nothing.
+ final Set historyBeforeTheRepair = valuesOfRepairedEntry(HISTORY);
+ assertThat(historyBeforeTheRepair).as("the entry to repair carries no history").isNotEmpty();
+
+ TestCaseUtils.ERROR_TEXT_WRITER.clear();
+ try
+ {
+ connection.modify(repairRequest(true));
+ }
+ finally
+ {
+ // Read the history back on every road out of the modify, including the ones where it
+ // failed: the entry keeps the history it had, and a repair which ends badly must not
+ // have left a record of itself there either.
+ assertThat(valuesOfRepairedEntry(HISTORY))
+ .as("the repair was recorded in the history of the entry")
+ .isEqualTo(historyBeforeTheRepair);
+ }
+
+ assertThat(attributeOfRepairedEntry("entryUUID")).isEqualTo(REPAIRED_UUID);
+ assertThat(attributeOfRepairedEntry("nsUniqueId")).isEqualTo(REPAIRED_NS_UNIQUE_ID);
+ assertThat(nextUpdate()).as("the repair was published to the topology").isNull();
+ // The change is not a replayed one either: replication is not left looking for it among
+ // the changes it was replaying.
+ final List records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages());
+ assertThat(records).as("the repair was reported as a change missing from the pending list")
+ .noneMatch(record -> record.contains(NOT_IN_PENDING));
+ }
+
+ /**
+ * The procedure has the control sent as not critical, and on a modify it has to be: the
+ * backend refuses a critical control it does not know, and it does so before the replication
+ * plugin takes the control off the request.
+ */
+ @Test(dependsOnMethods = "aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly")
+ public void aCriticalRepairControlIsRefusedOnAModify() throws Exception
+ {
+ final ModifyRequest critical = Requests.newModifyRequest(REPAIRED_DN)
+ .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL, true));
+ try
+ {
+ connection.modify(critical);
+ throw new AssertionError("a critical repair control was accepted on a modify");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNAVAILABLE_CRITICAL_EXTENSION);
+ }
+ }
+
+ /** A repaired entry goes on replicating as any other, under its repaired entryUUID. */
+ @Test(dependsOnMethods = "aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly")
+ public void aChangeAfterTheRepairIsPublishedUnderTheRepairedEntryUUID() throws Exception
+ {
+ connection.modify(Requests.newModifyRequest(REPAIRED_DN)
+ .addModification(ModificationType.REPLACE, "description", "changed after the repair"));
+
+ final LDAPUpdateMsg published = nextUpdate();
+ assertThat(published).as("a change after the repair was not published").isInstanceOf(ModifyMsg.class);
+ assertThat(published.getEntryUUID()).isEqualTo(attributeOfRepairedEntry("entryUUID"));
+ }
+
+ /**
+ * A client which may write the entries, but which no ACI lets use the control, gets no repair
+ * out of it on any operation: the control is dropped, as any control the client may not use,
+ * and the request is processed as if it had not been sent. On a modify the backend does that
+ * itself; an add and a delete reach the replication plugin before the backend checks their
+ * controls, and they used to be repaired there all the same.
+ */
+ @Test
+ public void aClientNoAciLetsUseTheControlGetsNoRepair() throws Exception
+ {
+ final String dn = "cn=not repaired," + TEST_ROOT_DN_STRING;
+ TestCaseUtils.addEntry(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: not repaired",
+ "cn: not repaired");
+ assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull();
+
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null);
+ try (Connection user = connectAsUser())
+ {
+ try
+ {
+ user.modify(Requests.newModifyRequest(dn)
+ .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ throw new AssertionError("a client no ACI lets use the control repaired a modify");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION);
+ }
+
+ final String addedDN = "cn=added with the control," + TEST_ROOT_DN_STRING;
+ try
+ {
+ user.add(Requests.newAddRequest(
+ "dn: " + addedDN,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: added",
+ "cn: added with the control",
+ "entryUUID: " + REPAIRED_UUID)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ throw new AssertionError("a client no ACI lets use the control repaired an add");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM);
+ }
+ assertThat(nextUpdate()).as("a refused add was published").isNull();
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+ }
+
+ /**
+ * A repaired delete would remove the entry from this replica alone: dropped, the control
+ * leaves an ordinary delete, which the other replicas are told about.
+ */
+ @Test
+ public void aDeleteByAClientNoAciLetsUseTheControlIsPublished() throws Exception
+ {
+ final String dn = "cn=deleted with the control," + TEST_ROOT_DN_STRING;
+ TestCaseUtils.addEntry(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: deleted",
+ "cn: deleted with the control");
+ assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull();
+
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null);
+ try (Connection user = connectAsUser())
+ {
+ user.delete(Requests.newDeleteRequest(dn)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+ assertThat(nextUpdate()).as("the delete was not published").isInstanceOf(DeleteMsg.class);
+ }
+
+ /** A client an ACI lets use the control repairs as an administrator does. */
+ @Test
+ public void aClientAnAciLetsUseTheControlRepairs() throws Exception
+ {
+ final String dn = "cn=repaired by the user," + TEST_ROOT_DN_STRING;
+ final String uuid = "8f4c2e1a-6b3d-4a9e-b7c5-1d2e3f4a5b6c";
+
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(REPAIR_CONTROL_ACI);
+ try (Connection user = connectAsUser())
+ {
+ user.add(Requests.newAddRequest(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: repaired",
+ "cn: repaired by the user",
+ "entryUUID: " + uuid)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+
+ final SearchResultEntry added = connection.searchSingleEntry(
+ Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)").addAttribute("entryUUID"));
+ assertThat(added.parseAttribute("entryUUID").asString()).isEqualTo(uuid);
+ assertThat(nextUpdate()).as("the repaired add was published to the topology").isNull();
+ }
+
+ /**
+ * A control the client may not use is refused when it is critical: on a delete, which reaches
+ * the replication plugin before the backend checks its controls, the plugin must leave it on
+ * the request for the backend to refuse, rather than drop it.
+ */
+ @Test
+ public void aCriticalRepairControlAClientMayNotUseIsRefusedOnADelete() throws Exception
+ {
+ final String dn = "cn=kept by a critical control," + TEST_ROOT_DN_STRING;
+ TestCaseUtils.addEntry(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: kept",
+ "cn: kept by a critical control");
+ assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull();
+
+ ResultCode resultCode = ResultCode.SUCCESS;
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null);
+ try (Connection user = connectAsUser())
+ {
+ user.delete(Requests.newDeleteRequest(dn)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL, true)));
+ }
+ catch (LdapException e)
+ {
+ resultCode = e.getResult().getResultCode();
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+ // Read first: a delete carried out is published, and must not be left for the next case.
+ assertThat(nextUpdate()).as("a critical control the client may not use was dropped, and the delete published")
+ .isNull();
+ assertThat(resultCode).isEqualTo(ResultCode.UNAVAILABLE_CRITICAL_EXTENSION);
+ }
+
+ /**
+ * The ACI of the controls is evaluated at the entry being repaired: on a delete, and on an add
+ * too, where the backend evaluates the other controls of the request at the parent entry.
+ */
+ @Test
+ public void theRepairControlAciIsJudgedAtTheEntryBeingRepaired() throws Exception
+ {
+ final String repairable = "cn=repairable delete," + TEST_ROOT_DN_STRING;
+ final String other = "cn=not repairable delete," + TEST_ROOT_DN_STRING;
+ for (String dn : new String[] { repairable, other })
+ {
+ final String cn = dn.substring("cn=".length(), dn.indexOf(','));
+ TestCaseUtils.addEntry("dn: " + dn, "objectClass: top", "objectClass: person", "sn: " + cn, "cn: " + cn);
+ assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull();
+ }
+ final String repairableAdd = "cn=repairable add," + TEST_ROOT_DN_STRING;
+ final String otherAdd = "cn=not repairable add," + TEST_ROOT_DN_STRING;
+ final String uuid = "5b1d7e3a-2c4f-4e8b-9a6d-0f1e2d3c4b5a";
+
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(
+ "(target=\"ldap:///cn=repairable*," + TEST_ROOT_DN_STRING + "\")"
+ + "(targetcontrol=\"" + OID_REPLICATION_REPAIR_CONTROL + "\")"
+ + "(version 3.0; acl \"Repair control on some entries\"; allow(read) userdn=\"ldap:///" + USER_DN + "\";)");
+ try (Connection user = connectAsUser())
+ {
+ user.delete(Requests.newDeleteRequest(other)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ assertThat(nextUpdate()).as("a delete outside the ACI's target was repaired").isInstanceOf(DeleteMsg.class);
+ user.delete(Requests.newDeleteRequest(repairable)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ assertThat(nextUpdate()).as("a delete inside the ACI's target was published").isNull();
+
+ try
+ {
+ user.add(personWithEntryUUID(otherAdd, uuid)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ throw new AssertionError("an add outside the ACI's target was repaired");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM);
+ }
+ try
+ {
+ user.add(personWithEntryUUID(repairableAdd, uuid)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ }
+ catch (LdapException e)
+ {
+ throw new AssertionError("an add inside the ACI's target was not repaired", e);
+ }
+ assertThat(nextUpdate()).as("an add inside the ACI's target was published").isNull();
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+ }
+
+ /**
+ * On an add and a delete the replication plugin runs before the backend applies a proxied
+ * authorization control, so it could only judge the bound client: it refuses the repair there,
+ * even to a client which may use any control, and the request is processed as if the control
+ * had not been sent.
+ */
+ @Test
+ public void aProxiedAddOrDeleteIsNotRepaired() throws Exception
+ {
+ TestCaseUtils.addEntry(
+ "dn: " + PROXY_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "uid: repair.proxy",
+ "sn: Proxy",
+ "cn: Repair Proxy",
+ "userPassword: password",
+ "ds-privilege-name: bypass-acl",
+ "ds-privilege-name: proxied-auth");
+ assertThat(nextUpdate()).as("the add of the proxy was not published").isNotNull();
+ final String dn = "cn=deleted through a proxy," + TEST_ROOT_DN_STRING;
+ TestCaseUtils.addEntry(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: deleted",
+ "cn: deleted through a proxy");
+ assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull();
+ final ProxiedAuthV2RequestControl asUser = ProxiedAuthV2RequestControl.newControl("dn:" + USER_DN);
+
+ final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null);
+ try (Connection proxy = factory.getConnection())
+ {
+ proxy.bind(PROXY_DN, "password".toCharArray());
+ try
+ {
+ proxy.add(personWithEntryUUID("cn=added through a proxy," + TEST_ROOT_DN_STRING, REPAIRED_UUID)
+ .addControl(asUser)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ throw new AssertionError("a proxied add was repaired");
+ }
+ catch (LdapException e)
+ {
+ assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM);
+ }
+ assertThat(nextUpdate()).as("a refused add was published").isNull();
+
+ proxy.delete(Requests.newDeleteRequest(dn)
+ .addControl(asUser)
+ .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)));
+ assertThat(nextUpdate()).as("a proxied delete was repaired").isInstanceOf(DeleteMsg.class);
+ }
+ finally
+ {
+ restoreTheGlobalControlAci.run();
+ }
+ }
+
+ private static AddRequest personWithEntryUUID(String dn, String uuid)
+ {
+ final String cn = dn.substring("cn=".length(), dn.indexOf(','));
+ return Requests.newAddRequest(
+ "dn: " + dn,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: " + cn,
+ "cn: " + cn,
+ "entryUUID: " + uuid);
+ }
+
+ /**
+ * Takes the global ACI which lets anyone use any control out of the configuration, and puts
+ * {@code aci} there instead when it is not {@code null}.
+ *
+ * @return what puts the configuration back as it was
+ */
+ private Runnable withGlobalControlAci(String aci) throws Exception
+ {
+ connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+ .addModification(ModificationType.DELETE, "ds-cfg-global-aci", ANY_CONTROL_ACI));
+ if (aci != null)
+ {
+ connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+ .addModification(ModificationType.ADD, "ds-cfg-global-aci", aci));
+ }
+ return () ->
+ {
+ try
+ {
+ if (aci != null)
+ {
+ connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+ .addModification(ModificationType.DELETE, "ds-cfg-global-aci", aci));
+ }
+ connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN)
+ .addModification(ModificationType.ADD, "ds-cfg-global-aci", ANY_CONTROL_ACI));
+ }
+ catch (LdapException e)
+ {
+ throw new AssertionError("the global ACI of the controls could not be put back", e);
+ }
+ };
+ }
+
+ private Connection connectAsUser() throws Exception
+ {
+ final Connection user = factory.getConnection();
+ user.bind(USER_DN, "password".toCharArray());
+ return user;
+ }
+
+ private static ModifyRequest repairRequest(boolean withRepairControl)
+ {
+ final ModifyRequest request = Requests.newModifyRequest(REPAIRED_DN)
+ .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID)
+ .addModification(ModificationType.REPLACE, "nsUniqueId", REPAIRED_NS_UNIQUE_ID);
+ if (withRepairControl)
+ {
+ // Not critical: the control is taken off the request by the replication plugin, which
+ // runs after the backend has refused a critical control it does not know.
+ request.addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL));
+ }
+ return request;
+ }
+
+ private String attributeOfRepairedEntry(String attribute) throws Exception
+ {
+ final Set values = valuesOfRepairedEntry(attribute);
+ return values.isEmpty() ? null : values.iterator().next();
+ }
+
+ /**
+ * The attribute is asked for by name as well as by {@code *} and {@code +}: an operational
+ * attribute is not always among the ones those two stand for, and a read which came back
+ * empty would leave the assertions on it comparing nothing with nothing.
+ */
+ private Set valuesOfRepairedEntry(String attribute) throws Exception
+ {
+ final SearchResultEntry entry = connection.searchSingleEntry(
+ Requests.newSearchRequest(REPAIRED_DN, SearchScope.BASE_OBJECT, "(objectClass=*)")
+ .addAttribute("*", "+", attribute));
+ return entry.containsAttribute(attribute)
+ ? new HashSet<>(entry.parseAttribute(attribute).asSetOfString())
+ : Collections. emptySet();
+ }
+
+ /**
+ * The next update the replication server forwards, or {@code null} if none comes within a
+ * few seconds - long enough for a change published by the operation which just returned,
+ * which the replication server forwards as soon as it has it.
+ */
+ private LDAPUpdateMsg nextUpdate() throws Exception
+ {
+ final long deadline = System.nanoTime() + SECONDS.toNanos(4);
+ while (deadline - System.nanoTime() > 0)
+ {
+ final ReplicationMsg msg;
+ try
+ {
+ msg = broker.receive();
+ }
+ catch (SocketTimeoutException e)
+ {
+ // The broker reads under a timeout of its own, shorter than the budget here.
+ continue;
+ }
+ if (msg == null)
+ {
+ // The broker reconnects a session it lost, and returns null only once it is stopped.
+ throw new AssertionError("the broker was stopped");
+ }
+ if (msg instanceof LDAPUpdateMsg)
+ {
+ return (LDAPUpdateMsg) msg;
+ }
+ }
+ return null;
}
}