From dd94891ad6946da5646fffdf11631144725b5636 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Thu, 24 Sep 2026 08:03:16 +0000
Subject: [PATCH] [#1051] Replicate a change made under the Relax Rules control like any other (#1053)
---
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java | 245 +++++++++++++++
opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java | 9
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java | 62 ++-
opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml | 13
opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java | 143 +++++---
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java | 31 +
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java | 66 +++
opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc | 7
opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java | 339 +++++++++++++++++++++
9 files changed, 822 insertions(+), 93 deletions(-)
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 af531d8..be5613e 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
@@ -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!:
@@ -264,7 +264,10 @@
Object Identifier: 1.3.6.1.4.1.4203.666.5.12
+
-Internet-Draft: link:https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03[ddraft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control, window=\_top]
+Internet-Draft: link:https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03[draft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control, window=\_top]
+
++
+Lets a client with the `bypass-acl` privilege add or modify attributes marked `NO-USER-MODIFICATION` or `OBSOLETE`, and skips the schema check of the resulting entry. The values the client supplies for `creatorsName`, `createTimestamp`, `modifiersName`, `modifyTimestamp` and `pwdChangedTime` are kept, and a pre-encoded password is accepted. The change is an ordinary one otherwise: it is logged, and it is replicated like any other.
--
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 5e88d56..10679fa 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
@@ -21,7 +21,7 @@
! CCPL HEADER END
!
! Copyright 2011 ForgeRock AS
- ! Portions copyright 2024 3A Systems,LLC.
+ ! Portions copyright 2024-2026 3A Systems,LLC.
!
-->
<appendix xml:id='appendix-controls'
@@ -457,7 +457,16 @@
<para>Object Identifier: 1.3.6.1.4.1.4203.666.5.12</para>
<para>Internet-Draft: <link
xlink:href='https://tools.ietf.org/html/draft-zeilenga-ldap-relax-03'
- >ddraft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control</link></para>
+ >draft-zeilenga-ldap-relax-03 - The LDAP Relax Rules Control</link></para>
+ <para>Lets a client with the <literal>bypass-acl</literal> privilege add
+ or modify attributes marked <literal>NO-USER-MODIFICATION</literal> or
+ <literal>OBSOLETE</literal>, and skips the schema check of the resulting
+ entry. The values the client supplies for <literal>creatorsName</literal>,
+ <literal>createTimestamp</literal>, <literal>modifiersName</literal>,
+ <literal>modifyTimestamp</literal> and <literal>pwdChangedTime</literal>
+ are kept, and a pre-encoded password is accepted. The change is an
+ ordinary one otherwise: it is logged, and it is replicated like any
+ other.</para>
</listitem>
</varlistentry>
</variablelist>
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java b/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
index c5ea768..d9abe5a 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/core/AddOperationBasis.java
@@ -13,6 +13,7 @@
*
* Copyright 2007-2010 Sun Microsystems, Inc.
* Portions Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.core;
@@ -288,11 +289,13 @@
AttributeType attrType = attrDesc.getAttributeType();
// If the attribute type is marked "NO-USER-MODIFICATION" then fail
- // unless this is an internal operation or is related to
- // synchronization in some way.
+ // unless this is an internal operation, is related to
+ // synchronization in some way, or the client asked for the rules
+ // to be relaxed.
if (attrType.isNoUserModification()
&& !isInternalOperation()
- && !isSynchronizationOperation())
+ && !isSynchronizationOperation()
+ && !isRelaxRulesRequested(this))
{
throw new LDAPException(LDAPResultCode.UNWILLING_TO_PERFORM,
ERR_ADD_ATTR_IS_NO_USER_MOD.get(entryDN, attrDesc));
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java b/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
index a7970cd..b42d677 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/plugins/LastModPlugin.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2008 Sun Microsystems, Inc.
* Portions Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.plugins;
@@ -32,6 +33,7 @@
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.schema.AttributeType;
import org.forgerock.opendj.server.config.meta.PluginCfgDefn;
import org.forgerock.opendj.server.config.server.LastModPluginCfg;
import org.forgerock.opendj.server.config.server.PluginCfg;
@@ -47,6 +49,7 @@
import org.opends.server.types.operation.PreOperationAddOperation;
import org.opends.server.types.operation.PreOperationModifyDNOperation;
import org.opends.server.types.operation.PreOperationModifyOperation;
+import org.opends.server.workflowelement.localbackend.LocalBackendWorkflowElement;
/**
* This class implements a Directory Server plugin that will add the
@@ -117,25 +120,31 @@
doPreOperation(PreOperationAddOperation addOperation)
{
// Create the attribute list for the creatorsName attribute, if appropriate.
- AttributeBuilder builder = new AttributeBuilder(getCreatorsNameAttributeType());
- DN creatorDN = addOperation.getAuthorizationDN();
- if (creatorDN == null)
+ if (!isSuppliedUnderRelaxRules(addOperation, getCreatorsNameAttributeType()))
{
- // This must mean that the operation was performed anonymously.
- // Even so, we still need to update the creatorsName attribute.
- builder.add(ByteString.empty());
+ AttributeBuilder builder = new AttributeBuilder(getCreatorsNameAttributeType());
+ DN creatorDN = addOperation.getAuthorizationDN();
+ if (creatorDN == null)
+ {
+ // This must mean that the operation was performed anonymously.
+ // Even so, we still need to update the creatorsName attribute.
+ builder.add(ByteString.empty());
+ }
+ else
+ {
+ builder.add(creatorDN.toString());
+ }
+ addOperation.setAttribute(getCreatorsNameAttributeType(), builder.toAttributeList());
}
- else
- {
- builder.add(creatorDN.toString());
- }
- addOperation.setAttribute(getCreatorsNameAttributeType(), builder.toAttributeList());
// Create the attribute list for the createTimestamp attribute.
- List<Attribute> timeList = Attributes.createAsList(
- getCreateTimestampAttributeType(), OP_ATTR_CREATE_TIMESTAMP, getGMTTime());
- addOperation.setAttribute(getCreateTimestampAttributeType(), timeList);
+ if (!isSuppliedUnderRelaxRules(addOperation, getCreateTimestampAttributeType()))
+ {
+ List<Attribute> timeList = Attributes.createAsList(
+ getCreateTimestampAttributeType(), OP_ATTR_CREATE_TIMESTAMP, getGMTTime());
+ addOperation.setAttribute(getCreateTimestampAttributeType(), timeList);
+ }
// We shouldn't ever need to return a non-success result.
return PluginResult.PreOperation.continueOperationProcessing();
@@ -148,49 +157,55 @@
doPreOperation(PreOperationModifyOperation modifyOperation)
{
// Create the modifiersName attribute.
- AttributeBuilder builder = new AttributeBuilder(getModifiersNameAttributeType());
- DN modifierDN = modifyOperation.getAuthorizationDN();
- if (modifierDN == null)
+ if (!isSuppliedUnderRelaxRules(modifyOperation, getModifiersNameAttributeType()))
{
- // This must mean that the operation was performed anonymously.
- // Even so, we still need to update the modifiersName attribute.
- builder.add(ByteString.empty());
- }
- else
- {
- builder.add(modifierDN.toString());
- }
- Attribute nameAttr = builder.toAttribute();
- try
- {
- modifyOperation.addModification(new Modification(ModificationType.REPLACE,
- nameAttr, true));
- }
- catch (DirectoryException de)
- {
- logger.traceException(de);
+ AttributeBuilder builder = new AttributeBuilder(getModifiersNameAttributeType());
+ DN modifierDN = modifyOperation.getAuthorizationDN();
+ if (modifierDN == null)
+ {
+ // This must mean that the operation was performed anonymously.
+ // Even so, we still need to update the modifiersName attribute.
+ builder.add(ByteString.empty());
+ }
+ else
+ {
+ builder.add(modifierDN.toString());
+ }
+ Attribute nameAttr = builder.toAttribute();
+ try
+ {
+ modifyOperation.addModification(new Modification(ModificationType.REPLACE,
+ nameAttr, true));
+ }
+ catch (DirectoryException de)
+ {
+ logger.traceException(de);
- // This should never happen.
- return PluginResult.PreOperation.stopProcessing(
- DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+ // This should never happen.
+ return PluginResult.PreOperation.stopProcessing(
+ DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+ }
}
// Create the modifyTimestamp attribute.
- Attribute timeAttr = Attributes.create(getModifyTimestampAttributeType(),
- OP_ATTR_MODIFY_TIMESTAMP, getGMTTime());
- try
+ if (!isSuppliedUnderRelaxRules(modifyOperation, getModifyTimestampAttributeType()))
{
- modifyOperation.addModification(new Modification(ModificationType.REPLACE,
- timeAttr, true));
- }
- catch (DirectoryException de)
- {
- logger.traceException(de);
+ Attribute timeAttr = Attributes.create(getModifyTimestampAttributeType(),
+ OP_ATTR_MODIFY_TIMESTAMP, getGMTTime());
+ try
+ {
+ modifyOperation.addModification(new Modification(ModificationType.REPLACE,
+ timeAttr, true));
+ }
+ catch (DirectoryException de)
+ {
+ logger.traceException(de);
- // This should never happen.
- return PluginResult.PreOperation.stopProcessing(
- DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+ // This should never happen.
+ return PluginResult.PreOperation.stopProcessing(
+ DirectoryConfig.getServerErrorResultCode(), de.getMessageObject());
+ }
}
@@ -198,6 +213,36 @@
return PluginResult.PreOperation.continueOperationProcessing();
}
+ /**
+ * Indicates whether a client relaxing the rules supplies the provided attribute in the entry it
+ * adds: such a value is kept, the way it is kept when the change is replayed on another replica.
+ */
+ private static boolean isSuppliedUnderRelaxRules(PreOperationAddOperation addOperation, AttributeType type)
+ {
+ return LocalBackendWorkflowElement.isRelaxRulesRequested(addOperation)
+ && addOperation.getOperationalAttributes().containsKey(type);
+ }
+
+ /**
+ * Indicates whether a client relaxing the rules modifies the provided attribute: such a value is
+ * kept, the way it is kept when the change is replayed on another replica.
+ */
+ private static boolean isSuppliedUnderRelaxRules(PreOperationModifyOperation modifyOperation, AttributeType type)
+ {
+ if (!LocalBackendWorkflowElement.isRelaxRulesRequested(modifyOperation))
+ {
+ return false;
+ }
+ for (Modification m : modifyOperation.getModifications())
+ {
+ if (m.getAttribute().getAttributeDescription().getAttributeType().equals(type))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
index 1e405dc..9ec152b 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
@@ -110,8 +110,19 @@
private Map<AttributeType, List<Attribute>> operationalAttributes;
/** The set of user attributes for the entry to add. */
private Map<AttributeType, List<Attribute>> userAttributes;
- /** Indicates whether the request included the RelaxRules request control. */
- private boolean RelaxRulesControlRequested=false;
+ /**
+ * Indicates whether the rules are relaxed on this change: the request included the Relax Rules
+ * request control, and the client has the {@code bypass-acl} privilege.
+ * <p>
+ * The checks of the attributes and of the schema run before the request controls are
+ * processed, so this is decided up front, from the request as it came and from the
+ * authentication identity of the client. Once the controls are processed,
+ * {@link #relaxedRulesStillHold()} verifies it against the identity the request runs as.
+ *
+ * @see LocalBackendWorkflowElement#isRelaxRulesRequested(org.opends.server.types.operation.PluginOperation)
+ */
+ private final boolean relaxRules;
+
/**
* Creates a new operation that may be used to add a new entry in a
* local backend of the Directory Server.
@@ -123,11 +134,28 @@
super(add);
LocalBackendWorkflowElement.attachLocalOperation (add, this);
+ relaxRules = LocalBackendWorkflowElement.isRelaxRulesRequested(this)
+ && getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, this);
}
- @Override
- public boolean isSynchronizationOperation() {
- return super.isSynchronizationOperation()||RelaxRulesControlRequested;
+ /**
+ * Indicates whether the request may go on as far as the Relax Rules control is concerned, once
+ * the request controls the client may not use are removed and the proxied authorization, if
+ * any, is applied.
+ * <p>
+ * A control still there needs the {@code bypass-acl} privilege of the identity the request
+ * runs as, which may not be the one {@link #relaxRules} was decided for. A control which is
+ * gone after the rules were relaxed on the checks already run cannot leave them relaxed. A
+ * control which is gone and relaxed nothing leaves an ordinary request.
+ */
+ private boolean relaxedRulesStillHold()
+ {
+ final boolean controlKept = LocalBackendWorkflowElement.isRelaxRulesRequested(this);
+ if (controlKept)
+ {
+ return getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, this);
+ }
+ return !relaxRules;
}
@@ -382,10 +410,11 @@
}
// If the server is configured to check schema and the
- // operation is not a synchronization operation,
- // check to see if the entry is valid according to the server schema,
- // and also whether its attributes are valid according to their syntax.
- if (DirectoryServer.getCoreConfigManager().isCheckSchema() && !isSynchronizationOperation())
+ // operation is not a synchronization operation nor one whose rules the
+ // client asked to relax, check to see if the entry is valid according to
+ // the server schema, and also whether its attributes are valid according
+ // to their syntax.
+ if (DirectoryServer.getCoreConfigManager().isCheckSchema() && !isSynchronizationOperation() && !relaxRules)
{
checkSchema(parentEntry);
}
@@ -413,7 +442,7 @@
// sensitive information to the client.
try
{
- if (!getAccessControlHandler().isAllowed(this) || (RelaxRulesControlRequested && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
+ if (!getAccessControlHandler().isAllowed(this) || !relaxedRulesStillHold())
{
setResultCodeAndMessageNoInfoDisclosure(entryDN,
ResultCode.INSUFFICIENT_ACCESS_RIGHTS,
@@ -530,7 +559,8 @@
{
if (at.isNoUserModification()
&& !isInternalOperation()
- && !isSynchronizationOperation())
+ && !isSynchronizationOperation()
+ && !relaxRules)
{
setResultCodeAndMessageNoInfoDisclosure(entryDN,
ResultCode.CONSTRAINT_VIOLATION,
@@ -698,7 +728,8 @@
: UserPasswordSyntax.isEncoded(value);
if (isPreEncoded)
{
- if (isInternalOperation() || passwordPolicy.isAllowPreEncodedPasswords())
+ // A client relaxing the rules may bring a password encoded elsewhere, e.g. to migrate it.
+ if (isInternalOperation() || passwordPolicy.isAllowPreEncodedPasswords() || relaxRules)
{
builder.add(value);
continue;
@@ -761,10 +792,15 @@
entry.replaceAttribute(builder.toAttribute());
- // Set the password changed time attribute.
+ // Set the password changed time attribute, unless a client relaxing the
+ // rules supplies the time the password was changed at.
Attribute changedTime = Attributes.create(
OP_ATTR_PWPOLICY_CHANGED_TIME, TimeThread.getGeneralizedTime());
- entry.putAttribute(changedTime.getAttributeDescription().getAttributeType(), newArrayList(changedTime));
+ AttributeType changedTimeType = changedTime.getAttributeDescription().getAttributeType();
+ if (!relaxRules || !entry.hasAttribute(changedTimeType))
+ {
+ entry.putAttribute(changedTimeType, newArrayList(changedTime));
+ }
// If we should force change on add, then set the appropriate flag.
@@ -976,7 +1012,7 @@
}
else if (RelaxRulesControl.OID.equals(oid))
{
- RelaxRulesControlRequested = true;
+ // Already taken into account: see relaxRules.
}
else if (TransactionSpecificationRequestControl.OID.equals(oid))
{
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
index 8b8a931..178946a 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
@@ -126,8 +126,17 @@
private boolean permissiveModify;
/** Indicates whether the request included the password policy request control. */
private boolean pwPolicyControlRequested;
- /** Indicates whether the request included the RelaxRules request control. */
- private boolean RelaxRulesControlRequested=false;
+ /**
+ * Indicates whether the request included the Relax Rules request control, and the client
+ * may use it.
+ * <p>
+ * It is set once the request controls the client may not use are removed, before any check
+ * it relaxes runs; {@link #operationIsAllowed()} then requires the {@code bypass-acl}
+ * privilege of the identity the request runs as.
+ *
+ * @see LocalBackendWorkflowElement#isRelaxRulesRequested(org.opends.server.types.operation.PluginOperation)
+ */
+ private boolean relaxRules;
/** The post-read request control, if present. */
private LDAPPostReadRequestControl postReadRequest;
/** The pre-read request control, if present. */
@@ -168,11 +177,6 @@
LocalBackendWorkflowElement.attachLocalOperation (modify, this);
}
- @Override
- public boolean isSynchronizationOperation() {
- return super.isSynchronizationOperation()||RelaxRulesControlRequested;
- }
-
/**
* Returns whether authentication for this user is managed locally
* or via Pass-Through Authentication.
@@ -540,7 +544,7 @@
{
try
{
- if (!getAccessControlHandler().isAllowed(this) || (RelaxRulesControlRequested && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
+ if (!getAccessControlHandler().isAllowed(this) || (relaxRules && !clientConnection.hasPrivilege(Privilege.BYPASS_ACL, this)))
{
setResultCodeAndMessageNoInfoDisclosure(modifiedEntry,
ResultCode.INSUFFICIENT_ACCESS_RIGHTS,
@@ -699,7 +703,7 @@
}
else if (RelaxRulesControl.OID.equals(oid))
{
- RelaxRulesControlRequested = true;
+ relaxRules = true;
}
else if (TransactionSpecificationRequestControl.OID.equals(oid))
{
@@ -724,9 +728,10 @@
// If the attribute type is marked "NO-USER-MODIFICATION" then fail unless
- // this is an internal operation or is related to synchronization in some way.
- final boolean isInternalOrSynchro = isInternalOrSynchro(m);
- if (t.isNoUserModification() && !isInternalOrSynchro)
+ // this is an internal operation, is related to synchronization in some way,
+ // or the client asked for the rules to be relaxed.
+ final boolean constraintsRelaxed = isInternalOrSynchro(m) || relaxRules;
+ if (t.isNoUserModification() && !constraintsRelaxed)
{
throw newDirectoryException(currentEntry,
ResultCode.CONSTRAINT_VIOLATION,
@@ -734,12 +739,12 @@
}
// If the attribute type is marked "OBSOLETE" and the modification is
- // setting new values, then fail unless this is an internal operation or
- // is related to synchronization in some way.
+ // setting new values, then fail unless this is an internal operation,
+ // is related to synchronization in some way, or the rules are relaxed.
if (t.isObsolete()
&& !a.isEmpty()
&& m.getModificationType() != ModificationType.DELETE
- && !isInternalOrSynchro)
+ && !constraintsRelaxed)
{
throw newDirectoryException(currentEntry,
ResultCode.CONSTRAINT_VIOLATION,
@@ -770,6 +775,19 @@
return isInternalOperation() || m.isInternal() || isSynchronizationOperation();
}
+ /** Indicates whether a modification of the request is on the provided attribute. */
+ private boolean modifiesAttribute(String attributeName)
+ {
+ for (Modification m : getModifications())
+ {
+ if (m.getAttribute().getAttributeDescription().getAttributeType().hasName(attributeName))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
private boolean isPassword(AttributeType t)
{
return pwPolicyState != null
@@ -993,8 +1011,10 @@
{
if (pwPolicyState.passwordIsPreEncoded(v))
{
+ // A client relaxing the rules may bring a password encoded elsewhere, e.g. to migrate it.
if (!isInternalOperation()
- && !authPolicy.isAllowPreEncodedPasswords())
+ && !authPolicy.isAllowPreEncodedPasswords()
+ && !relaxRules)
{
pwpErrorType = PasswordPolicyErrorType.INSUFFICIENT_PASSWORD_QUALITY;
throw new DirectoryException(ResultCode.CONSTRAINT_VIOLATION,
@@ -1201,7 +1221,7 @@
private boolean mustCheckSchema()
{
- return !isSynchronizationOperation() && DirectoryServer.getCoreConfigManager().isCheckSchema();
+ return !isSynchronizationOperation() && !relaxRules && DirectoryServer.getCoreConfigManager().isCheckSchema();
}
/**
@@ -1524,8 +1544,12 @@
wasLocked = pwPolicyState.isLocked();
// Update the password policy state attributes in the user's entry. If the
- // modification fails, then these changes won't be applied.
- pwPolicyState.setPasswordChangedTime();
+ // modification fails, then these changes won't be applied. A client relaxing
+ // the rules may supply the time the password was changed at.
+ if (!relaxRules || !modifiesAttribute(OP_ATTR_PWPOLICY_CHANGED_TIME))
+ {
+ pwPolicyState.setPasswordChangedTime();
+ }
pwPolicyState.clearFailureLockout();
pwPolicyState.clearGraceLoginTimes();
pwPolicyState.clearWarnedTime();
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
index b6dfdac..bde925e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendWorkflowElement.java
@@ -27,6 +27,7 @@
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
import org.opends.server.api.AccessControlHandler;
import org.opends.server.api.LocalBackend;
import org.opends.server.controls.LDAPPostReadRequestControl;
@@ -56,6 +57,7 @@
import org.opends.server.types.Privilege;
import org.opends.server.types.SearchResultEntry;
import org.opends.server.types.WritabilityMode;
+import org.opends.server.types.operation.PluginOperation;
import static org.opends.messages.CoreMessages.*;
import static org.opends.messages.ProtocolMessages.ERR_PROXYAUTH_AUTHZ_NOT_PERMITTED;
@@ -220,6 +222,35 @@
}
/**
+ * Indicates whether the provided operation carries the Relax Rules request control.
+ * <p>
+ * The control relaxes the constraints of the schema on the request - the attributes marked
+ * NO-USER-MODIFICATION or OBSOLETE, and the schema check of the resulting entry - for a
+ * client which has the {@code bypass-acl} privilege, and keeps the values such a client
+ * supplies for the attributes the server maintains (the last modified attributes, a
+ * pre-encoded password and its change time). It does not make the change a synchronization
+ * one: the change is logged, run through the plugins and replicated as any other.
+ * <p>
+ * The method only reads the request controls: before {@code removeAllDisallowedControls} has
+ * run they include a control the client may not use, and whether the client has the privilege
+ * is for the caller to check. By the time the pre-operation plugins run, both are settled.
+ *
+ * @param operation The operation to look at.
+ * @return {@code true} if the request carries the Relax Rules control.
+ */
+ public static boolean isRelaxRulesRequested(PluginOperation operation)
+ {
+ for (Control c : operation.getRequestControls())
+ {
+ if (RelaxRulesControl.OID.equals(c.getOID()))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
* Removes all the disallowed request controls from the provided operation.
* <p>
* As per RFC 4511 4.1.11, if a disallowed request control is critical, then a
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
new file mode 100644
index 0000000..c537ff9
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
@@ -0,0 +1,245 @@
+/*
+ * 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 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 java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.ModificationType;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+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 org.opends.server.replication.protocol.AddMsg;
+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.opends.server.types.Modification;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests that a change made under the Relax Rules control replicates like any other: the
+ * control relaxes the constraints of the schema on the request, it does not make the change
+ * a replayed one.
+ * <p>
+ * The suffix is replicated and a broker listens on its replication server: what the change
+ * publishes is read from there.
+ */
+@SuppressWarnings("javadoc")
+public class RelaxRulesReplicationTest extends ReplicationTestCase
+{
+ private static final String MODIFIED_DN = "cn=relaxed modify," + TEST_ROOT_DN_STRING;
+ private static final String ADDED_DN = "cn=relaxed add," + TEST_ROOT_DN_STRING;
+ /** An attribute the schema marks NO-USER-MODIFICATION, which only the control lets a client write. */
+ private static final String RELAXED_ATTRIBUTE = "pwdChangedTime";
+ private static final String RELAXED_VALUE = "20211203224637.000Z";
+ /**
+ * 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: RelaxRulesReplicationTest\n"
+ + "ds-cfg-replication-server-id: 107\n";
+ final String synchroServerLdif =
+ "dn: cn=relaxRulesReplicationTest, cn=domains, " + SYNCHRO_PLUGIN_DN + "\n"
+ + "objectClass: top\n"
+ + "objectClass: ds-cfg-replication-domain\n"
+ + "cn: relaxRulesReplicationTest\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 aRelaxedModifyIsPublishedLikeAnyOtherChange() throws Exception
+ {
+ TestCaseUtils.addEntry(
+ "dn: " + MODIFIED_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: relaxed",
+ "cn: relaxed modify");
+ assertThat(nextUpdateFor(MODIFIED_DN)).as("the add of the entry to modify was not published")
+ .isInstanceOf(AddMsg.class);
+
+ TestCaseUtils.ERROR_TEXT_WRITER.clear();
+ connection.modify(Requests.newModifyRequest(MODIFIED_DN)
+ .addControl(new RelaxRulesControl())
+ .addModification(ModificationType.REPLACE, RELAXED_ATTRIBUTE, RELAXED_VALUE));
+
+ assertThat(attributeOf(MODIFIED_DN, RELAXED_ATTRIBUTE)).isEqualTo(RELAXED_VALUE);
+
+ final LDAPUpdateMsg published = nextUpdateFor(MODIFIED_DN);
+ assertThat(published).as("the relaxed modify was not published").isInstanceOf(ModifyMsg.class);
+ assertThat(((ModifyMsg) published).getMods())
+ .as("the published change does not carry the relaxed attribute")
+ .anyMatch(this::modifiesTheRelaxedAttribute);
+ // A change with no history cannot be published again from the entry on the next session.
+ assertThat(valuesOf(MODIFIED_DN, "ds-sync-hist"))
+ .as("the relaxed modify left no historical information")
+ .anyMatch(value -> value.toLowerCase().startsWith(RELAXED_ATTRIBUTE.toLowerCase() + ":"));
+ final List<String> records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages());
+ assertThat(records).as("the relaxed modify was taken for a replayed change")
+ .noneMatch(record -> record.contains(NOT_IN_PENDING));
+ }
+
+ @Test
+ public void aRelaxedAddIsPublishedLikeAnyOtherChange() throws Exception
+ {
+ TestCaseUtils.ERROR_TEXT_WRITER.clear();
+ connection.add(Requests.newAddRequest(
+ "dn: " + ADDED_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "sn: relaxed",
+ "cn: relaxed add",
+ RELAXED_ATTRIBUTE + ": " + RELAXED_VALUE)
+ .addControl(new RelaxRulesControl()));
+
+ assertThat(attributeOf(ADDED_DN, RELAXED_ATTRIBUTE)).isEqualTo(RELAXED_VALUE);
+
+ final LDAPUpdateMsg published = nextUpdateFor(ADDED_DN);
+ assertThat(published).as("the relaxed add was not published").isInstanceOf(AddMsg.class);
+ assertThat(((AddMsg) published).getAttributes())
+ .as("the published add does not carry the relaxed attribute")
+ .anyMatch(attr -> attr.getAttributeDescription().getAttributeType().hasName(RELAXED_ATTRIBUTE));
+ // An add with no history cannot be published again from the entry on the next session.
+ assertThat(valuesOf(ADDED_DN, "ds-sync-hist"))
+ .as("the relaxed add left no historical information")
+ .anyMatch(value -> value.startsWith("dn:") && value.endsWith(":add"));
+ final List<String> records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages());
+ assertThat(records).as("the relaxed add was taken for a replayed change")
+ .noneMatch(record -> record.contains(NOT_IN_PENDING));
+ }
+
+ private boolean modifiesTheRelaxedAttribute(Modification mod)
+ {
+ return mod.getAttribute().getAttributeDescription().getAttributeType().hasName(RELAXED_ATTRIBUTE);
+ }
+
+ private String attributeOf(String dn, String attribute) throws Exception
+ {
+ final SearchResultEntry entry = read(dn);
+ return entry.containsAttribute(attribute) ? entry.parseAttribute(attribute).asString() : null;
+ }
+
+ private Set<String> valuesOf(String dn, String attribute) throws Exception
+ {
+ return read(dn).parseAttribute(attribute).asSetOfString();
+ }
+
+ private SearchResultEntry read(String dn) throws Exception
+ {
+ return connection.searchSingleEntry(
+ Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)")
+ .addAttribute("*", "+"));
+ }
+
+ /**
+ * The next update the replication server forwards for the provided entry, 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.
+ * <p>
+ * The updates of other entries are skipped: those a failed case leaves behind on the broker
+ * the cases share must not be taken for the ones of the next case.
+ */
+ private LDAPUpdateMsg nextUpdateFor(String dn) throws Exception
+ {
+ final DN entryDN = DN.valueOf(dn);
+ 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 && ((LDAPUpdateMsg) msg).getDN().equals(entryDN))
+ {
+ return (LDAPUpdateMsg) msg;
+ }
+ }
+ return null;
+ }
+}
diff --git a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
index 703b85d..6ea36a1 100644
--- a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/RelaxRulesTestCase.java
@@ -11,14 +11,18 @@
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions Copyright [year] [name of copyright owner]".
*
- * Copyright 2024 3A Systems, LLC.
+ * Copyright 2024-2026 3A Systems, LLC.
*/
package org.openidentityplatform.opendj;
import org.forgerock.opendj.adapter.server3x.Adapters;
import org.forgerock.opendj.ldap.*;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.controls.GenericControl;
+import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl;
import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+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.Result;
@@ -26,6 +30,7 @@
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
+import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
@@ -33,7 +38,22 @@
@Test(sequential = true)
public class RelaxRulesTestCase extends DirectoryServerTestCase {
+ /** A client which may write anything under the suffix, but has no {@code bypass-acl} privilege. */
+ private static final String USER_DN = "uid=relax.user,o=test";
+ /** A client with the {@code bypass-acl} privilege which may act as {@link #USER_DN}. */
+ private static final String PROXY_DN = "uid=relax.proxy,o=test";
+ private static final String ACCESS_HANDLER_DN = "cn=Access Control Handler,cn=config";
+ /** Lets any authenticated client send the Relax Rules control, so that it survives the ACI of the controls. */
+ private static final String RELAX_CONTROL_ACI = "(targetcontrol=\"" + RelaxRulesControl.OID + "\")"
+ + "(version 3.0; acl \"Relax Rules control access\"; allow(read) userdn=\"ldap:///all\";)";
+ /** 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\";)";
+ private static final String OLD_TIME = "20211203224637.000Z";
+ private static final String PRE_ENCODED_PASSWORD = "{SSHA}K9Hv0w7Z0Q2yL3ZJmD1m4n8q3mYk1Xn1R0x6Xw==";
+
Connection connection;
+ private LDAPConnectionFactory factory;
@BeforeClass
public void startServer() throws Exception {
@@ -53,13 +73,50 @@
"userPassword:: cGFzc3dvcmQ=",
"postalAddress: Aarika Atpco$00900 Maple Street$New Orleans, KS 10857",
"postalCode: 10857",
- ""
+ "",
+ "dn: " + USER_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "cn: Relax User",
+ "sn: User",
+ "uid: relax.user",
+ "userPassword: password",
+ "",
+ "dn: " + PROXY_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "cn: Relax Proxy",
+ "sn: Proxy",
+ "uid: relax.proxy",
+ "userPassword: password",
+ "ds-privilege-name: bypass-acl",
+ "ds-privilege-name: proxied-auth"
);
- final LDAPConnectionFactory factory =new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+ factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
connection = factory.getConnection();
connection.bind("cn=Directory Manager", "password".toCharArray());
assertThat(connection.isValid()).isTrue();
+
+ // The user may write anything under the suffix, operational attributes included: what stops it
+ // below is the Relax Rules control alone.
+ connection.modify(Requests.newModifyRequest("o=test").addModification(ModificationType.ADD, "aci",
+ "(targetattr=\"*||+\")(version 3.0; acl \"Relax Rules test user\"; allow(all) userdn=\"ldap:///"
+ + USER_DN + "\";)"));
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void closeConnection() {
+ if (connection != null) {
+ connection.close();
+ }
+ if (factory != null) {
+ factory.close();
+ }
}
@Test
@@ -80,4 +137,280 @@
assertThat(srEntry.getAttribute("pwdChangedTime").firstValueAsString()).isEqualTo(
"20211203224637.000Z");
}
+
+ @Test
+ public void aRelaxedModifyStillRunsThePreOperationPlugins() throws Exception {
+ final String dn = addPerson("plugins");
+
+ connection.modify(Requests.newModifyRequest(dn)
+ .addControl(new RelaxRulesControl())
+ .addModification(ModificationType.REPLACE, "description", "relaxed"));
+
+ assertThat(valueOf(dn, "modifiersName")).as("the last modified plugin did not run")
+ .isEqualToIgnoringCase("cn=Directory Manager,cn=Root DNs,cn=config");
+ }
+
+ @Test
+ public void aRelaxedModifyKeepsTheLastModifiedValuesTheClientSupplies() throws Exception {
+ final String dn = addPerson("lastmod modify");
+
+ connection.modify(Requests.newModifyRequest(dn)
+ .addControl(new RelaxRulesControl())
+ .addModification(ModificationType.REPLACE, "modifyTimestamp", OLD_TIME)
+ .addModification(ModificationType.REPLACE, "modifiersName", "cn=migrated"));
+
+ assertThat(valueOf(dn, "modifyTimestamp")).isEqualTo(OLD_TIME);
+ assertThat(valueOf(dn, "modifiersName")).isEqualTo("cn=migrated");
+ }
+
+ @Test
+ public void aRelaxedAddKeepsTheCreationValuesTheClientSupplies() throws Exception {
+ final String dn = "cn=lastmod add,o=test";
+
+ connection.add(person(dn)
+ .addAttribute("createTimestamp", OLD_TIME)
+ .addAttribute("creatorsName", "cn=migrated")
+ .addControl(new RelaxRulesControl()));
+
+ assertThat(valueOf(dn, "createTimestamp")).isEqualTo(OLD_TIME);
+ assertThat(valueOf(dn, "creatorsName")).isEqualTo("cn=migrated");
+ }
+
+ @Test
+ public void aRelaxedModifyKeepsAMigratedPasswordAndItsChangeTime() throws Exception {
+ final String dn = addPerson("password modify");
+
+ connection.modify(Requests.newModifyRequest(dn)
+ .addControl(new RelaxRulesControl())
+ .addModification(ModificationType.REPLACE, "userPassword", PRE_ENCODED_PASSWORD)
+ .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME));
+
+ assertThat(valueOf(dn, "userPassword")).isEqualTo(PRE_ENCODED_PASSWORD);
+ assertThat(valueOf(dn, "pwdChangedTime")).isEqualTo(OLD_TIME);
+ }
+
+ @Test
+ public void aRelaxedAddKeepsAMigratedPasswordAndItsChangeTime() throws Exception {
+ final String dn = "cn=password add,o=test";
+
+ connection.add(person(dn)
+ .addAttribute("userPassword", PRE_ENCODED_PASSWORD)
+ .addAttribute("pwdChangedTime", OLD_TIME)
+ .addControl(new RelaxRulesControl()));
+
+ assertThat(valueOf(dn, "userPassword")).isEqualTo(PRE_ENCODED_PASSWORD);
+ assertThat(valueOf(dn, "pwdChangedTime")).isEqualTo(OLD_TIME);
+ }
+
+ @Test
+ public void aRelaxedAddSkipsTheSchemaCheck() throws Exception {
+ // person does not allow mail.
+ assertThat(resultOf(() -> connection.add(person("cn=schema add,o=test").addAttribute("mail", "x@example.com"))))
+ .isEqualTo(ResultCode.OBJECTCLASS_VIOLATION);
+
+ connection.add(person("cn=schema add,o=test")
+ .addAttribute("mail", "x@example.com")
+ .addControl(new RelaxRulesControl()));
+
+ assertThat(valueOf("cn=schema add,o=test", "mail")).isEqualTo("x@example.com");
+ }
+
+ @Test
+ public void aRelaxedModifySkipsTheSchemaCheck() throws Exception {
+ final String dn = addPerson("schema modify");
+ final ModifyRequest addMail = Requests.newModifyRequest(dn)
+ .addModification(ModificationType.ADD, "mail", "x@example.com");
+ assertThat(resultOf(() -> connection.modify(addMail))).isEqualTo(ResultCode.OBJECTCLASS_VIOLATION);
+
+ connection.modify(Requests.copyOfModifyRequest(addMail).addControl(new RelaxRulesControl()));
+
+ assertThat(valueOf(dn, "mail")).isEqualTo("x@example.com");
+ }
+
+ @Test
+ public void aRelaxedModifyMayWriteAnObsoleteAttribute() throws Exception {
+ final String obsoleteType = "( 1.3.6.1.4.1.36733.2.1.999.1053 NAME 'relaxRulesObsoleteTest' OBSOLETE"
+ + " SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-ORIGIN 'RelaxRulesTestCase' )";
+ connection.modify(Requests.newModifyRequest("cn=schema")
+ .addModification(ModificationType.ADD, "attributeTypes", obsoleteType));
+ try {
+ final String dn = "cn=obsolete,o=test";
+ connection.add(Requests.newAddRequest(dn)
+ .addAttribute("objectClass", "top", "person", "extensibleObject")
+ .addAttribute("cn", "obsolete")
+ .addAttribute("sn", "obsolete"));
+ final ModifyRequest writeObsolete = Requests.newModifyRequest(dn)
+ .addModification(ModificationType.REPLACE, "relaxRulesObsoleteTest", "value");
+ assertThat(resultOf(() -> connection.modify(writeObsolete))).isEqualTo(ResultCode.CONSTRAINT_VIOLATION);
+
+ connection.modify(Requests.copyOfModifyRequest(writeObsolete).addControl(new RelaxRulesControl()));
+
+ assertThat(valueOf(dn, "relaxRulesObsoleteTest")).isEqualTo("value");
+ connection.delete(dn);
+ } finally {
+ connection.modify(Requests.newModifyRequest("cn=schema")
+ .addModification(ModificationType.DELETE, "attributeTypes", obsoleteType));
+ }
+ }
+
+ /**
+ * A non-critical control the client may not use is dropped (RFC 4511 4.1.11): the request is then an
+ * ordinary one, and succeeds as such.
+ */
+ @Test
+ public void aNonCriticalControlTheClientMayNotUseIsIgnored() throws Exception {
+ final String dn = addPerson("ignored control");
+ try (ControlAccess restricted = restrictControlAccess(null);
+ Connection user = bindAsUser()) {
+ user.modify(Requests.newModifyRequest(dn)
+ .addControl(nonCriticalRelaxRules())
+ .addModification(ModificationType.REPLACE, "description", "ordinary"));
+ user.add(person("cn=ignored control add,o=test").addControl(nonCriticalRelaxRules()));
+ }
+
+ assertThat(valueOf(dn, "description")).isEqualTo("ordinary");
+ assertThat(exists("cn=ignored control add,o=test")).isTrue();
+ }
+
+ /** Once the control ACI lets the control through, only the {@code bypass-acl} privilege lets it relax anything. */
+ @Test
+ public void aClientWithoutBypassAclMayNotRelaxTheRules() throws Exception {
+ final String dn = addPerson("unprivileged");
+ try (ControlAccess relaxAllowed = restrictControlAccess(RELAX_CONTROL_ACI);
+ Connection user = bindAsUser()) {
+ assertThat(resultOf(() -> user.add(person("cn=unprivileged add,o=test")
+ .addAttribute("pwdChangedTime", OLD_TIME)
+ .addControl(new RelaxRulesControl()))))
+ .isNotEqualTo(ResultCode.SUCCESS);
+ assertThat(resultOf(() -> user.add(person("cn=unprivileged plain add,o=test")
+ .addControl(new RelaxRulesControl()))))
+ .isEqualTo(ResultCode.INSUFFICIENT_ACCESS_RIGHTS);
+ assertThat(resultOf(() -> user.modify(Requests.newModifyRequest(dn)
+ .addControl(new RelaxRulesControl())
+ .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME))))
+ .isEqualTo(ResultCode.INSUFFICIENT_ACCESS_RIGHTS);
+ }
+
+ assertThat(exists("cn=unprivileged add,o=test")).isFalse();
+ assertThat(exists("cn=unprivileged plain add,o=test")).isFalse();
+ assertThat(valueOf(dn, "pwdChangedTime")).isNull();
+ }
+
+ /**
+ * The privilege which lets the rules be relaxed is the one of the identity the request runs as: a
+ * client with {@code bypass-acl} which proxies as an ordinary user relaxes nothing, whether the control
+ * ACI lets the control through for that user or not.
+ */
+ @Test
+ public void aProxiedIdentityWithoutBypassAclMayNotRelaxTheRules() throws Exception {
+ assertAProxiedRelaxedChangeIsRefused("proxied kept");
+ try (ControlAccess restricted = restrictControlAccess(null)) {
+ assertAProxiedRelaxedChangeIsRefused("proxied dropped");
+ }
+ }
+
+ private void assertAProxiedRelaxedChangeIsRefused(String cn) throws Exception {
+ final String dn = addPerson(cn);
+ final String addedDN = "cn=" + cn + " add,o=test";
+ final ProxiedAuthV2RequestControl asUser = ProxiedAuthV2RequestControl.newControl("dn:" + USER_DN);
+
+ try (Connection proxy = factory.getConnection()) {
+ proxy.bind(PROXY_DN, "password".toCharArray());
+ // Without the Relax Rules control, the proxy may act as the user.
+ proxy.modify(Requests.newModifyRequest(dn)
+ .addControl(asUser)
+ .addModification(ModificationType.REPLACE, "description", "proxied"));
+
+ assertThat(resultOf(() -> proxy.add(person(addedDN)
+ .addAttribute("pwdChangedTime", OLD_TIME)
+ .addControl(asUser)
+ .addControl(nonCriticalRelaxRules()))))
+ .isNotEqualTo(ResultCode.SUCCESS);
+ assertThat(resultOf(() -> proxy.modify(Requests.newModifyRequest(dn)
+ .addControl(asUser)
+ .addControl(nonCriticalRelaxRules())
+ .addModification(ModificationType.REPLACE, "pwdChangedTime", OLD_TIME))))
+ .isNotEqualTo(ResultCode.SUCCESS);
+ }
+
+ assertThat(valueOf(dn, "modifiersName")).isEqualToIgnoringCase(USER_DN);
+ assertThat(exists(addedDN)).isFalse();
+ assertThat(valueOf(dn, "pwdChangedTime")).isNull();
+ }
+
+ /**
+ * Removes the global ACI of the test configuration which lets anyone use any control, adding the provided
+ * one instead if any, until closed.
+ */
+ private ControlAccess restrictControlAccess(String aci) throws LdapException {
+ 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 () -> {
+ 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));
+ };
+ }
+
+ private interface ControlAccess extends AutoCloseable {
+ @Override
+ void close() throws LdapException;
+ }
+
+ /** {@link RelaxRulesControl} is always critical. */
+ private static Control nonCriticalRelaxRules() {
+ return GenericControl.newControl(RelaxRulesControl.OID, false);
+ }
+
+ private String addPerson(String cn) throws LdapException {
+ final String dn = "cn=" + cn + ",o=test";
+ connection.add(person(dn));
+ return dn;
+ }
+
+ private static AddRequest person(String dn) {
+ final String cn = DN.valueOf(dn).rdn().getFirstAVA().getAttributeValue().toString();
+ return Requests.newAddRequest(dn)
+ .addAttribute("objectClass", "top", "person")
+ .addAttribute("cn", cn)
+ .addAttribute("sn", cn);
+ }
+
+ private Connection bindAsUser() throws LdapException {
+ final Connection user = factory.getConnection();
+ user.bind(USER_DN, "password".toCharArray());
+ return user;
+ }
+
+ private String valueOf(String dn, String attribute) throws LdapException {
+ final SearchResultEntry entry = connection.searchSingleEntry(
+ Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)").addAttribute("*", "+"));
+ final Attribute attr = entry.getAttribute(attribute);
+ return attr != null ? attr.firstValueAsString() : null;
+ }
+
+ private boolean exists(String dn) {
+ return resultOf(() -> connection.searchSingleEntry(
+ Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)"))) == ResultCode.SUCCESS;
+ }
+
+ private interface Request {
+ Object send() throws LdapException;
+ }
+
+ private static ResultCode resultOf(Request request) {
+ try {
+ request.send();
+ return ResultCode.SUCCESS;
+ } catch (LdapException e) {
+ return e.getResult().getResultCode();
+ }
+ }
}
--
Gitblit v1.10.0