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