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. -- 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> 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)); 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 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)) { 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(); 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 opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RelaxRulesReplicationTest.java
New file @@ -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; } } 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(); } } }