From 9fdfdb765cd5ce18e7d37e75069a69b8daf1eb0b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 15 Sep 2026 22:40:03 +0300 Subject: [PATCH 1/4] [#1050] Document the replication repair control, and test that a repaired modify stays on its replica The control that lets an administrator write entryUUID and ds-sync-hist was only listed by its OID among the supported controls. The reference appendix now names it and says what it does, and the replication chapter of the administration guide shows how to give an entry back the entryUUID another directory assigned to it - on every replica in turn, since the change is not published. ReplicationRepairControlTest covered the add case only, on a suffix that was not replicated. It now runs over a replicated suffix with a broker listening on the replication server, and checks that a modify of entryUUID and nsUniqueId under the control succeeds where the same modify without it is refused, reaches neither the replication server nor the error log, and leaves the entry replicating as any other under its repaired entryUUID. A line-break missing from the LDIF of the add case is restored as well. --- .../admin-guide/chap-replication.adoc | 61 ++++- .../asciidoc/reference/appendix-controls.adoc | 8 + .../docbkx/admin-guide/appendix-controls.xml | 16 ++ .../plugin/ReplicationRepairControlTest.java | 223 +++++++++++++++++- 4 files changed, 300 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 6e5a78cf17..e2ccb13502 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,62 @@ You can therefore compare the current configuration with the earlier configurati +[#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. + +[#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: ++ + +[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 +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: the backend refuses a critical control it does not know before the replication plugin takes the control off the request. + +. 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. +====== + +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 be5613ec72..eb9a5122a5 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 @@ Object Identifier: 1.3.6.1.4.1.26027.1.5.4 + 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. 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 10679fa1d3..be1a0f923d 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,22 @@ + + Replication Repair request control + + + LDAP controls + Replication repair + + 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. Send it as not critical. + + + Server Side Sort Request Control 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..0bff32c58c 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,122 @@ * * 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.List; + +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.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.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; + /** + * 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. + */ + private static final String NOT_IN_PENDING = + "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal(); + + 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()); + } + + @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 +191,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 +213,118 @@ 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(); + + final String repairedUUID = "d5b910d8-47cb-4ac0-9e5f-0f4a77de58d4"; + final String repairedNsUniqueId = "d5b910d8-47cb4ac0-9e5f0f4a-77de58d4"; + assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(repairedUUID); + + // Without the control, entryUUID is NO-USER-MODIFICATION for an administrator too. + try + { + connection.modify(repairRequest(false, repairedUUID, repairedNsUniqueId)); + throw new AssertionError("entryUUID was modified without the repair control"); + } + catch (LdapException e) + { + assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION); + } + + TestCaseUtils.ERROR_TEXT_WRITER.clear(); + connection.modify(repairRequest(true, repairedUUID, repairedNsUniqueId)); + + assertThat(attributeOfRepairedEntry("entryUUID")).isEqualTo(repairedUUID); + assertThat(attributeOfRepairedEntry("nsUniqueId")).isEqualTo(repairedNsUniqueId); + 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)); + } + + /** 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")); + } + + private static ModifyRequest repairRequest(boolean withRepairControl, String entryUUID, String nsUniqueId) + { + final ModifyRequest request = Requests.newModifyRequest(REPAIRED_DN) + .addModification(ModificationType.REPLACE, "entryUUID", entryUUID) + .addModification(ModificationType.REPLACE, "nsUniqueId", nsUniqueId); + 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 SearchResultEntry entry = connection.searchSingleEntry( + Requests.newSearchRequest(REPAIRED_DN, SearchScope.BASE_OBJECT, "(objectClass=*)") + .addAttribute("*", "+")); + return entry.containsAttribute(attribute) ? entry.parseAttribute(attribute).asString() : null; + } + + /** + * 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) + { + throw new AssertionError("the broker session is gone"); + } + if (msg instanceof LDAPUpdateMsg) + { + return (LDAPUpdateMsg) msg; + } + } + return null; } } From 49a9f2b7ee65cad2067ee8709ca78b0fc45e05e2 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Wed, 23 Sep 2026 12:31:19 +0300 Subject: [PATCH 2/4] [#1050] Say what else the repair control turns off, and what a partial repair costs Review round 2. The chapter and both appendix entries now say that the control marks the whole request a synchronization operation: schema and value-syntax checking, the pre-operation plugins and password policy handling do not run, so a mistyped value is accepted and a userPassword value is stored in the clear. The warning about repairing some replicas and not others says what that costs while it lasts - every replicated change to the entry is dropped in either direction with no error-log record, a delete included - and the example spells the criticality of the control out. ReplicationRepairControlTest gives the repaired entry values of its own rather than the ones the add case uses for another entry, matches the message id exactly, asserts in a finally that the repair left the history of the entry alone, and covers the chapter's rule that the control goes out as not critical. The history is read by name and as a whole value set: through "*" and "+" alone it came back empty, which left the assertion comparing nothing with nothing. --- .../admin-guide/chap-replication.adoc | 8 +- .../asciidoc/reference/appendix-controls.adoc | 2 +- .../docbkx/admin-guide/appendix-controls.xml | 9 +- .../plugin/ReplicationRepairControlTest.java | 92 ++++++++++++++++--- 4 files changed, 93 insertions(+), 18 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 e2ccb13502..4f0da02473 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 @@ -1993,6 +1993,8 @@ Replication owns a few operational attributes: `entryUUID`, which identifies an 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. + [#repair-entry-uuid] .To Set the Entry UUID of an Entry ==== @@ -2006,7 +2008,7 @@ $ ldapmodify \ --port 1389 \ --bindDN "cn=Directory Manager" \ --bindPassword password \ - --control 1.3.6.1.4.1.26027.1.5.2 + --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 @@ -2016,7 +2018,7 @@ 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: the backend refuses a critical control it does not know before the replication plugin takes the control off the request. +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: + @@ -2041,6 +2043,8 @@ 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 delete: the entry stays on the replicas the deletion did not reach. This is why the repair of each replica is a step of the same procedure, run while nothing else changes the entry. ====== 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 eb9a5122a5..7bb3d9b3e7 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 @@ -203,7 +203,7 @@ 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. 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__. +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. 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 be1a0f923d..408deb9c76 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 @@ -355,7 +355,14 @@ 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. Send it as not critical. + 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. Put nothing but the repair in + the request, and send it as not critical. 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 0bff32c58c..ce19d018dc 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 @@ -27,7 +27,10 @@ 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; @@ -64,12 +67,23 @@ 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. + * 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(); + "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal() + " "; private ReplicationBroker broker; private LDAPConnectionFactory factory; @@ -234,14 +248,12 @@ public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception "cn: repair me"); assertThat(nextUpdate()).as("the add of the entry to repair was not published").isNotNull(); - final String repairedUUID = "d5b910d8-47cb-4ac0-9e5f-0f4a77de58d4"; - final String repairedNsUniqueId = "d5b910d8-47cb4ac0-9e5f0f4a-77de58d4"; - assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(repairedUUID); + assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(REPAIRED_UUID); // Without the control, entryUUID is NO-USER-MODIFICATION for an administrator too. try { - connection.modify(repairRequest(false, repairedUUID, repairedNsUniqueId)); + connection.modify(repairRequest(false)); throw new AssertionError("entryUUID was modified without the repair control"); } catch (LdapException e) @@ -249,11 +261,28 @@ public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception 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(); - connection.modify(repairRequest(true, repairedUUID, repairedNsUniqueId)); + 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(repairedUUID); - assertThat(attributeOfRepairedEntry("nsUniqueId")).isEqualTo(repairedNsUniqueId); + 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. @@ -262,6 +291,28 @@ public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception .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 @@ -274,11 +325,11 @@ public void aChangeAfterTheRepairIsPublishedUnderTheRepairedEntryUUID() throws E assertThat(published.getEntryUUID()).isEqualTo(attributeOfRepairedEntry("entryUUID")); } - private static ModifyRequest repairRequest(boolean withRepairControl, String entryUUID, String nsUniqueId) + private static ModifyRequest repairRequest(boolean withRepairControl) { final ModifyRequest request = Requests.newModifyRequest(REPAIRED_DN) - .addModification(ModificationType.REPLACE, "entryUUID", entryUUID) - .addModification(ModificationType.REPLACE, "nsUniqueId", nsUniqueId); + .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 @@ -289,11 +340,24 @@ private static ModifyRequest repairRequest(boolean withRepairControl, String ent } 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("*", "+")); - return entry.containsAttribute(attribute) ? entry.parseAttribute(attribute).asString() : null; + .addAttribute("*", "+", attribute)); + return entry.containsAttribute(attribute) + ? new HashSet<>(entry.parseAttribute(attribute).asSetOfString()) + : Collections. emptySet(); } /** From b2b9aabb40a45725f60c27c1f6773bfc1ca00a66 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 24 Sep 2026 12:03:08 +0300 Subject: [PATCH 3/4] [#1050] Check the repair control against the ACI of the controls on an add and a delete, and say what a partial repair does to the entries below On an add and a delete the replication plugin sees the request before the backend checks its controls, and it took the repair control off the request without asking whether the client may use it. findDomain() now asks the access control handler first, and leaves a control the client may not use for the backend to drop or refuse, as on a modify. Also: a test of the control on a server with no replication domain, the ACI and subtree cases in the chapter and both appendix entries, and the message of nextUpdate() when the broker returns null. --- .../admin-guide/chap-replication.adoc | 8 +- .../asciidoc/reference/appendix-controls.adoc | 2 +- .../docbkx/admin-guide/appendix-controls.xml | 7 +- .../plugin/MultimasterReplication.java | 32 +++ ...eplicationRepairControlStandaloneTest.java | 92 +++++++++ .../plugin/ReplicationRepairControlTest.java | 191 +++++++++++++++++- 6 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlStandaloneTest.java 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 4f0da02473..17a2561207 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 @@ -1995,10 +1995,12 @@ The replication repair request control (OID `1.3.6.1.4.1.26027.1.5.2`) lets an a 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. + [#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: +. Apply the change to each replica in turn, while nothing else changes the entry or the entries below it: + [source, console] @@ -2044,7 +2046,9 @@ entryUUID: 3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77 ====== 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 delete: the entry stays on the replicas the deletion did not reach. This is why the repair of each replica is a step of the same procedure, run while nothing else changes the entry. +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 7bb3d9b3e7..5907e39e06 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 @@ -203,7 +203,7 @@ 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. 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__. +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; for any other client 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 408deb9c76..4abfd38f1b 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 @@ -361,8 +361,11 @@ 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. Put nothing but the repair in - the request, and send it as not critical. + 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; for any other client 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. 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..e4dbbb6a27 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; @@ -161,6 +162,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 +206,28 @@ 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 backend asks the same question of every control, but an add or a delete reaches the + * replication plugin before it does, and by then the plugin has taken the control off the + * request - so on those operations the answer given here is the only one. + */ + private static boolean mayUseRepairControl(DN dn, Operation op, Control control) + { + try + { + return AccessControlConfigManager.getInstance().getAccessControlHandler().isAllowed(dn, op, control); + } + catch (DirectoryException e) + { + logger.traceException(e); + 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 ce19d018dc..fda410efe7 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 @@ -45,6 +45,7 @@ import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.opends.server.TestCaseUtils; import org.opends.server.replication.ReplicationTestCase; +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; @@ -85,6 +86,16 @@ public class ReplicationRepairControlTest extends ReplicationTestCase 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; + 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; @@ -123,6 +134,24 @@ public void setUp() throws Exception 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) @@ -325,6 +354,165 @@ public void aChangeAfterTheRepairIsPublishedUnderTheRepairedEntryUUID() throws E 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(); + } + + /** + * 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) @@ -382,7 +570,8 @@ private LDAPUpdateMsg nextUpdate() throws Exception } if (msg == null) { - throw new AssertionError("the broker session is gone"); + // 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) { From f4b154560d56f111cdbfebe5cb1472854800638e Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 24 Sep 2026 18:42:03 +0300 Subject: [PATCH 4/4] [#1050] Refuse the repair on a proxied add or delete, and judge the control's ACI at the entry being repaired On an add and a delete the replication plugin sees the repair control before the backend applies proxied authorization, so it could only judge the bound client: the repair is refused there. The javadoc and the docs now say the ACI is evaluated at the entry being repaired, on an add too. New cases pin the proxied refusal, the critical arm of the refusal, and the DN the ACI is judged at. --- .../admin-guide/chap-replication.adoc | 2 +- .../asciidoc/reference/appendix-controls.adoc | 2 +- .../docbkx/admin-guide/appendix-controls.xml | 8 +- .../plugin/MultimasterReplication.java | 31 +++- .../plugin/ReplicationRepairControlTest.java | 173 ++++++++++++++++++ 5 files changed, 207 insertions(+), 9 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 17a2561207..ddee29c327 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 @@ -1995,7 +1995,7 @@ The replication repair request control (OID `1.3.6.1.4.1.26027.1.5.2`) lets an a 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 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 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 5907e39e06..f4ed85ffdf 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 @@ -203,7 +203,7 @@ 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; for any other client 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__. +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 4abfd38f1b..09b22f0996 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 @@ -363,9 +363,11 @@ 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; for any other client 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. + 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. 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 e4dbbb6a27..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 @@ -65,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; @@ -209,14 +210,24 @@ public static LDAPReplicationDomain findDomain(DN dn, PluginOperation pluginOp) /** * 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. + * 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. *

- * The backend asks the same question of every control, but an add or a delete reaches the - * replication plugin before it does, and by then the plugin has taken the control off the - * request - so on those operations the answer given here is the only one. + * 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); @@ -228,6 +239,18 @@ private static boolean mayUseRepairControl(DN dn, Operation op, Control control) } } + 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/ReplicationRepairControlTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java index fda410efe7..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 @@ -40,6 +40,8 @@ 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; @@ -88,6 +90,8 @@ public class ReplicationRepairControlTest extends ReplicationTestCase /** 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=\"*\")" @@ -472,6 +476,175 @@ public void aClientAnAciLetsUseTheControlRepairs() throws Exception 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}.