From a2d67f674f7e80dcd9ca901ea63df41ef47f4214 Mon Sep 17 00:00:00 2001
From: vharseko <vharseko@openam.org.ru>
Date: Wed, 22 Nov 2017 05:08:57 +0000
Subject: [PATCH] Merge pull request #3 from GuyPaddock/wren/feature/subtree-flattening

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java       |   99 ++-
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java           |  106 +++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java               |  725 ++++++++++++++++++++++---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java        |    7 
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties            |    8 
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfiguratorTest.java   |  394 +++++++++++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java         |   42 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java                     |   27 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java                        |   75 ++
 opendj-server-legacy/resource/config/rest2ldap/endpoints/api/example-v1.json                       |   28 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java            |    4 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java          |   16 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java                 |   72 ++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java |    5 
 opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/example-v1.json   |   28 +
 15 files changed, 1,431 insertions(+), 205 deletions(-)

diff --git a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/example-v1.json b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/example-v1.json
index 096de36..0bb2c83 100644
--- a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/example-v1.json
+++ b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/example-v1.json
@@ -21,6 +21,34 @@
                         "dnAttribute": "uid"
                     }
                 },
+                // This resource is the same as "users", but read-only.
+                // Users cannot be created, modified, or deleted through this sub-resource.
+                "read-only-users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc=example,dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    },
+                    "isReadOnly": true
+                },
+                // This resource provides a read-only view of all users in the system, including
+                // users nested underneath entries like org units, organizations, etc., starting
+                // from "ou=people,dc=example,dc=com" and working down. It filters out any other
+                // structural elements, including organizations, org units, etc.
+                "all-users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc=example,dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    },
+                    "isReadOnly": true,
+                    "flattenSubtree": true,
+                    "baseSearchFilter": "(objectClass=person)"
+                },
                 "groups": {
                     "type": "collection",
                     "dnTemplate": "ou=groups,dc=example,dc=com",
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java
index 1c16427..06fe696 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java
@@ -12,12 +12,14 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
- *
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_READ_ONLY_ENDPOINT;
 
+import org.forgerock.api.models.ApiDescription;
+import org.forgerock.http.ApiProducer;
 import org.forgerock.json.resource.BadRequestException;
 import org.forgerock.json.resource.QueryRequest;
 import org.forgerock.json.resource.QueryResourceHandler;
@@ -28,6 +30,7 @@
 import org.forgerock.json.resource.ResourceException;
 import org.forgerock.json.resource.ResourceResponse;
 import org.forgerock.services.context.Context;
+import org.forgerock.services.descriptor.Describable;
 import org.forgerock.util.promise.Promise;
 
 /**
@@ -56,4 +59,15 @@
     protected <V> Promise<V, ResourceException> handleRequest(final Context context, final Request request) {
         return new BadRequestException(ERR_READ_ONLY_ENDPOINT.get().toString()).asPromise();
     }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public ApiDescription api(ApiProducer<ApiDescription> producer) {
+        if (delegate instanceof Describable) {
+            return ((Describable<ApiDescription, Request>)delegate).api(producer);
+        }
+        else {
+            return super.api(producer);
+        }
+    }
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
index 9879681..5d9712f 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -82,6 +83,7 @@
                             final String baseDnTemplate, final AttributeDescription primaryKey,
                             final PropertyMapper mapper) {
         super(ldapAttributeName);
+
         this.schema = schema;
         this.baseDnTemplate = DnTemplate.compile(baseDnTemplate);
         this.primaryKey = primaryKey;
@@ -89,13 +91,11 @@
     }
 
     /**
-     * Sets the filter which should be used when searching for referenced LDAP
-     * entries. The default is {@code (objectClass=*)}.
+     * Sets the filter which should be used when searching for referenced LDAP entries.
      *
-     * @param filter
-     *            The filter which should be used when searching for referenced
-     *            LDAP entries.
-     * @return This property mapper.
+     * @param   filter
+     *          The filter which should be used when searching for referenced LDAP entries.
+     * @return  This property mapper.
      */
     public ReferencePropertyMapper searchFilter(final Filter filter) {
         this.filter = checkNotNull(filter);
@@ -104,25 +104,24 @@
 
     /**
      * Sets the filter which should be used when searching for referenced LDAP
-     * entries. The default is {@code (objectClass=*)}.
+     * entries.
      *
-     * @param filter
-     *            The filter which should be used when searching for referenced
-     *            LDAP entries.
-     * @return This property mapper.
+     * @param   filter
+     *          The filter which should be used when searching for referenced LDAP entries.
+     * @return  This property mapper.
      */
     public ReferencePropertyMapper searchFilter(final String filter) {
         return searchFilter(Filter.valueOf(filter));
     }
 
     /**
-     * Sets the search scope which should be used when searching for referenced
-     * LDAP entries. The default is {@link SearchScope#WHOLE_SUBTREE}.
+     * Sets the search scope which should be used when searching for referenced LDAP entries.
+     * The default is {@link SearchScope#WHOLE_SUBTREE}.
      *
-     * @param scope
-     *            The search scope which should be used when searching for
-     *            referenced LDAP entries.
-     * @return This property mapper.
+     * @param   scope
+     *          The search scope which should be used when searching for
+     *          referenced LDAP entries.
+     * @return  This property mapper.
      */
     public ReferencePropertyMapper searchScope(final SearchScope scope) {
         this.scope = checkNotNull(scope);
@@ -142,9 +141,9 @@
         return mapper.getLdapFilter(context, resource, path, subPath, type, operator, valueAssertion)
                 .thenAsync(new AsyncFunction<Filter, Filter, ResourceException>() {
                     @Override
-                    public Promise<Filter, ResourceException> apply(final Filter result) {
+                    public Promise<Filter, ResourceException> apply(final Filter filter) {
                         // Search for all referenced entries and construct a filter.
-                        final SearchRequest request = createSearchRequest(context, result);
+                        final SearchRequest request = createSearchRequest(context, filter);
                         final List<Filter> subFilters = new LinkedList<>();
 
                         return connectionFrom(context).searchAsync(request, new SearchResultHandler() {
@@ -325,8 +324,9 @@
         }
     }
 
-    private SearchRequest createSearchRequest(final Context context, final Filter result) {
-        final Filter searchFilter = filter != null ? Filter.and(filter, result) : result;
+    private SearchRequest createSearchRequest(final Context context, final Filter filter) {
+        final Filter searchFilter = this.filter != null ? Filter.and(this.filter, filter) : filter;
+
         return newSearchRequest(baseDnTemplate.format(context), scope, searchFilter, "1.1");
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java
index 9c8fbd0..4567647 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -34,6 +35,8 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -470,6 +473,46 @@
         return id;
     }
 
+    /**
+     * Gets a unique name for the configuration of this resource as a service in CREST.
+     *
+     * The name is the combination of the resource type and the writability of the resource. For
+     * example, {@code frapi:opendj:rest2ldap:group:1.0:read-write} or
+     * {@code frapi:opendj:rest2ldap:user:1.0:read-only}. Multiple resources can share the same
+     * service description if they manipulate the same resource type and have the same writability.
+     *
+     * @param  isReadOnly
+     *         Whether or not this resource is read-only.
+     *
+     * @return The unique service ID for this resource, given the specified writability.
+     */
+    String getServiceId(boolean isReadOnly) {
+        final StringBuilder serviceId = new StringBuilder(this.getResourceId());
+
+        if (isReadOnly) {
+            serviceId.append(":read-only");
+        } else {
+            serviceId.append(":read-write");
+        }
+
+        return serviceId.toString();
+    }
+
+    /**
+     * Gets a map of the sub-resources under this resource, keyed by URL template.
+     *
+     * @return  The map of sub-resource URL templates to sub-resources.
+     */
+    Map<String, SubResource> getSubResourceMap() {
+        final Map<String, SubResource> result = new HashMap<>();
+
+        for (SubResource subResource : this.subResources) {
+            result.put(subResource.getUrlTemplate(), subResource);
+        }
+
+        return result;
+    }
+
     void build(final Rest2Ldap rest2Ldap) {
         // Prevent re-entrant calls.
         if (isBuilt) {
@@ -522,7 +565,7 @@
 
         org.forgerock.api.models.Resource.Builder resource = org.forgerock.api.models.Resource.
             resource()
-            .title(id)
+            .title(this.getServiceId(isReadOnly))
             .description(toLS(description))
             .resourceSchema(schemaRef("#/definitions/" + id))
             .mvccSupported(isMvccSupported());
@@ -539,8 +582,8 @@
         return ApiDescription.apiDescription()
                       .id("unused").version("unused")
                       .definitions(definitions())
-                      .services(services(resource))
-                      .paths(paths())
+                      .services(services(resource, isReadOnly))
+                      .paths(paths(isReadOnly))
                       .errors(errors())
                       .build();
     }
@@ -555,13 +598,17 @@
     ApiDescription collectionApi(boolean isReadOnly) {
         org.forgerock.api.models.Resource.Builder resource = org.forgerock.api.models.Resource.
             resource()
-            .title(id)
+            .title(this.getServiceId(isReadOnly))
             .description(toLS(description))
             .resourceSchema(schemaRef("#/definitions/" + id))
             .mvccSupported(isMvccSupported());
 
         resource.items(buildItems(isReadOnly));
-        resource.create(createOperation(CreateMode.ID_FROM_SERVER));
+
+        if (!isReadOnly) {
+            resource.create(createOperation(CreateMode.ID_FROM_SERVER));
+        }
+
         resource.query(Query.query()
                            .stability(EVOLVING)
                            .type(QueryType.FILTER)
@@ -580,23 +627,29 @@
         return ApiDescription.apiDescription()
                              .id("unused").version("unused")
                              .definitions(definitions())
-                             .services(services(resource))
-                             .paths(paths())
+                             .services(services(resource, isReadOnly))
+                             .paths(paths(isReadOnly))
                              .errors(errors())
                              .build();
     }
 
-    private Services services(org.forgerock.api.models.Resource.Builder resource) {
+    private Services services(org.forgerock.api.models.Resource.Builder resource,
+                              boolean isReadOnly) {
+        final String serviceId = this.getServiceId(isReadOnly);
+
         return Services.services()
-                       .put(id, resource.build())
+                       .put(serviceId, resource.build())
                        .build();
     }
 
-    private Paths paths() {
+    private Paths paths(boolean isReadOnly) {
+        final String serviceId = this.getServiceId(isReadOnly);
+        final org.forgerock.api.models.Resource resource = resourceRef("#/services/" + serviceId);
+
         return Paths.paths()
                      // do not put anything in the path to avoid unfortunate string concatenation
                      // also use UNVERSIONED and rely on the router to stamp the version
-                     .put("", versionedPath().put(UNVERSIONED, resourceRef("#/services/" + id)).build())
+                     .put("", versionedPath().put(UNVERSIONED, resource).build())
                      .build();
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
index 66e0a5c..5efd956 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2015-2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -371,7 +372,7 @@
                                                  rfc7662.get("clientId").required().asString(),
                                                  rfc7662.get("clientSecret").required().asString());
         } catch (final URISyntaxException e) {
-            throw new IllegalArgumentException(ERR_CONIFG_OAUTH2_INVALID_INTROSPECT_URL.get(
+            throw new IllegalArgumentException(ERR_CONFIG_OAUTH2_INVALID_INTROSPECT_URL.get(
                     introspectionEndPointURL, e.getLocalizedMessage()).toString(), e);
         }
     }
@@ -394,8 +395,8 @@
             final Duration expiration = expirationJson.as(duration());
             if (expiration.isZero() || expiration.isUnlimited()) {
                 throw newJsonValueException(expirationJson,
-                                            expiration.isZero() ? ERR_CONIFG_OAUTH2_CACHE_ZERO_DURATION.get()
-                                                                : ERR_CONIFG_OAUTH2_CACHE_UNLIMITED_DURATION.get());
+                                            expiration.isZero() ? ERR_CONFIG_OAUTH2_CACHE_ZERO_DURATION.get()
+                                                                : ERR_CONFIG_OAUTH2_CACHE_UNLIMITED_DURATION.get());
             }
             return expiration;
         } catch (final Exception e) {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
index 65572d5..1a794c7 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
@@ -12,7 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
- *
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -306,43 +306,84 @@
     private enum NamingStrategyType { CLIENTDNNAMING, CLIENTNAMING, SERVERNAMING }
     private enum SubResourceType { COLLECTION, SINGLETON }
 
-    private static SubResource configureSubResource(final String urlTemplate, final JsonValue config) {
+    private static SubResource configureSubResource(final String urlTemplate,
+                                                    final JsonValue config) {
         final String dnTemplate = config.get("dnTemplate").defaultTo("").asString();
         final Boolean isReadOnly = config.get("isReadOnly").defaultTo(false).asBoolean();
         final String resourceId = config.get("resource").required().asString();
 
-        if (config.get("type").required().as(enumConstant(SubResourceType.class)) == SubResourceType.COLLECTION) {
-            final String[] glueObjectClasses = config.get("glueObjectClasses")
-                                                     .defaultTo(emptyList())
-                                                     .asList(String.class)
-                                                     .toArray(new String[0]);
+        final SubResourceType subResourceType =
+          config.get("type").required().as(enumConstant(SubResourceType.class));
 
-            final SubResourceCollection collection = collectionOf(resourceId).urlTemplate(urlTemplate)
-                                                                             .dnTemplate(dnTemplate)
-                                                                             .isReadOnly(isReadOnly)
-                                                                             .glueObjectClasses(glueObjectClasses);
-
-            final JsonValue namingStrategy = config.get("namingStrategy").required();
-            switch (namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class))) {
-            case CLIENTDNNAMING:
-                collection.useClientDnNaming(namingStrategy.get("dnAttribute").required().asString());
-                break;
-            case CLIENTNAMING:
-                collection.useClientNaming(namingStrategy.get("dnAttribute").required().asString(),
-                                           namingStrategy.get("idAttribute").required().asString());
-                break;
-            case SERVERNAMING:
-                collection.useServerNaming(namingStrategy.get("dnAttribute").required().asString(),
-                                           namingStrategy.get("idAttribute").required().asString());
-                break;
-            }
-
-            return collection;
+        if (subResourceType == SubResourceType.COLLECTION) {
+            return configureCollectionSubResource(
+                config, resourceId, urlTemplate, dnTemplate, isReadOnly);
         } else {
-            return singletonOf(resourceId).urlTemplate(urlTemplate).dnTemplate(dnTemplate).isReadOnly(isReadOnly);
+            return configureSingletonSubResource(
+                config, resourceId, urlTemplate, dnTemplate, isReadOnly);
         }
     }
 
+    private static SubResource configureCollectionSubResource(final JsonValue config,
+                                                              final String resourceId,
+                                                              final String urlTemplate,
+                                                              final String dnTemplate,
+                                                              final Boolean isReadOnly) {
+        final String[] glueObjectClasses =
+            config.get("glueObjectClasses")
+                .defaultTo(emptyList())
+                .asList(String.class)
+                .toArray(new String[0]);
+
+        final Boolean flattenSubtree = config.get("flattenSubtree").defaultTo(false).asBoolean();
+        final String searchFilter = config.get("baseSearchFilter").asString();
+
+        final SubResourceCollection collection =
+            collectionOf(resourceId)
+                .urlTemplate(urlTemplate)
+                .dnTemplate(dnTemplate)
+                .isReadOnly(isReadOnly)
+                .glueObjectClasses(glueObjectClasses)
+                .flattenSubtree(flattenSubtree)
+                .baseSearchFilter(searchFilter);
+
+        configureCollectionNamingStrategy(config, collection);
+
+        return collection;
+    }
+
+    private static void configureCollectionNamingStrategy(final JsonValue config,
+                                                          final SubResourceCollection collection) {
+        final JsonValue namingStrategy = config.get("namingStrategy").required();
+        final NamingStrategyType namingStrategyType =
+            namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class));
+
+        switch (namingStrategyType) {
+        case CLIENTDNNAMING:
+            collection.useClientDnNaming(namingStrategy.get("dnAttribute").required().asString());
+            break;
+        case CLIENTNAMING:
+            collection.useClientNaming(namingStrategy.get("dnAttribute").required().asString(),
+                                       namingStrategy.get("idAttribute").required().asString());
+            break;
+        case SERVERNAMING:
+            collection.useServerNaming(namingStrategy.get("dnAttribute").required().asString(),
+                                       namingStrategy.get("idAttribute").required().asString());
+            break;
+        }
+    }
+
+    private static SubResource configureSingletonSubResource(final JsonValue config,
+                                                             final String resourceId,
+                                                             final String urlTemplate,
+                                                             final String dnTemplate,
+                                                             final Boolean isReadOnly) {
+        return singletonOf(resourceId)
+                    .urlTemplate(urlTemplate)
+                    .dnTemplate(dnTemplate)
+                    .isReadOnly(isReadOnly);
+    }
+
     private static PropertyMapper configurePropertyMapper(final JsonValue mapper, final String defaultLdapAttribute) {
         switch (mapper.get("type").required().asString()) {
         case "resourceType":
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java
index 0e61043..4c96470 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java
@@ -12,7 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
- *
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -60,9 +60,10 @@
 
     String urlTemplate = "";
     String dnTemplateString = "";
-    boolean isReadOnly = false;
-    Rest2Ldap rest2Ldap;
-    Resource resource;
+
+    protected boolean isReadOnly = false;
+    protected Rest2Ldap rest2Ldap;
+    protected Resource resource;
 
     SubResource(final String resourceId) {
         this.resourceId = resourceId;
@@ -80,9 +81,27 @@
 
     @Override
     public final String toString() {
+        return getUrlTemplate();
+    }
+
+    /**
+     * Gets the URL template that must match for this sub-resource to apply to a given request.
+     *
+     * @return  The URL template for this sub-resource.
+     */
+    public String getUrlTemplate() {
         return urlTemplate;
     }
 
+    /**
+     * Gets whether or not this sub-resource has been configured for read-only access.
+     *
+     * @return  {@code true} if the sub-resource is read-only; {@code false} otherwise.
+     */
+    public boolean isReadOnly() {
+        return isReadOnly;
+    }
+
     final Resource getResource() {
         return resource;
     }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
index 7713ced..0e27feb 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -74,13 +75,39 @@
     private final Attribute glueObjectClasses = new LinkedAttribute("objectClass");
 
     private NamingStrategy namingStrategy;
+    private boolean flattenSubtree;
+    private Filter baseSearchFilter;
 
     SubResourceCollection(final String resourceId) {
         super(resourceId);
+
         useClientDnNaming("uid");
     }
 
     /**
+     * Gets whether or not this sub-resource should flatten sub-entries in results.
+     *
+     * @return  {@code true} if entries deep in the sub-tree are included in a flattened
+     *          collection view; {@code false} if only entries at the top level of the DN for this
+     *          sub-resource should be returned.
+     */
+    public boolean shouldFlattenSubtree() {
+        return flattenSubtree;
+    }
+
+    /**
+     * Gets the base filter that always restricts what LDAP entries are accessible through this
+     * collection, before any filters are applied from the request itself.
+     *
+     * The default is {@code null} (no base filter restriction at all).
+     *
+     * @return  Either a search filter; or {@code null} if no base search filter has been defined.
+     */
+    public Filter getBaseSearchFilter() {
+        return baseSearchFilter;
+    }
+
+    /**
      * Indicates that the JSON resource ID must be provided by the user, and will be used for naming the associated LDAP
      * entry. More specifically, LDAP entry names will be derived by appending a single RDN to the collection's base DN
      * composed of the specified attribute type and LDAP value taken from the LDAP entry once attribute mapping has been
@@ -213,12 +240,72 @@
     /**
      * Indicates whether this sub-resource collection only supports read and query operations.
      *
-     * @param readOnly
+     * @param isReadOnly
      *         {@code true} if this sub-resource collection is read-only.
      * @return A reference to this object.
      */
-    public SubResourceCollection isReadOnly(final boolean readOnly) {
-        isReadOnly = readOnly;
+    public SubResourceCollection isReadOnly(final boolean isReadOnly) {
+        this.isReadOnly = isReadOnly;
+        return this;
+    }
+
+    /**
+     * Controls whether or not LDAP entries in the hierarchy below the root entry of the resource
+     * collection are included in the list of resources (essentially, flattening the hierarchy
+     * into one collection of resources).
+     *
+     * This can only be used if the resource is read-only. The default is not to flatten, which
+     * preserves the legacy behavior of Rest2LDAP.
+     *
+     * @param  flattenSubtree
+     *         Whether or not to flatten the hierarchy by searching the entire subtree.
+     * @return A reference to this object.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public SubResourceCollection flattenSubtree(boolean flattenSubtree) {
+        if (flattenSubtree && !this.isReadOnly) {
+            throw new LocalizedIllegalArgumentException(
+                ERR_CONFIG_MUST_BE_READ_ONLY_TO_FLATTEN_SUBTREE.get());
+        }
+
+        this.flattenSubtree = flattenSubtree;
+        return this;
+    }
+
+    /**
+     * Sets the base filter that always restricts what LDAP entries are accessible through this
+     * collection, before any filters are applied from the request itself.
+     *
+     * The default is {@code null} (no base filter restriction at all).
+     *
+     * @param   filter
+     *          The filter which should be used to restrict which LDAP entries are returned.
+     * @return  A reference to this object.
+     */
+    public SubResourceCollection baseSearchFilter(final Filter filter) {
+        this.baseSearchFilter = filter;
+        return this;
+    }
+
+    /**
+     * Sets the base filter that always restricts what LDAP entries are accessible through this
+     * collection, before any filters are applied from the request itself.
+     *
+     * The default is {@code null} (no base filter restriction at all).
+     *
+     * @param   filter
+     *          The filter which should be used to restrict which LDAP entries are returned.
+     * @return  A reference to this object.
+     */
+    public SubResourceCollection baseSearchFilter(final String filter) {
+        if (filter == null) {
+            baseSearchFilter((Filter)null);
+        }
+        else {
+            baseSearchFilter(Filter.valueOf(filter));
+        }
+
         return this;
     }
 
@@ -256,11 +343,14 @@
     }
 
     private SubResourceImpl collection(final Context context) {
-        return new SubResourceImpl(rest2Ldap,
-                                   dnFrom(context),
-                                   dnTemplateString.isEmpty() ? null : glueObjectClasses,
-                                   namingStrategy,
-                                   resource);
+        return new SubResourceImpl(
+            rest2Ldap,
+            dnFrom(context),
+            dnTemplateString.isEmpty() ? null : glueObjectClasses,
+            namingStrategy,
+            resource,
+            flattenSubtree,
+            baseSearchFilter);
     }
 
     private String idFrom(final Context context) {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
index 7cec13f..f731a1b 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -31,7 +32,6 @@
 import static org.forgerock.opendj.ldap.ByteString.valueOfBytes;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.ldap.Filter.alwaysTrue;
-import static org.forgerock.opendj.ldap.SearchScope.SINGLE_LEVEL;
 import static org.forgerock.opendj.ldap.requests.Requests.*;
 import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
 import static org.forgerock.opendj.rest2ldap.RoutingContext.newCollectionRoutingContext;
@@ -140,9 +140,17 @@
     private final boolean usePermissiveModify;
     private final Resource resource;
     private final Attribute glueObjectClasses;
+    private final boolean flattenSubtree;
+    private final Filter baseSearchFilter;
 
     SubResourceImpl(final Rest2Ldap rest2Ldap, final DN baseDn, final Attribute glueObjectClasses,
                     final NamingStrategy namingStrategy, final Resource resource) {
+        this(rest2Ldap, baseDn, glueObjectClasses, namingStrategy, resource, false, null);
+    }
+
+    SubResourceImpl(final Rest2Ldap rest2Ldap, final DN baseDn, final Attribute glueObjectClasses,
+                    final NamingStrategy namingStrategy, final Resource resource,
+                    final boolean flattenSubtree, final Filter baseSearchFilter) {
         this.readOnUpdatePolicy = rest2Ldap.getOptions().get(READ_ON_UPDATE_POLICY);
         this.useSubtreeDelete = rest2Ldap.getOptions().get(USE_SUBTREE_DELETE);
         this.usePermissiveModify = rest2Ldap.getOptions().get(USE_PERMISSIVE_MODIFY);
@@ -153,6 +161,8 @@
         this.glueObjectClasses = glueObjectClasses;
         this.namingStrategy = namingStrategy;
         this.resource = resource;
+        this.flattenSubtree = flattenSubtree;
+        this.baseSearchFilter = baseSearchFilter;
     }
 
     Promise<ActionResponse, ResourceException> action(
@@ -504,9 +514,34 @@
     Promise<QueryResponse, ResourceException> query(
             final Context context, final QueryRequest request, final QueryResourceHandler resourceHandler) {
         return getLdapFilter(context, request.getQueryFilter())
+                .then(applyBaseSearchFilter())
                 .thenAsync(runQuery(context, request, resourceHandler));
     }
 
+    /**
+     * Generates a function that applies any base filter that this sub-resource may have been
+     * initialized with.
+     *
+     * @return  The function to invoke to apply a base filter, if one has been specified.
+     */
+    private Function<Filter, Filter, ResourceException> applyBaseSearchFilter() {
+        return new Function<Filter, Filter, ResourceException>() {
+            @Override
+            public Filter apply(final Filter requestFilter) throws ResourceException {
+                final Filter baseSearchFilter = SubResourceImpl.this.baseSearchFilter,
+                             searchFilter;
+
+                if (baseSearchFilter != null) {
+                    searchFilter = Filter.and(baseSearchFilter, requestFilter);
+                } else {
+                    searchFilter = requestFilter;
+                }
+
+                return searchFilter;
+            }
+        };
+    }
+
     // FIXME: supporting assertions against sub-type properties.
     private Promise<Filter, ResourceException> getLdapFilter(
             final Context context, final QueryFilter<JsonPointer> queryFilter) {
@@ -523,6 +558,7 @@
                     public Promise<Filter, ResourceException> visitAndFilter(
                             final Void unused, final List<QueryFilter<JsonPointer>> subFilters) {
                         final List<Promise<Filter, ResourceException>> promises = new ArrayList<>(subFilters.size());
+
                         for (final QueryFilter<JsonPointer> subFilter : subFilters) {
                             promises.add(subFilter.accept(this, unused));
                         }
@@ -532,14 +568,17 @@
                             public Filter apply(final List<Filter> value) {
                                 // Check for unmapped filter components and optimize.
                                 final Iterator<Filter> i = value.iterator();
+
                                 while (i.hasNext()) {
                                     final Filter f = i.next();
+
                                     if (f == alwaysFalse()) {
                                         return alwaysFalse();
                                     } else if (f == alwaysTrue()) {
                                         i.remove();
                                     }
                                 }
+
                                 switch (value.size()) {
                                 case 0:
                                     return alwaysTrue();
@@ -673,6 +712,7 @@
                                 parentDnAndType, resource, ROOT, field, STARTS_WITH, null, valueAssertion);
                     }
                 };
+        
         // Note that the returned LDAP filter may be null if it could not be mapped by any property mappers.
         return queryFilter.accept(visitor, null);
     }
@@ -700,7 +740,7 @@
                 final String[] attributes = getLdapAttributesForUnknownType(request.getFields()).toArray(new String[0]);
                 final Filter searchFilter = ldapFilter == Filter.alwaysTrue() ? Filter.objectClassPresent()
                         : ldapFilter;
-                final SearchRequest searchRequest = newSearchRequest(baseDn, SINGLE_LEVEL, searchFilter, attributes);
+                final SearchRequest searchRequest = createSearchRequest(searchFilter, attributes);
 
                 // Add the page results control. We can support the page offset by reading the next offset pages, or
                 // offset x page size resources.
@@ -1064,6 +1104,34 @@
         return namingStrategy.createSearchRequest(baseDn, resourceId).addAttribute(attributes);
     }
 
+    /**
+     * Creates a request to search LDAP for entries that match the provided search filter, and
+     * the specified attributes.
+     *
+     * If the subtree flattening is enabled, the search request will encompass the whole subtree.
+     *
+     * @param   searchFilter
+     *          The filter that entries must match to be returned.
+     * @param   desiredAttributes
+     *          The names of the attributes to be included with each entry.
+     *
+     * @return  The resulting search request.
+     */
+    private SearchRequest createSearchRequest(Filter searchFilter, String[] desiredAttributes) {
+        final SearchScope searchScope;
+        final SearchRequest searchRequest;
+
+        if (SubResourceImpl.this.flattenSubtree) {
+            searchScope = SearchScope.SUBORDINATES;
+        } else {
+            searchScope = SearchScope.SINGLE_LEVEL;
+        }
+
+        searchRequest = newSearchRequest(baseDn, searchScope, searchFilter, desiredAttributes);
+
+        return searchRequest;
+    }
+
     @SuppressWarnings("unused")
     private static <R> AsyncFunction<LdapException, R, ResourceException> adaptLdapException(final Class<R> clazz) {
         return new AsyncFunction<LdapException, R, ResourceException>() {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
index 77976f2..6182a24 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -139,7 +140,8 @@
     }
 
     private SubResourceImpl singleton(final Context context) {
-        return new SubResourceImpl(rest2Ldap, dnFrom(context), null, SINGLETON_NAMING_STRATEGY, resource);
+        return new SubResourceImpl(
+            rest2Ldap, dnFrom(context), null, SINGLETON_NAMING_STRATEGY, resource);
     }
 
     /**
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index 3c1e587..40631a1 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -12,14 +12,15 @@
 # information: "Portions Copyright [year] [name of copyright owner]".
 #
 # Copyright 2016 ForgeRock AS.
+# Portions Copyright 2017 Rosie Applications, Inc.
 #
 
 # Configuration errors
 ERR_FAIL_PARSE_CONFIGURATION_1=Unable to start Rest2Ldap Http Application due to the configuration error: '%s'
 ERR_CONFIG_OAUTH2_UNSUPPORTED_ACCESS_TOKEN_RESOLVER_2= '%s'is not a supported access token resolver. Must be one of '%s'
-ERR_CONIFG_OAUTH2_INVALID_INTROSPECT_URL_3=The token introspection endpoint '%s' is not a valid URL: '%s'
-ERR_CONIFG_OAUTH2_CACHE_ZERO_DURATION_4=The cache expiration duration cannot be zero
-ERR_CONIFG_OAUTH2_CACHE_UNLIMITED_DURATION_5=The cache expiration duration cannot be unlimited
+ERR_CONFIG_OAUTH2_INVALID_INTROSPECT_URL_3=The token introspection endpoint '%s' is not a valid URL: '%s'
+ERR_CONFIG_OAUTH2_CACHE_ZERO_DURATION_4=The cache expiration duration cannot be zero
+ERR_CONFIG_OAUTH2_CACHE_UNLIMITED_DURATION_5=The cache expiration duration cannot be unlimited
 ERR_CONFIG_OAUTH2_CACHE_INVALID_DURATION_6=Malformed duration value '%s' for cache expiration. \
  The duration syntax supports all human readable notations from day ('days'', 'day'', 'd'') to nanosecond \
  ('nanoseconds', 'nanosecond', 'nanosec', 'nanos', 'nano', 'ns')
@@ -148,3 +149,4 @@
 ERR_PATCH_JSON_INTERNAL_PROPERTY_90=The patch request cannot be processed because it attempts to modify the \
   internal field '%s' of object '%s'. This capability is not currently supported by Rest2Ldap. Applications should \
   instead perform a patch which replaces the entire object '%s'
+ERR_CONFIG_MUST_BE_READ_ONLY_TO_FLATTEN_SUBTREE_91=Sub-resources must be read-only to support sub-tree flattening.
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
index 343ed60..cb1bab0 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
@@ -12,6 +12,7 @@
  * information: "Portions copyright [year] [name of copyright owner]".
  *
  * Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -92,72 +93,331 @@
     private static final QueryFilter<JsonPointer> NO_FILTER = QueryFilter.alwaysTrue();
 
     @Test
-    public void testQueryAll() throws Exception {
+    public void testQueryAllWithNoSubtreeFlatteningAndNoSearchFilter() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new LinkedList<>();
-        final QueryResponse result = connection.query(
-                newAuthConnectionContext(), newQueryRequest("").setQueryFilter(NO_FILTER), resources);
-        assertThat(resources).hasSize(5);
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("").setQueryFilter(NO_FILTER),
+                resources);
+
+        assertThat(resources).hasSize(7);
         assertThat(result.getPagedResultsCookie()).isNull();
         assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        checkThatOrgUnitsExist(resources, "level1");
+
+        checkThatUsersExist(resources, 1,
+          "test1",
+          "test2",
+          "test3",
+          "test4",
+          "test5",
+          "test6"
+        );
     }
 
     @Test
-    public void testQueryNone() throws Exception {
+    public void testQueryAllWithSearchFilterAndNoSubtreeFlattening() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new LinkedList<>();
-        final QueryResponse result = connection.query(newAuthConnectionContext(),
-                newQueryRequest("").setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()), resources);
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("top-level-users").setQueryFilter(NO_FILTER),
+                resources);
+
+        assertThat(resources).hasSize(6);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        checkThatUsersExist(resources,
+          "test1",
+          "test2",
+          "test3",
+          "test4",
+          "test5",
+          "test6"
+        );
+    }
+
+    @Test
+    public void testQueryAllWithSubtreeFlatteningAndNoSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-entries").setQueryFilter(NO_FILTER),
+                resources);
+
+        assertThat(resources).hasSize(10);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        checkThatOrgUnitsExist(resources,
+          "level1",
+          "level2"
+        );
+
+        checkThatUsersExist(resources, 2,
+          "sub2",
+          "sub1",
+          "test1",
+          "test2",
+          "test3",
+          "test4",
+          "test5",
+          "test6"
+        );
+    }
+
+    @Test
+    public void testQueryAllWithSubtreeFlatteningAndSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-users").setQueryFilter(NO_FILTER),
+                resources);
+
+        assertThat(resources).hasSize(8);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        checkThatUsersExist(resources,
+          "sub2",
+          "sub1",
+          "test1",
+          "test2",
+          "test3",
+          "test4",
+          "test5",
+          "test6"
+        );
+    }
+
+    @Test
+    public void testQueryNoneWithNoSubtreeFlatteningAndNoSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("").setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()),
+                resources);
+
         assertThat(resources).hasSize(0);
         assertThat(result.getPagedResultsCookie()).isNull();
         assertThat(result.getTotalPagedResults()).isEqualTo(-1);
     }
 
     @Test
-    public void testQueryPageResultsCookie() throws Exception {
+    public void testQueryNoneWithSearchFilterAndNoSubtreeFlattening() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("top-level-users")
+                    .setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()),
+                resources);
+
+        assertThat(resources).hasSize(0);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+    }
+
+    @Test
+    public void testQueryNoneWithSubtreeFlatteningAndNoSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-entries")
+                    .setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()),
+                resources);
+
+        assertThat(resources).hasSize(0);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+    }
+
+    @Test
+    public void testQueryNoneWithSubtreeFlatteningAndSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result = connection.query(newAuthConnectionContext(),
+          newQueryRequest("all-users").setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()), resources);
+
+        assertThat(resources).hasSize(0);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+    }
+
+    @Test
+    public void testQueryPageResultsCookieWithNoSubtreeFlatteningAndNoSearchFilter()
+    throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new ArrayList<>();
 
         // Read first page.
-        QueryResponse result = connection.query(
-                newAuthConnectionContext(), newQueryRequest("").setQueryFilter(NO_FILTER).setPageSize(2), resources);
+        QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(3),
+                resources);
+
         assertThat(result.getPagedResultsCookie()).isNotNull();
-        assertThat(resources).hasSize(2);
-        assertThat(resources.get(0).getId()).isEqualTo("test1");
-        assertThat(resources.get(1).getId()).isEqualTo("test2");
+        assertThat(resources).hasSize(3);
+
+        checkThatOrgUnitsExist(resources, "level1");
+
+        checkThatUsersExist(resources, 1,
+          "test1",
+          "test2");
 
         String cookie = result.getPagedResultsCookie();
+
         resources.clear();
 
         // Read second page.
-        result = connection.query(newAuthConnectionContext(),
-                newQueryRequest("").setQueryFilter(NO_FILTER).setPageSize(2).setPagedResultsCookie(cookie), resources);
+        result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(3)
+                    .setPagedResultsCookie(cookie),
+                resources);
+
         assertThat(result.getPagedResultsCookie()).isNotNull();
-        assertThat(resources).hasSize(2);
-        assertThat(resources.get(0).getId()).isEqualTo("test3");
-        assertThat(resources.get(1).getId()).isEqualTo("test4");
+        assertThat(resources).hasSize(3);
+
+        checkThatUsersExist(resources,
+            "test3",
+            "test4",
+            "test5");
 
         cookie = result.getPagedResultsCookie();
+
         resources.clear();
 
         // Read third page.
-        result = connection.query(newAuthConnectionContext(),
-                newQueryRequest("").setQueryFilter(NO_FILTER).setPageSize(2).setPagedResultsCookie(cookie), resources);
+        result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(3)
+                    .setPagedResultsCookie(cookie),
+                resources);
+
         assertThat(result.getPagedResultsCookie()).isNull();
         assertThat(resources).hasSize(1);
-        assertThat(resources.get(0).getId()).isEqualTo("test5");
+
+        checkThatUsersExist(resources,
+            "test6");
     }
 
     @Test
-    public void testQueryPageResultsIndexed() throws Exception {
+    public void testQueryPageResultsCookieWithSubtreeFlatteningAndSearchFilter() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new ArrayList<>();
-        QueryResponse result = connection.query(newAuthConnectionContext(),
-                newQueryRequest("").setQueryFilter(NO_FILTER).setPageSize(2).setPagedResultsOffset(1), resources);
+
+        // Read first page.
+        QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-users")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(5),
+                resources);
+
+        assertThat(result.getPagedResultsCookie()).isNotNull();
+        assertThat(resources).hasSize(5);
+
+        checkThatUsersExist(resources,
+            "sub2",
+            "sub1",
+            "test1",
+            "test2",
+            "test3");
+
+        String cookie = result.getPagedResultsCookie();
+
+        resources.clear();
+
+        // Read second page.
+        result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-users")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(5)
+                    .setPagedResultsCookie(cookie),
+                resources);
+
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(resources).hasSize(3);
+
+        checkThatUsersExist(resources,
+            "test4",
+            "test5",
+            "test6");
+
+        resources.clear();
+    }
+
+    @Test
+    public void testQueryPageResultsIndexedWithNoSubtreeFlatteningAndNoSearchFilter()
+    throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new ArrayList<>();
+
+        QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(2)
+                    .setPagedResultsOffset(1),
+                resources);
+
         assertThat(result.getPagedResultsCookie()).isNotNull();
         assertThat(resources).hasSize(2);
-        assertThat(resources.get(0).getId()).isEqualTo("test3");
-        assertThat(resources.get(1).getId()).isEqualTo("test4");
+
+        checkThatUsersExist(resources,
+            "test2",
+            "test3");
+    }
+
+    @Test
+    public void testQueryPageResultsIndexedWithSubtreeFlatteningAndSearchFilter() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new ArrayList<>();
+
+        QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-users")
+                    .setQueryFilter(NO_FILTER)
+                    .setPageSize(3)
+                    .setPagedResultsOffset(1),
+                resources);
+
+        assertThat(result.getPagedResultsCookie()).isNotNull();
+        assertThat(resources).hasSize(3);
+
+        checkThatUsersExist(resources,
+            "test2",
+            "test3",
+            "test4");
     }
 
     @Test(expectedExceptions = NotFoundException.class)
@@ -165,6 +425,7 @@
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
         final ResourceResponse resource = connection.delete(context, newDeleteRequest("/test1"));
+
         checkResourcesAreEqual(resource, getTestUser1(12345));
         connection.read(context, newReadRequest("/test1"));
     }
@@ -173,7 +434,9 @@
     public void testDeleteMVCCMatch() throws Exception {
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
-        final ResourceResponse resource = connection.delete(context, newDeleteRequest("/test1").setRevision("12345"));
+        final ResourceResponse resource =
+            connection.delete(context, newDeleteRequest("/test1").setRevision("12345"));
+
         checkResourcesAreEqual(resource, getTestUser1(12345));
         connection.read(context, newReadRequest("/test1"));
     }
@@ -182,6 +445,7 @@
     public void testDeleteMVCCNoMatch() throws Exception {
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
+
         connection.delete(context, newDeleteRequest("/test1").setRevision("12346"));
     }
 
@@ -189,6 +453,7 @@
     public void testDeleteNotFound() throws Exception {
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
+
         connection.delete(context, newDeleteRequest("/missing"));
     }
 
@@ -199,6 +464,7 @@
         final ResourceResponse resource1 = connection.patch(context,
                 newPatchRequest("/test1", add("/name/displayName", "changed")));
         checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
     }
@@ -209,6 +475,7 @@
         final Context context = newAuthConnectionContext(requests);
         final Connection connection = newConnection();
         final ResourceResponse resource1 = connection.patch(context, newPatchRequest("/test1"));
+
         checkResourcesAreEqual(resource1, getTestUser1(12345));
 
         /*
@@ -227,11 +494,16 @@
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1(12345);
+
         newContent.put("description", asList("one", "two"));
+
         final ResourceResponse resource1 =
                 connection.patch(context,
-                                 newPatchRequest("/test1", add("/description", asList("one", "two"))));
+                                 newPatchRequest(
+                                    "/test1",
+                                    add("/description", asList("one", "two"))));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
@@ -241,26 +513,37 @@
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1(12345);
+
         newContent.put("description", asList("one", "two"));
-        final ResourceResponse resource1 = connection.patch(
-            context, newPatchRequest("/test1", add("/description/-", "one"), add("/description/-", "two")));
+
+        final ResourceResponse resource1 =
+            connection.patch(
+                context,
+                newPatchRequest("/test1", add("/description/-", "one"),
+                                          add("/description/-", "two")));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchConstantAttribute() throws Exception {
-        newConnection().patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/schemas", asList("junk"))));
+        newConnection().patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/schemas", asList("junk"))));
     }
 
     @Test
     public void testPatchDeleteOptionalAttribute() throws Exception {
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
+
         connection.patch(context, newPatchRequest("/test1", add("/description", asList("one", "two"))));
+
         final ResourceResponse resource1 = connection.patch(context, newPatchRequest("/test1", remove("/description")));
         checkResourcesAreEqual(resource1, getTestUser1(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1(12345));
     }
@@ -270,34 +553,52 @@
         final Context context = newAuthConnectionContext();
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1(12345);
+
         newContent.put("singleNumber", 100);
         newContent.put("multiNumber", asList(200, 300));
 
-        final ResourceResponse resource1 = connection.patch(context, newPatchRequest("/test1",
-            add("/singleNumber", 0),
-            add("/multiNumber", asList(100, 200)),
-            increment("/singleNumber", 100),
-            increment("/multiNumber", 100)));
+        final ResourceResponse resource1 =
+            connection.patch(
+                context,
+                newPatchRequest(
+                    "/test1",
+                    add("/singleNumber", 0),
+                    add("/multiNumber", asList(100, 200)),
+                    increment("/singleNumber", 100),
+                    increment("/multiNumber", 100)));
+
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchMissingRequiredAttribute() throws Exception {
-        newConnection().patch(newAuthConnectionContext(), newPatchRequest("/test1", remove("/name/surname")));
+        newConnection().patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", remove("/name/surname")));
     }
 
     @Test
     public void testPatchModifyOptionalAttribute() throws Exception {
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
-        connection.patch(context, newPatchRequest("/test1", add("/description", asList("one", "two"))));
+
+        connection.patch(
+            context,
+            newPatchRequest("/test1", add("/description", asList("one", "two"))));
+
         final ResourceResponse resource1 =
-                connection.patch(context, newPatchRequest("/test1", add("/description", asList("three"))));
+            connection.patch(
+                context,
+                newPatchRequest("/test1", add("/description", asList("three"))));
+
         final JsonValue newContent = getTestUser1(12345);
+
         newContent.put("description", asList("one", "two", "three"));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
@@ -305,29 +606,42 @@
     @Test(expectedExceptions = NotSupportedException.class)
     public void testPatchMultiValuedAttributeIndexAppend() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/description/0", "junk")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/description/0", "junk")));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchMultiValuedAttributeIndexAppendWithList() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/description/-",
-                asList("one", "two"))));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/description/-", asList("one", "two"))));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchMultiValuedAttributeWithSingleValue() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/description", "one")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/description", "one")));
     }
 
     @Test
     public void testPatchMVCCMatch() throws Exception {
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
-        final ResourceResponse resource1 = connection.patch(
-                context, newPatchRequest("/test1", add("/name/displayName", "changed")).setRevision("12345"));
+        final ResourceResponse resource1 =
+            connection.patch(
+                context,
+                newPatchRequest(
+                    "/test1", add("/name/displayName", "changed")).setRevision("12345"));
+
         checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
     }
@@ -335,22 +649,27 @@
     @Test(expectedExceptions = PreconditionFailedException.class)
     public void testPatchMVCCNoMatch() throws Exception {
         final Connection connection = newConnection();
+
         connection.patch(
-                newAuthConnectionContext(),
-                newPatchRequest("/test1", add("/name/displayName", "changed")).setRevision("12346"));
+            newAuthConnectionContext(),
+            newPatchRequest(
+                "/test1",
+                add("/name/displayName", "changed")).setRevision("12346"));
     }
 
     @Test(expectedExceptions = NotFoundException.class)
     public void testPatchNotFound() throws Exception {
         newConnection().patch(
-                newAuthConnectionContext(),
-                newPatchRequest("/missing", add("/name/displayName", "changed")));
+            newAuthConnectionContext(),
+            newPatchRequest("/missing", add("/name/displayName", "changed")));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchReadOnlyAttribute() throws Exception {
         // Etag is read-only.
-        newConnection().patch(newAuthConnectionContext(), newPatchRequest("/test1", add("_rev", "99999")));
+        newConnection().patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("_rev", "99999")));
     }
 
     @Test
@@ -363,9 +682,15 @@
             field("_rev", "12345"),
             field("name", object(field("displayName", "Humpty"),
                                  field("surname", "Dumpty")))));
-        final ResourceResponse resource1 = connection.patch(context, newPatchRequest("/test1",
-            replace("/name", object(field("displayName", "Humpty"), field("surname", "Dumpty")))));
+
+        final ResourceResponse resource1 =
+            connection.patch(
+                context,
+                newPatchRequest("/test1",
+                replace("/name", object(field("displayName", "Humpty"),
+                                        field("surname",     "Dumpty")))));
         checkResourcesAreEqual(resource1, expected);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, expected);
     }
@@ -374,18 +699,23 @@
     public void testPatchReplaceWholeObject() throws Exception {
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
-        final JsonValue newContent = json(object(
-            field("name", object(field("displayName", "Humpty"),
-                                 field("surname", "Dumpty")))));
-        final JsonValue expected = json(object(
-            field("schemas", asList("urn:scim:schemas:core:1.0")),
-            field("_id", "test1"),
-            field("_rev", "12345"),
-            field("name", object(field("displayName", "Humpty"),
-                                 field("surname", "Dumpty")))));
+        final JsonValue newContent =
+            json(object(
+                field("name", object(field("displayName", "Humpty"),
+                                     field("surname",     "Dumpty")))));
+
+        final JsonValue expected =
+            json(object(
+                field("schemas", asList("urn:scim:schemas:core:1.0")),
+                field("_id", "test1"),
+                field("_rev", "12345"),
+                field("name", object(field("displayName", "Humpty"),
+                                     field("surname",     "Dumpty")))));
+
         final ResourceResponse resource1 =
-                connection.patch(context, newPatchRequest("/test1", replace("/", newContent)));
+            connection.patch(context, newPatchRequest("/test1", replace("/", newContent)));
         checkResourcesAreEqual(resource1, expected);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, expected);
     }
@@ -393,32 +723,48 @@
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchSingleValuedAttributeIndexAppend() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/name/surname/-", "junk")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/name/surname/-", "junk")));
     }
 
     @Test(expectedExceptions = NotSupportedException.class)
     public void testPatchSingleValuedAttributeIndexNumber() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/name/surname/0", "junk")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/name/surname/0", "junk")));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchSingleValuedAttributeWithMultipleValues() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/name/surname", asList("black",
-                "white"))));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest(
+                "/test1",
+                add("/name/surname", asList("black", "white"))));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchUnknownAttribute() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/dummy", "junk")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/dummy", "junk")));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testPatchUnknownSubAttribute() throws Exception {
         final Connection connection = newConnection();
-        connection.patch(newAuthConnectionContext(), newPatchRequest("/test1", add("/description/dummy", "junk")));
+
+        connection.patch(
+            newAuthConnectionContext(),
+            newPatchRequest("/test1", add("/description/dummy", "junk")));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
@@ -430,7 +776,9 @@
 
     @Test
     public void testRead() throws Exception {
-        final ResourceResponse resource = newConnection().read(newAuthConnectionContext(), newReadRequest("/test1"));
+        final ResourceResponse resource =
+            newConnection().read(newAuthConnectionContext(), newReadRequest("/test1"));
+
         checkResourcesAreEqual(resource, getTestUser1(12345));
     }
 
@@ -441,15 +789,19 @@
 
     @Test
     public void testReadSelectAllFields() throws Exception {
-        final ResourceResponse resource = newConnection().read(newAuthConnectionContext(),
-                                                               newReadRequest("/test1").addField("/"));
+        final ResourceResponse resource =
+            newConnection().read(
+                newAuthConnectionContext(), newReadRequest("/test1").addField("/"));
+
         checkResourcesAreEqual(resource, getTestUser1(12345));
     }
 
     @Test
     public void testReadSelectPartial() throws Exception {
-        final ResourceResponse resource = newConnection().read(
-            newAuthConnectionContext(), newReadRequest("/test1").addField("/name/surname"));
+        final ResourceResponse resource =
+            newConnection().read(
+                newAuthConnectionContext(), newReadRequest("/test1").addField("/name/surname"));
+
         assertThat(resource.getId()).isEqualTo("test1");
         assertThat(resource.getRevision()).isEqualTo("12345");
         assertThat(resource.getContent().get("_id").asString()).isNull();
@@ -461,8 +813,10 @@
     /** Disabled - see CREST-86 (Should JSON resource fields be case insensitive?) */
     @Test(enabled = false)
     public void testReadSelectPartialInsensitive() throws Exception {
-        final ResourceResponse resource = newConnection().read(
-            newAuthConnectionContext(), newReadRequest("/test1").addField("/name/SURNAME"));
+        final ResourceResponse resource =
+            newConnection().read(
+                newAuthConnectionContext(), newReadRequest("/test1").addField("/name/SURNAME"));
+
         assertThat(resource.getId()).isEqualTo("test1");
         assertThat(resource.getRevision()).isEqualTo("12345");
         assertThat(resource.getContent().get("_id").asString()).isNull();
@@ -476,8 +830,9 @@
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
         final ResourceResponse resource1 = connection.update(
-                context, newUpdateRequest("/test1", getTestUser1Updated(12345)));
+            context, newUpdateRequest("/test1", getTestUser1Updated(12345)));
         checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
     }
@@ -487,14 +842,15 @@
         final List<Request> requests = new LinkedList<>();
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext(requests);
-        final ResourceResponse resource1 = connection.update(context, newUpdateRequest("/test1", getTestUser1(12345)));
+        final ResourceResponse resource1 =
+            connection.update(context, newUpdateRequest("/test1", getTestUser1(12345)));
 
         // Check that no modify operation was sent
         // (only a single search should be sent in order to get the current resource).
         assertThat(requests).hasSize(1);
         assertThat(requests.get(0)).isInstanceOf(SearchRequest.class);
-
         checkResourcesAreEqual(resource1, getTestUser1(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1(12345));
     }
@@ -504,9 +860,13 @@
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.put("description", asList("one", "two"));
-        final ResourceResponse resource1 = connection.update(context, newUpdateRequest("/test1", newContent));
+
+        final ResourceResponse resource1 =
+            connection.update(context, newUpdateRequest("/test1", newContent));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
@@ -515,6 +875,7 @@
     public void testUpdateConstantAttribute() throws Exception {
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.put("schemas", asList("junk"));
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
     }
@@ -524,11 +885,15 @@
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.put("description", asList("one", "two"));
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
         newContent.remove("description");
-        final ResourceResponse resource1 = connection.update(context, newUpdateRequest("/test1", newContent));
+
+        final ResourceResponse resource1 =
+          connection.update(context, newUpdateRequest("/test1", newContent));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
@@ -537,6 +902,7 @@
     public void testUpdateMissingRequiredAttribute() throws Exception {
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.get("name").remove("surname");
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
     }
@@ -546,11 +912,15 @@
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.put("description", asList("one", "two"));
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
         newContent.put("description", asList("three"));
-        final ResourceResponse resource1 = connection.update(context, newUpdateRequest("/test1", newContent));
+
+        final ResourceResponse resource1 =
+            connection.update(context, newUpdateRequest("/test1", newContent));
         checkResourcesAreEqual(resource1, newContent);
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, newContent);
     }
@@ -560,8 +930,11 @@
         final Connection connection = newConnection();
         final Context context = newAuthConnectionContext();
         final ResourceResponse resource1 =
-                connection.update(context, newUpdateRequest("/test1", getTestUser1Updated(12345)).setRevision("12345"));
+            connection.update(
+                context,
+                newUpdateRequest("/test1", getTestUser1Updated(12345)).setRevision("12345"));
         checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+
         final ResourceResponse resource2 = connection.read(context, newReadRequest("/test1"));
         checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
     }
@@ -569,27 +942,36 @@
     @Test(expectedExceptions = PreconditionFailedException.class)
     public void testUpdateMVCCNoMatch() throws Exception {
         final Connection connection = newConnection();
-        connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", getTestUser1Updated(12345))
-                .setRevision("12346"));
+
+        connection.update(
+            newAuthConnectionContext(),
+            newUpdateRequest("/test1", getTestUser1Updated(12345)).setRevision("12346"));
     }
 
     @Test(expectedExceptions = NotFoundException.class)
     public void testUpdateNotFound() throws Exception {
         final Connection connection = newConnection();
-        connection.update(newAuthConnectionContext(), newUpdateRequest("/missing", getTestUser1Updated(12345)));
+
+        connection.update(
+            newAuthConnectionContext(),
+            newUpdateRequest("/missing", getTestUser1Updated(12345)));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testUpdateReadOnlyAttribute() throws Exception {
         final Connection connection = newConnection();
+
         // Etag is read-only.
-        connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", getTestUser1Updated(99999)));
+        connection.update(
+            newAuthConnectionContext(),
+            newUpdateRequest("/test1", getTestUser1Updated(99999)));
     }
 
     @Test(expectedExceptions = BadRequestException.class)
     public void testUpdateSingleValuedAttributeWithMultipleValues() throws Exception {
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.put("surname", asList("black", "white"));
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
     }
@@ -598,6 +980,7 @@
     public void testUpdateUnknownAttribute() throws Exception {
         final Connection connection = newConnection();
         final JsonValue newContent = getTestUser1Updated(12345);
+
         newContent.add("dummy", "junk");
         connection.update(newAuthConnectionContext(), newUpdateRequest("/test1", newContent));
     }
@@ -607,40 +990,120 @@
     }
 
     private Rest2Ldap usersApi() throws IOException {
-        return rest2Ldap(defaultOptions(),
-                         resource("api").subResource(collectionOf("user").dnTemplate("dc=test")
-                                                                         .useClientDnNaming("uid")),
-                         resource("user").objectClasses("top", "person")
-                                         .property("schemas", constant(asList("urn:scim:schemas:core:1.0")))
-                                         .property("_id", simple("uid").isRequired(true).writability(CREATE_ONLY))
-                                         .property("name", object().property("displayName",
-                                                                             simple("cn").isRequired(true))
-                                                                   .property("surname", simple("sn").isRequired(true)))
-                                         .property("_rev", simple("etag").isRequired(true).writability(READ_ONLY))
-                                         .property("description", simple("description").isMultiValued(true))
-                                         .property("singleNumber",
-                                                   simple("singleNumber").decoder(byteStringToInteger()))
-                                         .property("multiNumber",
-                                                   simple("multiNumber").isMultiValued(true)
-                                                                        .decoder(byteStringToInteger())));
+        return rest2Ldap(
+            defaultOptions(),
+            resource("api")
+                .subResource(
+                    collectionOf("user")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid"))
+                .subResource(
+                    collectionOf("user")
+                        .urlTemplate("top-level-users")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid")
+                        .baseSearchFilter("(objectClass=person)"))
+                .subResource(
+                    collectionOf("user")
+                        .urlTemplate("all-entries")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid")
+                        .isReadOnly(true)
+                        .flattenSubtree(true))
+                .subResource(
+                    collectionOf("user")
+                        .urlTemplate("all-users")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid")
+                        .isReadOnly(true)
+                        .flattenSubtree(true)
+                        .baseSearchFilter("(objectClass=person)")),
+            resource("user")
+                .objectClasses("top", "person")
+                .property(
+                    "schemas",
+                    constant(asList("urn:scim:schemas:core:1.0")))
+                .property(
+                    "_id",
+                    simple("uid").isRequired(true).writability(CREATE_ONLY))
+                .property(
+                    "_ou",
+                    simple("ou").isRequired(false).writability(CREATE_ONLY))
+                .property(
+                    "name",
+                    object()
+                      .property("displayName", simple("cn").isRequired(true))
+                      .property("surname",     simple("sn").isRequired(true)))
+                .property(
+                    "_rev",
+                    simple("etag").isRequired(true).writability(READ_ONLY))
+                .property(
+                    "description",
+                    simple("description").isMultiValued(true))
+                .property(
+                    "singleNumber",
+                    simple("singleNumber").decoder(byteStringToInteger()))
+                .property(
+                    "multiNumber",
+                    simple("multiNumber").isMultiValued(true).decoder(byteStringToInteger()))
+        );
     }
 
     private void checkResourcesAreEqual(final ResourceResponse actual, final JsonValue expected) {
         final ResourceResponse expectedResource = asResource(expected);
+
         assertThat(actual.getId()).isEqualTo(expectedResource.getId());
         assertThat(actual.getRevision()).isEqualTo(expectedResource.getRevision());
-        assertThat(actual.getContent().getObject()).isEqualTo(expectedResource.getContent().getObject());
+
+        assertThat(actual.getContent().getObject())
+            .isEqualTo(expectedResource.getContent().getObject());
+    }
+
+    private void checkThatOrgUnitsExist(final List<ResourceResponse> resources,
+                                        final String... orgUnitIds) {
+        checkThatOrgUnitsExist(resources, 0, orgUnitIds);
+    }
+
+    private void checkThatOrgUnitsExist(final List<ResourceResponse> resources,
+                                        final int startingIndex,
+                                        final String... expectedOrgUnitIds) {
+        for (int orgUnitIndex = 0; orgUnitIndex < expectedOrgUnitIds.length; ++orgUnitIndex) {
+            final ResourceResponse resource = resources.get(startingIndex + orgUnitIndex);
+            final JsonValue orgUnitId = resource.getContent().get("_ou");
+
+            assertThat(orgUnitId).isNotNull();
+            assertThat(orgUnitId.asString()).isEqualTo(expectedOrgUnitIds[orgUnitIndex]);
+        }
+    }
+
+    private void checkThatUsersExist(final List<ResourceResponse> resources,
+                                     final String... expectedUserIds) {
+        checkThatUsersExist(resources, 0, expectedUserIds);
+    }
+
+    private void checkThatUsersExist(final List<ResourceResponse> resources,
+                                     final int startingIndex, final String... expectedUserIds) {
+        for (int userIndex = 0; userIndex < expectedUserIds.length; ++userIndex) {
+            final ResourceResponse resource = resources.get(startingIndex + userIndex);
+
+            assertThat(resource.getContent().get("_ou").isNull());
+            assertThat(resource.getId()).isEqualTo(expectedUserIds[userIndex]);
+        }
     }
 
     private AuthenticatedConnectionContext newAuthConnectionContext() throws IOException {
         return newAuthConnectionContext(new ArrayList<Request>());
     }
 
-    private AuthenticatedConnectionContext newAuthConnectionContext(List<Request> requests) throws IOException {
-        return new AuthenticatedConnectionContext(ctx(), getConnectionFactory(requests).getConnection());
+    private AuthenticatedConnectionContext newAuthConnectionContext(List<Request> requests)
+    throws IOException {
+        return new AuthenticatedConnectionContext(
+            ctx(),
+            getConnectionFactory(requests).getConnection());
     }
 
-    private ConnectionFactory getConnectionFactory(final List<Request> requests) throws IOException {
+    private ConnectionFactory getConnectionFactory(final List<Request> requests)
+    throws IOException {
         // @formatter:off
         final MemoryBackend backend =
                 new MemoryBackend(new LDIFEntryReader(
@@ -692,7 +1155,46 @@
                         "userpassword: password",
                         "cn: test user 5",
                         "sn: user 5",
-                        "etag: 55555"
+                        "etag: 55555",
+                        "",
+                        "dn: uid=test6,dc=test",
+                        "objectClass: top",
+                        "objectClass: person",
+                        "uid: test6",
+                        "userpassword: password",
+                        "cn: test user 6",
+                        "sn: user 6",
+                        "etag: 66666",
+                        "",
+                        "dn: ou=level1,dc=test",
+                        "objectClass: top",
+                        "objectClass: organizationalUnit",
+                        "ou: level1",
+                        "etag: 77777",
+                        "",
+                        "dn: uid=sub1,ou=level1,dc=test",
+                        "objectClass: top",
+                        "objectClass: person",
+                        "uid: sub1",
+                        "userpassword: password",
+                        "cn: test user level 1",
+                        "sn: user 7",
+                        "etag: 88888",
+                        "",
+                        "dn: ou=level2,ou=level1,dc=test",
+                        "objectClass: top",
+                        "objectClass: organizationalUnit",
+                        "ou: level2",
+                        "etag: 99999",
+                        "",
+                        "dn: uid=sub2,ou=level2,ou=level1,dc=test",
+                        "objectClass: top",
+                        "objectClass: person",
+                        "uid: sub2",
+                        "userpassword: password",
+                        "cn: test user level 2",
+                        "sn: user 8",
+                        "etag: 86753"
                 ));
         // @formatter:on
 
@@ -768,10 +1270,15 @@
 
             @Override
             public void handleSearch(RequestContext requestContext, SearchRequest request,
-                IntermediateResponseHandler intermediateResponseHandler, SearchResultHandler entryHandler,
+                IntermediateResponseHandler intermediateResponseHandler,
+                SearchResultHandler entryHandler,
                 LdapResultHandler<Result> resultHandler) {
                 requests.add(request);
-                handler.handleSearch(requestContext, request, intermediateResponseHandler, entryHandler,
+                handler.handleSearch(
+                    requestContext,
+                    request,
+                    intermediateResponseHandler,
+                    entryHandler,
                     resultHandler);
             }
 
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
index 98b08f0..0f0785a 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -190,7 +191,7 @@
     }
 
     @DataProvider
-    public Object[][] ingnoredCacheResolverConfigurations() {
+    public Object[][] ignoredCacheResolverConfigurations() {
         // @Checkstyle:off
         return new Object[][] {
                 {
@@ -205,7 +206,7 @@
         // @Checkstyle:on
     }
 
-    @Test(dataProvider = "ingnoredCacheResolverConfigurations")
+    @Test(dataProvider = "ignoredCacheResolverConfigurations")
     public void testNoCacheFallbackOnResolver(final String rawJson) throws Exception {
         assertThat(fakeApp.createCachedTokenResolverIfNeeded(parseJson(rawJson), resolver)).isEqualTo(resolver);
     }
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfiguratorTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfiguratorTest.java
index 2690bd4..f948f30 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfiguratorTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfiguratorTest.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2017 Rosie Applications, Inc.
  */
 package org.forgerock.opendj.rest2ldap;
 
@@ -23,22 +24,30 @@
 import static org.forgerock.util.Options.*;
 
 import java.io.File;
+import java.io.IOException;
 import java.io.StringReader;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Collections;
 
+import java.util.List;
+import java.util.Map;
 import org.forgerock.api.CrestApiProducer;
 import org.forgerock.api.models.ApiDescription;
+import org.forgerock.api.models.Items;
+import org.forgerock.api.models.Resource;
+import org.forgerock.api.models.Services;
 import org.forgerock.http.routing.UriRouterContext;
 import org.forgerock.http.util.Json;
 import org.forgerock.json.JsonValue;
 import org.forgerock.json.resource.Request;
 import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.services.context.Context;
 import org.forgerock.services.context.RootContext;
 import org.forgerock.testng.ForgeRockTestCase;
 import org.forgerock.util.Options;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -53,13 +62,19 @@
 public class Rest2LdapJsonConfiguratorTest extends ForgeRockTestCase {
     private static final String ID = "frapi:opendj:rest2ldap";
     private static final String VERSION = "4.0.0";
+
+    private static final Path SERVLET_MODULE_PATH =
+        getPathToMavenModule("opendj-rest2ldap-servlet");
+
     private static final Path CONFIG_DIR = Paths.get(
-        "../opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap");
+        SERVLET_MODULE_PATH.toString(), "src", "main", "webapp", "WEB-INF", "classes", "rest2ldap");
 
     @Test
     public void testConfigureEndpointsWithApiDescription() throws Exception {
-        final DescribableRequestHandler handler = configureEndpoints(CONFIG_DIR.resolve("endpoints").toFile());
+        final File endpointsDir = CONFIG_DIR.resolve("endpoints").toFile();
+        final DescribableRequestHandler handler = createDescribableHandler(endpointsDir);
         final ApiDescription api = requestApi(handler, "api/users/bjensen");
+
         assertThat(api).isNotNull();
 
         // Ensure we can can pretty print and parse back the generated api description
@@ -67,44 +82,401 @@
 
         assertThat(api.getId()).isEqualTo(ID);
         assertThat(api.getVersion()).isEqualTo(VERSION);
-        assertThat(api.getPaths().getNames()).containsOnly("/api/users", "/api/groups");
+
+        assertThat(api.getPaths().getNames()).containsOnly(
+            "/api/users",
+            "/api/read-only-users",
+            "/api/all-users",
+            "/api/groups");
+
         assertThat(api.getDefinitions().getNames()).containsOnly(
             "frapi:opendj:rest2ldap:object:1.0",
             "frapi:opendj:rest2ldap:group:1.0",
             "frapi:opendj:rest2ldap:user:1.0",
             "frapi:opendj:rest2ldap:posixUser:1.0");
+
+        final Services services = api.getServices();
+
+        assertThat(services.getNames()).containsOnly(
+            "frapi:opendj:rest2ldap:user:1.0:read-write",
+            "frapi:opendj:rest2ldap:user:1.0:read-only",
+            "frapi:opendj:rest2ldap:group:1.0:read-write");
+
+        final String[] readOnlyServices = new String[] {
+            "frapi:opendj:rest2ldap:user:1.0:read-only"
+        };
+
+        for (String serviceName : readOnlyServices) {
+            final Resource service = services.get(serviceName);
+            final Items items = service.getItems();
+
+            assertThat(service.getCreate()).isNull();
+            assertThat(items.getCreate()).isNull();
+            assertThat(items.getUpdate()).isNull();
+            assertThat(items.getDelete()).isNull();
+            assertThat(items.getPatch()).isNull();
+
+            assertThat(items.getRead()).isNotNull();
+        }
+
+        final String[] writableServices = new String[] {
+            "frapi:opendj:rest2ldap:user:1.0:read-write",
+            "frapi:opendj:rest2ldap:group:1.0:read-write"
+        };
+
+        for (String serviceName : writableServices) {
+            final Resource service = services.get(serviceName);
+            final Items items = service.getItems();
+
+            assertThat(service.getCreate()).isNotNull();
+            assertThat(items.getCreate()).isNotNull();
+            assertThat(items.getUpdate()).isNotNull();
+            assertThat(items.getDelete()).isNotNull();
+            assertThat(items.getPatch()).isNotNull();
+            assertThat(items.getRead()).isNotNull();
+        }
     }
 
-    private DescribableRequestHandler configureEndpoints(final File endpointsDir) throws Exception {
-        final RequestHandler rh = Rest2LdapJsonConfigurator.configureEndpoints(endpointsDir, Options.defaultOptions());
-        DescribableRequestHandler handler = new DescribableRequestHandler(rh);
+    @DataProvider
+    public Object[][] invalidSubResourceSubtreeFlatteningConfigurations() {
+        // @Checkstyle:off
+        return new Object[][] {
+            {
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'writeable-collection': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'flattenSubtree': true"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'writeable-collection': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'isReadOnly': false,"
+                                + "'flattenSubtree': true"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            }
+        };
+        // @Checkstyle:on
+    }
+
+    @DataProvider
+    public Object[][] validSubResourceConfigurations() {
+        // @Checkstyle:off
+        return new Object[][] {
+            {
+                false,
+                false,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "}"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                false,
+                false,
+                "(objectClass=person)",
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'baseSearchFilter': '(objectClass=person)'"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                false,
+                false,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'flattenSubtree': false"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                true,
+                false,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'isReadOnly': true"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                true,
+                false,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'isReadOnly': true,"
+                                + "'flattenSubtree': false"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                false,
+                false,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'isReadOnly': false,"
+                                + "'flattenSubtree': false"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            },
+            {
+                true,
+                true,
+                null,
+                "{"
+                    + "'example-v1': {"
+                        + "'subResources': {"
+                            + "'all-users': {"
+                                + "'type': 'collection',"
+                                + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                                + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                                + "'namingStrategy': {"
+                                    + "'type': 'clientDnNaming',"
+                                    + "'dnAttribute': 'uid'"
+                                + "},"
+                                + "'isReadOnly': true,"
+                                + "'flattenSubtree': true"
+                            + "}"
+                        + "}"
+                    + "}"
+                + "}"
+            }
+        };
+        // @Checkstyle:on
+    }
+
+    @Test(dataProvider = "invalidSubResourceSubtreeFlatteningConfigurations")
+    public void testInvalidSubResourceSubtreeFlatteningConfigurations(final String rawJson)
+    throws Exception {
+        try {
+            Rest2LdapJsonConfigurator.configureResources(parseJson(rawJson));
+
+            fail("Expected an IllegalArgumentException");
+        }
+        catch (IllegalArgumentException ex) {
+            assertThat(ex.getMessage())
+                .isEqualTo("Sub-resources must be read-only to support sub-tree flattening.");
+        }
+    }
+
+    @Test
+    public void testInvalidSubResourceSearchFilterConfiguration()
+    throws Exception {
+        final String rawJson =
+            "{"
+                + "'example-v1': {"
+                    + "'subResources': {"
+                        + "'all-users': {"
+                            + "'type': 'collection',"
+                            + "'dnTemplate': 'ou=people,dc=example,dc=com',"
+                            + "'resource': 'frapi:opendj:rest2ldap:user:1.0',"
+                            + "'namingStrategy': {"
+                                + "'type': 'clientDnNaming',"
+                                + "'dnAttribute': 'uid'"
+                            + "},"
+                            + "'baseSearchFilter': 'badFilter'"
+                        + "}"
+                    + "}"
+                + "}"
+            + "}";
+
+        try {
+            Rest2LdapJsonConfigurator.configureResources(parseJson(rawJson));
+
+            fail("Expected an IllegalArgumentException");
+        }
+        catch (IllegalArgumentException ex) {
+            assertThat(ex.getMessage())
+                .isEqualTo(
+                    "The provided search filter \"badFilter\" was missing an equal sign in the " +
+                    "suspected simple filter component between positions 0 and 9");
+        }
+    }
+
+    @Test(dataProvider = "validSubResourceConfigurations")
+    public void testValidSubResourceConfigurations(final boolean expectedReadOnly,
+                                                   final boolean expectedSubtreeFlattened,
+                                                   final String expectedSearchFilter,
+                                                   final String rawJson) throws Exception {
+        final List<org.forgerock.opendj.rest2ldap.Resource> resources =
+            Rest2LdapJsonConfigurator.configureResources(parseJson(rawJson));
+        final org.forgerock.opendj.rest2ldap.Resource firstResource;
+        final Map<String, SubResource> subResources;
+        final SubResourceCollection allUsersSubResource;
+
+        assertThat(resources.size()).isEqualTo(1);
+
+        firstResource = resources.get(0);
+
+        assertThat(firstResource.getResourceId()).isEqualTo("example-v1");
+
+        subResources = firstResource.getSubResourceMap();
+
+        assertThat(subResources.size()).isEqualTo(1);
+
+        allUsersSubResource = (SubResourceCollection)subResources.get("all-users");
+
+        assertThat(allUsersSubResource.isReadOnly()).isEqualTo(expectedReadOnly);
+        assertThat(allUsersSubResource.shouldFlattenSubtree()).isEqualTo(expectedSubtreeFlattened);
+
+        if (expectedSearchFilter == null) {
+            assertThat(allUsersSubResource.getBaseSearchFilter()).isNull();
+        }
+        else {
+            assertThat(allUsersSubResource.getBaseSearchFilter().toString())
+                .isEqualTo(expectedSearchFilter);
+        }
+    }
+
+    private RequestHandler createRequestHandler(final File endpointsDir) throws IOException {
+        return Rest2LdapJsonConfigurator.configureEndpoints(endpointsDir, Options.defaultOptions());
+    }
+
+    private DescribableRequestHandler createDescribableHandler(final File endpointsDir)
+    throws Exception {
+        final RequestHandler rh = createRequestHandler(endpointsDir);
+        final DescribableRequestHandler handler = new DescribableRequestHandler(rh);
+
         handler.api(new CrestApiProducer(ID, VERSION));
+
         return handler;
     }
 
-    private ApiDescription requestApi(final DescribableRequestHandler handler, String uriPath) {
-        Context context = newRouterContext(uriPath);
-        Request request = newApiRequest(resourcePath(uriPath));
+    private ApiDescription requestApi(final DescribableRequestHandler handler,
+                                      final String uriPath) {
+        final Context context = newRouterContext(uriPath);
+        final Request request = newApiRequest(resourcePath(uriPath));
+
         return handler.handleApiRequest(context, request);
     }
 
     private Context newRouterContext(final String uriPath) {
         Context ctx = new RootContext();
+
         ctx = new Rest2LdapContext(ctx, rest2Ldap(defaultOptions()));
         ctx = new UriRouterContext(ctx, null, uriPath, Collections.<String, String> emptyMap());
+
         return ctx;
     }
 
     private String prettyPrint(Object o) throws Exception {
         final ObjectMapper objectMapper =
-            new ObjectMapper().registerModules(new Json.LocalizableStringModule(), new Json.JsonValueModule());
+            new ObjectMapper().registerModules(
+                new Json.LocalizableStringModule(),
+                new Json.JsonValueModule());
+
         final ObjectWriter writer = objectMapper.writer().withDefaultPrettyPrinter();
+
         return writer.writeValueAsString(o);
     }
 
-    static JsonValue parseJson(final String json) throws Exception {
+    private static JsonValue parseJson(final String json) throws Exception {
         try (StringReader r = new StringReader(json)) {
             return new JsonValue(readJsonLenient(r));
         }
     }
+
+    private static Path getPathToClass(Class<?> clazz) {
+        return Paths.get(clazz.getProtectionDomain().getCodeSource().getLocation().getPath());
+    }
+
+    private static Path getPathToMavenModule(String moduleName) {
+        final Path testClassPath = getPathToClass(Rest2LdapJsonConfiguratorTest.class);
+
+        return Paths.get(testClassPath.toString(), "..", "..", "..", moduleName);
+    }
 }
diff --git a/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/example-v1.json b/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/example-v1.json
index 096de36..0bb2c83 100644
--- a/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/example-v1.json
+++ b/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/example-v1.json
@@ -21,6 +21,34 @@
                         "dnAttribute": "uid"
                     }
                 },
+                // This resource is the same as "users", but read-only.
+                // Users cannot be created, modified, or deleted through this sub-resource.
+                "read-only-users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc=example,dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    },
+                    "isReadOnly": true
+                },
+                // This resource provides a read-only view of all users in the system, including
+                // users nested underneath entries like org units, organizations, etc., starting
+                // from "ou=people,dc=example,dc=com" and working down. It filters out any other
+                // structural elements, including organizations, org units, etc.
+                "all-users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc=example,dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    },
+                    "isReadOnly": true,
+                    "flattenSubtree": true,
+                    "baseSearchFilter": "(objectClass=person)"
+                },
                 "groups": {
                     "type": "collection",
                     "dnTemplate": "ou=groups,dc=example,dc=com",

--
Gitblit v1.10.0