From 490181201bfd34a4ca940caca019e49ef824f921 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 25 Sep 2026 06:25:43 +0000
Subject: [PATCH] [#1050] Document the replication repair control, and test that a repaired modify stays on its replica (#1052)
---
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java | 649 ++++++++++++++++++++++++++++++++++++++++
opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml | 28 +
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/MultimasterReplication.java | 55 +++
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlStandaloneTest.java | 92 +++++
opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc | 8
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc | 69 ++++
6 files changed, 893 insertions(+), 8 deletions(-)
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc
index 6e5a78c..ddee29c 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc
@@ -12,7 +12,7 @@
information: "Portions copyright [year] [name of copyright owner]".
Copyright 2017 ForgeRock AS.
- Portions Copyright 2024 3A Systems LLC.
+ Portions Copyright 2024-2026 3A Systems LLC.
////
:figure-caption!:
@@ -1986,3 +1986,70 @@
+[#repl-repair-entry]
+=== Repairing an Entry on One Replica
+
+Replication owns a few operational attributes: `entryUUID`, which identifies an entry across replicas, and `ds-sync-hist`, which records the changes to an entry for conflict resolution. Both are `NO-USER-MODIFICATION` in the schema, so a modify request that touches them is refused, even one from `cn=Directory Manager`.
+
+The replication repair request control (OID `1.3.6.1.4.1.26027.1.5.2`) lets an administrator write these attributes anyway. A request that carries the control changes the replica it is sent to, and that replica only: the change is not published to the replication servers, and it is not recorded in the history of the entry. Use it when the replicas already agree on the data, but an entry must be given specific values - for example, the `entryUUID` that another directory service assigned to it before a migration, and that client applications still hold.
+
+The control marks the whole request a synchronization operation, so it turns off more than the `NO-USER-MODIFICATION` refusal. Nothing the request carries is checked against the schema, and no value is checked against the syntax of its attribute: a mistyped `entryUUID` is stored as it is given and the request still returns success. The pre-operation plugins do not run either - attribute uniqueness and referential integrity are not enforced, and `modifiersName` and `modifyTimestamp` keep the values they had, so the entry does not record that it was repaired. Neither does password policy handling run: a `userPassword` value in such a request is stored as it is given, in the clear, rather than encoded. Put nothing but the repair in the request.
+
+The server honours the control only for a client that has the `bypass-acl` privilege, as the root DNs do, or that an ACI allows to use it (`targetcontrol="1.3.6.1.4.1.26027.1.5.2"`); none of the default global ACIs does. For any other client the server drops the control, which is not critical, and processes the request as if the control had not been sent: a modify of `entryUUID` is refused with the same error as a request without the control, and a delete sent with the control is carried out and replicated as any other. The ACI is evaluated at the entry being repaired, on an add as well, where the server evaluates the other controls of the request at the parent entry. On an add or a delete that also carries a proxied authorization control, the server does not honour the repair control at all, and treats it as it does for a client no ACI allows to use it: on those two operations it would otherwise be judged for the bound client rather than for the identity the request runs as.
+
+[#repair-entry-uuid]
+.To Set the Entry UUID of an Entry
+====
+. Apply the change to each replica in turn, while nothing else changes the entry or the entries below it:
++
+
+[source, console]
+----
+$ ldapmodify \
+ --hostname opendj.example.com \
+ --port 1389 \
+ --bindDN "cn=Directory Manager" \
+ --bindPassword password \
+ --control "1.3.6.1.4.1.26027.1.5.2:false"
+dn: cn=My Group,ou=Groups,dc=example,dc=com
+changetype: modify
+replace: entryUUID
+entryUUID: 3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77
+
+Processing MODIFY request for cn=My Group,ou=Groups,dc=example,dc=com
+MODIFY operation successful for DN cn=My Group,ou=Groups,dc=example,dc=com
+----
++
+Send the control as not critical, which is what `--control` does unless told otherwise: on a modify the backend refuses a critical control it does not know before the replication plugin takes the control off the request, and the request fails with result code 12 (`Unavailable Critical Extension`).
+
+. Check that every replica now returns the same value:
++
+
+[source, console]
+----
+$ ldapsearch \
+ --hostname opendj.example.com \
+ --port 1389 \
+ --bindDN "cn=Directory Manager" \
+ --bindPassword password \
+ --baseDN "cn=My Group,ou=Groups,dc=example,dc=com" \
+ --searchScope base \
+ "(objectClass=*)" \
+ entryUUID
+dn: cn=My Group,ou=Groups,dc=example,dc=com
+entryUUID: 3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77
+----
+
+====
+
+[WARNING]
+======
+A repair that reaches some replicas and not others leaves the topology inconsistent. A replica initialized from one that was not repaired takes the old values with it, and when replication later resolves a conflict on the entry by its `entryUUID`, the replicas no longer agree on which entry that is.
+
+Until every replica carries the repaired value, a change made to the entry on one side of the difference is also discarded on the other, in either direction: the replayed change names the entry by the `entryUUID` of the replica it came from, the receiving replica finds no entry with that value, and the change is dropped without an error-log record - only the `resolved-naming-conflicts` monitor attribute moves. A modify is lost this way, and so is a rename of the entry, and so is a delete: the entry stays on the replicas the deletion did not reach.
+
+The entries below the repaired entry are named by the `entryUUID` of their parent, and they do not fare better, although here replication does raise an alert and counts the change in the `unresolved-naming-conflicts` monitor attribute. An entry added under the repaired entry on one side of the difference is added on the other side as a conflict entry, with a new RDN, directly under the base DN. An entry moved under the repaired entry on one side stays where it was on the other, marked as a conflict entry. This is why the repair of each replica is a step of the same procedure, run while nothing else changes the entry or the entries below it.
+======
+
+Once the replicas agree again, the entry replicates as any other: a change to it is published with the repaired `entryUUID`, and no reinitialization is needed.
+
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
index be5613e..f4ed85f 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc
@@ -197,6 +197,14 @@
+
OpenDJ specific, for using the bookmark cookie when reading the external change log.
+[#replication-repair-request-control]
+Replication Repair request control::
++
+Object Identifier: 1.3.6.1.4.1.26027.1.5.2
+
++
+OpenDJ specific, for writing the attributes replication owns, such as `entryUUID` and `ds-sync-hist`, on the replica the request is sent to, and on that replica only: the change is neither published to the replication servers nor recorded in the history of the entry. The control marks the whole request a synchronization operation, so schema and value-syntax checking, the pre-operation plugins - attribute uniqueness, referential integrity, `modifiersName` and `modifyTimestamp` - and password policy handling do not run for it either: a mistyped value is accepted without an error, and a `userPassword` value is stored in the clear rather than encoded. The server honours the control only for a client that has the `bypass-acl` privilege, or that an ACI allows to use it at the entry being repaired, and never on an add or a delete that also carries a proxied authorization control; otherwise it drops the control and processes the request as if the control had not been sent. Put nothing but the repair in the request, and send it as not critical. See xref:../admin-guide/chap-replication.adoc#repl-repair-entry["Repairing an Entry on One Replica"] in the __Administration Guide__.
+
[#server-side-sort-request-control]
Server-Side Sort request control::
+
diff --git a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
index 10679fa..09b22f0 100644
--- a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
+++ b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml
@@ -343,6 +343,34 @@
</listitem>
</varlistentry>
+ <varlistentry xml:id="replication-repair-request-control">
+ <term>Replication Repair request control</term>
+ <listitem>
+ <indexterm>
+ <primary>LDAP controls</primary>
+ <secondary>Replication repair</secondary>
+ </indexterm>
+ <para>Object Identifier: 1.3.6.1.4.1.26027.1.5.2</para>
+ <para>OpenDJ specific, for writing the attributes replication owns,
+ such as <literal>entryUUID</literal> and <literal>ds-sync-hist</literal>,
+ on the replica the request is sent to, and on that replica only: the
+ change is neither published to the replication servers nor recorded in
+ the history of the entry. The control marks the whole request a
+ synchronization operation, so schema and value-syntax checking, the
+ pre-operation plugins - attribute uniqueness, referential integrity,
+ <literal>modifiersName</literal> and <literal>modifyTimestamp</literal> -
+ and password policy handling do not run for it either: a mistyped value
+ is accepted without an error, and a <literal>userPassword</literal> value
+ is stored in the clear rather than encoded. The server honours the control
+ only for a client that has the <literal>bypass-acl</literal> privilege, or
+ that an ACI allows to use it at the entry being repaired, and never on an
+ add or a delete that also carries a proxied authorization control;
+ otherwise it drops the control and processes the request as if the
+ control had not been sent. Put nothing but the repair in the request, and
+ send it as not critical.</para>
+ </listitem>
+ </varlistentry>
+
<varlistentry xml:id="server-side-sort-request-control">
<term>Server Side Sort Request Control</term>
<listitem>
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 3e1f4a9..49181cf 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 @@
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);
/*
@@ -197,6 +208,50 @@
}
/**
+ * 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.
+ * <p>
+ * 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
* fully operational when this method returns.
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 0000000..63e4253
--- /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.
+ * <p>
+ * {@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 c338df8..f16d743 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.
+ * <p>
+ * 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 @@
"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 @@
};
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<String> 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<String> 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<String> 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<String> 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.<String> 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;
}
}
--
Gitblit v1.10.0