mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday dd94891ad6946da5646fffdf11631144725b5636
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
/*
 * 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 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;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
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;
 
import static org.assertj.core.api.Assertions.assertThat;
 
@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 {
        TestCaseUtils.startServer();
        TestCaseUtils.initializeTestBackend(true);
 
        TestCaseUtils.addEntries(
           "dn: uid=user.2, o=test",
            "objectClass: top",
            "objectClass: person",
            "objectClass: inetOrgPerson",
            "objectClass: organizationalPerson",
            "cn: Aarika Atpco",
            "sn: user.2",
            "uid:user.2",
            "description: This is the description for Aarika Atpco.",
            "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"
        );
 
        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
    public void test() throws LdapException {
        final ModifyRequest changeRequest =
                Requests.newModifyRequest("uid=user.2, o=test")
                        .addControl(new RelaxRulesControl())
                        .addModification(ModificationType.REPLACE, "pwdChangedTime", "20211203224637.000Z");
 
        final Result result = connection.modify(changeRequest);
        assertThat(result.getDiagnosticMessage()).isEmpty();
        assertThat(result.getMatchedDN()).isEmpty();
 
        //Verifies that entry has been correctly modified.
        final SearchResultEntry srEntry =
                connection.searchSingleEntry(Requests.newSearchRequest(
                        "uid=user.2, o=test", SearchScope.BASE_OBJECT, "(uid=user.2)").addAttribute("+"));
        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();
        }
    }
}