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 b5a0b02bc3..0bb2c83d1b 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 @@ -33,6 +33,22 @@ }, "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 71435591cf..06fe696989 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,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; @@ -61,6 +61,7 @@ protected Promise handleRequest(final Context context, } @Override + @SuppressWarnings("unchecked") public ApiDescription api(ApiProducer producer) { if (delegate instanceof Describable) { return ((Describable)delegate).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 98796812e5..5d9712f377 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 @@ public final class ReferencePropertyMapper extends AbstractLdapPropertyMapper getLdapFilter(final Context context, final Re return mapper.getLdapFilter(context, resource, path, subPath, type, operator, valueAssertion) .thenAsync(new AsyncFunction() { @Override - public Promise apply(final Filter result) { + public Promise 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 subFilters = new LinkedList<>(); return connectionFrom(context).searchAsync(request, new SearchResultHandler() { @@ -325,8 +324,9 @@ public JsonValue apply(final List value) { } } - 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 35825f6c6c..45676478cf 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; @@ -484,7 +487,7 @@ String getResourceId() { * @return The unique service ID for this resource, given the specified writability. */ String getServiceId(boolean isReadOnly) { - StringBuilder serviceId = new StringBuilder(this.getResourceId()); + final StringBuilder serviceId = new StringBuilder(this.getResourceId()); if (isReadOnly) { serviceId.append(":read-only"); @@ -495,6 +498,21 @@ String getServiceId(boolean isReadOnly) { 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 getSubResourceMap() { + final Map 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) { 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 66e0a5c688..5efd9569df 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 @@ private AccessTokenResolver parseRfc7662Resolver(final JsonValue configuration) 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 @@ private Duration parseCacheExpiration(final JsonValue expirationJson) { 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 65572d58ab..1a794c7ad1 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 static Resource configureResource(final String resourceId, final JsonVal 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 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; - } + final SubResourceType subResourceType = + config.get("type").required().as(enumConstant(SubResourceType.class)); - 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 0e61043650..4c964703e9 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 @@ public abstract class SubResource { 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 @@ public final int hashCode() { @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 7713ced9e7..0e27febd1d 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,12 +75,38 @@ public final class SubResourceCollection extends SubResource { 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 @@ -213,12 +240,72 @@ public SubResourceCollection glueObjectClasses(final String... objectClasses) { /** * 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 @@ public Promise apply(LdapException e) } 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 7cec13fe39..f731a1b732 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 @@ final class SubResourceImpl { 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 @@ final class SubResourceImpl { this.glueObjectClasses = glueObjectClasses; this.namingStrategy = namingStrategy; this.resource = resource; + this.flattenSubtree = flattenSubtree; + this.baseSearchFilter = baseSearchFilter; } Promise action( @@ -504,9 +514,34 @@ public Promise apply(Entry entry) throws Re Promise 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 applyBaseSearchFilter() { + return new Function() { + @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 getLdapFilter( final Context context, final QueryFilter queryFilter) { @@ -523,6 +558,7 @@ private Promise getLdapFilter( public Promise visitAndFilter( final Void unused, final List> subFilters) { final List> promises = new ArrayList<>(subFilters.size()); + for (final QueryFilter subFilter : subFilters) { promises.add(subFilter.accept(this, unused)); } @@ -532,14 +568,17 @@ public Promise visitAndFilter( public Filter apply(final List value) { // Check for unmapped filter components and optimize. final Iterator 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 @@ public Promise visitStartsWithFilter( 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 @@ public Promise apply(final Filter ldapFilter) 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 @@ private SearchRequest searchRequestForUnknownType(final String resourceId, final 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 AsyncFunction adaptLdapException(final Class clazz) { return new AsyncFunction() { 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 77976f242b..6182a244ad 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 @@ Promise route(final Context context) { } 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 3c1e587208..40631a12e9 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_JSON_QUERY_PARSE_ERROR_89=The value '%s' could not be parsed as a valid JSON 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 343ed60138..cb1bab0c8d 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 @@ public final class BasicRequestsTest extends ForgeRockTestCase { private static final QueryFilter NO_FILTER = QueryFilter.alwaysTrue(); @Test - public void testQueryAll() throws Exception { + public void testQueryAllWithNoSubtreeFlatteningAndNoSearchFilter() throws Exception { final Connection connection = newConnection(); final List 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 testQueryAllWithSearchFilterAndNoSubtreeFlattening() throws Exception { + final Connection connection = newConnection(); + final List resources = new LinkedList<>(); + 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 testQueryNone() throws Exception { + public void testQueryAllWithSubtreeFlatteningAndNoSearchFilter() throws Exception { + final Connection connection = newConnection(); + final List 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 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 resources = new LinkedList<>(); + final QueryResponse result = + connection.query( + newAuthConnectionContext(), + newQueryRequest("").setQueryFilter(QueryFilter. alwaysFalse()), + resources); + + assertThat(resources).hasSize(0); + assertThat(result.getPagedResultsCookie()).isNull(); + assertThat(result.getTotalPagedResults()).isEqualTo(-1); + } + + @Test + public void testQueryNoneWithSearchFilterAndNoSubtreeFlattening() throws Exception { + final Connection connection = newConnection(); + final List resources = new LinkedList<>(); + final QueryResponse result = + connection.query( + newAuthConnectionContext(), + newQueryRequest("top-level-users") + .setQueryFilter(QueryFilter. 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 resources = new LinkedList<>(); + final QueryResponse result = + connection.query( + newAuthConnectionContext(), + newQueryRequest("all-entries") + .setQueryFilter(QueryFilter. 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 resources = new LinkedList<>(); final QueryResponse result = connection.query(newAuthConnectionContext(), - newQueryRequest("").setQueryFilter(QueryFilter. alwaysFalse()), resources); + newQueryRequest("all-users").setQueryFilter(QueryFilter. alwaysFalse()), resources); + assertThat(resources).hasSize(0); assertThat(result.getPagedResultsCookie()).isNull(); assertThat(result.getTotalPagedResults()).isEqualTo(-1); } @Test - public void testQueryPageResultsCookie() throws Exception { + public void testQueryPageResultsCookieWithNoSubtreeFlatteningAndNoSearchFilter() + throws Exception { final Connection connection = newConnection(); final List 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 testQueryPageResultsCookieWithSubtreeFlatteningAndSearchFilter() throws Exception { + final Connection connection = newConnection(); + final List resources = new ArrayList<>(); + + // 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 testQueryPageResultsIndexed() throws Exception { + public void testQueryPageResultsIndexedWithNoSubtreeFlatteningAndNoSearchFilter() + throws Exception { final Connection connection = newConnection(); final List resources = new ArrayList<>(); - QueryResponse result = connection.query(newAuthConnectionContext(), - newQueryRequest("").setQueryFilter(NO_FILTER).setPageSize(2).setPagedResultsOffset(1), resources); + + 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 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 @@ public void testDelete() throws Exception { 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 testDelete() throws Exception { 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 testDeleteMVCCMatch() throws Exception { 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 testDeleteMVCCNoMatch() throws Exception { public void testDeleteNotFound() throws Exception { final Context context = newAuthConnectionContext(); final Connection connection = newConnection(); + connection.delete(context, newDeleteRequest("/missing")); } @@ -199,6 +464,7 @@ public void testPatch() throws Exception { 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 @@ public void testPatchEmpty() throws Exception { 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 @@ public void testPatchAddOptionalAttribute() throws Exception { 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 @@ public void testPatchAddOptionalAttributeIndexAppend() throws Exception { 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 @@ public void testPatchIncrement() throws Exception { 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 @@ public void testPatchModifyOptionalAttribute() throws Exception { @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 @@ public void testPatchMVCCMatch() throws Exception { @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 @@ public void testPatchReplacePartialObject() throws Exception { 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 testPatchReplacePartialObject() throws Exception { 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 @@ public void testPatchReplaceWholeObject() throws Exception { @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 @@ public void testPatchUnknownSubSubAttribute() throws Exception { @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 @@ public void testReadNotFound() throws Exception { @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 @@ public void testReadSelectPartial() throws Exception { /** 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 @@ public void testUpdate() throws Exception { 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 @@ public void testUpdateNoChange() throws Exception { final List 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 @@ public void testUpdateAddOptionalAttribute() throws Exception { 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 testUpdateAddOptionalAttribute() throws Exception { 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 @@ public void testUpdateDeleteOptionalAttribute() throws Exception { 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 testUpdateDeleteOptionalAttribute() throws Exception { 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 @@ public void testUpdateModifyOptionalAttribute() throws Exception { 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 @@ public void testUpdateMVCCMatch() throws Exception { 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 @@ public void testUpdateMVCCMatch() throws Exception { @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 testUpdateSingleValuedAttributeWithMultipleValues() throws Exception 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 Connection newConnection() throws IOException { } 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 resources, + final String... orgUnitIds) { + checkThatOrgUnitsExist(resources, 0, orgUnitIds); + } + + private void checkThatOrgUnitsExist(final List 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 resources, + final String... expectedUserIds) { + checkThatUsersExist(resources, 0, expectedUserIds); + } + + private void checkThatUsersExist(final List 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()); } - private AuthenticatedConnectionContext newAuthConnectionContext(List requests) throws IOException { - return new AuthenticatedConnectionContext(ctx(), getConnectionFactory(requests).getConnection()); + private AuthenticatedConnectionContext newAuthConnectionContext(List requests) + throws IOException { + return new AuthenticatedConnectionContext( + ctx(), + getConnectionFactory(requests).getConnection()); } - private ConnectionFactory getConnectionFactory(final List requests) throws IOException { + private ConnectionFactory getConnectionFactory(final List requests) + throws IOException { // @formatter:off final MemoryBackend backend = new MemoryBackend(new LDIFEntryReader( @@ -692,7 +1155,46 @@ private ConnectionFactory getConnectionFactory(final List requests) thr "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 @@ public void handleModifyDN(RequestContext requestContext, ModifyDNRequest reques @Override public void handleSearch(RequestContext requestContext, SearchRequest request, - IntermediateResponseHandler intermediateResponseHandler, SearchResultHandler entryHandler, + IntermediateResponseHandler intermediateResponseHandler, + SearchResultHandler entryHandler, LdapResultHandler 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 98b08f0b24..0f0785a3ca 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 @@ public void testInvalidCacheResolverConfigurations(final String rawJson) throws } @DataProvider - public Object[][] ingnoredCacheResolverConfigurations() { + public Object[][] ignoredCacheResolverConfigurations() { // @Checkstyle:off return new Object[][] { { @@ -205,7 +206,7 @@ public Object[][] ingnoredCacheResolverConfigurations() { // @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 7c2e39934b..f948f306df 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; @@ -29,6 +30,8 @@ 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; @@ -39,10 +42,12 @@ 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; @@ -66,9 +71,8 @@ public class Rest2LdapJsonConfiguratorTest extends ForgeRockTestCase { @Test public void testConfigureEndpointsWithApiDescription() throws Exception { - final DescribableRequestHandler handler = - createDescribableHandler(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(); @@ -82,6 +86,7 @@ public void testConfigureEndpointsWithApiDescription() throws Exception { assertThat(api.getPaths().getNames()).containsOnly( "/api/users", "/api/read-only-users", + "/api/all-users", "/api/groups"); assertThat(api.getDefinitions().getNames()).containsOnly( @@ -132,6 +137,291 @@ public void testConfigureEndpointsWithApiDescription() throws Exception { } } + @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 resources = + Rest2LdapJsonConfigurator.configureResources(parseJson(rawJson)); + final org.forgerock.opendj.rest2ldap.Resource firstResource; + final Map 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()); } 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 b5a0b02bc3..0bb2c83d1b 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 @@ -33,6 +33,22 @@ }, "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",