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/openidentityplatform/opendj/RelaxRulesTestCase.java | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 336 insertions(+), 3 deletions(-)
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