Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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!:
Expand Down Expand Up @@ -1986,3 +1986,70 @@ 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.

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.

Original file line number Diff line number Diff line change
Expand Up @@ -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. 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::
+
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -161,6 +163,15 @@ public static LDAPReplicationDomain findDomain(DN dn, PluginOperation pluginOp)
Control c = it.next();
if (OID_REPLICATION_REPAIR_CONTROL.equals(c.getOID()))
{
if (!mayUseRepairControl(dn, op, c))
{
/*
Leave the control on the request: the backend drops it when it is not critical,
and refuses the request when it is, as it does with any control the client may
not use.
*/
break;
}
op.setSynchronizationOperation(true);
op.setDontSynchronize(true);
/*
Expand Down Expand Up @@ -196,6 +207,50 @@ public static LDAPReplicationDomain findDomain(DN dn, PluginOperation pluginOp)
return domain;
}

/**
* Whether the client may use the repair control on this operation, as the access control of
* the controls decides it: a client with the {@code bypass-acl} privilege, or one an ACI allows
* to use the control. The question is asked at the entry being repaired, on every operation:
* on an add as well, where the backend judges the other controls at the parent entry.
* <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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading