/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. * Portions Copyright 2017 Rosie Applications, Inc. */ package org.forgerock.opendj.rest2ldap; import static org.forgerock.http.routing.RoutingMode.EQUALS; import static org.forgerock.http.routing.RoutingMode.STARTS_WITH; import static org.forgerock.json.resource.RouteMatchers.requestUriMatcher; import static org.forgerock.opendj.ldap.Filter.objectClassPresent; import static org.forgerock.opendj.ldap.SearchScope.BASE_OBJECT; import static org.forgerock.opendj.ldap.SearchScope.SINGLE_LEVEL; import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest; import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException; import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*; import static org.forgerock.opendj.rest2ldap.RoutingContext.newRoutingContext; import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException; import static org.forgerock.util.promise.Promises.newResultPromise; import org.forgerock.api.models.ApiDescription; import org.forgerock.http.ApiProducer; import org.forgerock.http.routing.UriRouterContext; import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.json.resource.ActionRequest; import org.forgerock.json.resource.ActionResponse; import org.forgerock.json.resource.BadRequestException; import org.forgerock.json.resource.CreateRequest; import org.forgerock.json.resource.DeleteRequest; import org.forgerock.json.resource.NotSupportedException; import org.forgerock.json.resource.PatchRequest; import org.forgerock.json.resource.QueryRequest; import org.forgerock.json.resource.QueryResourceHandler; import org.forgerock.json.resource.QueryResponse; import org.forgerock.json.resource.ReadRequest; import org.forgerock.json.resource.Request; import org.forgerock.json.resource.ResourceException; import org.forgerock.json.resource.ResourceResponse; import org.forgerock.json.resource.Router; import org.forgerock.json.resource.UpdateRequest; import org.forgerock.opendj.ldap.Attribute; import org.forgerock.opendj.ldap.AttributeDescription; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.Connection; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.Entry; import org.forgerock.opendj.ldap.Filter; import org.forgerock.opendj.ldap.LdapException; import org.forgerock.opendj.ldap.LinkedAttribute; import org.forgerock.opendj.ldap.RDN; import org.forgerock.opendj.ldap.requests.SearchRequest; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.forgerock.services.context.Context; import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; import org.forgerock.util.promise.Promise; /** * Defines a one-to-many relationship between a parent resource and its children. Removal of the parent resource * implies that the children (the sub-resources) are also removed. Collections support all request types. */ public final class SubResourceCollection extends SubResource { /** The LDAP object classes associated with the glue entries forming the DN template. */ 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 * performed. *

* Note that this naming policy requires that the user provides the resource name when creating new resources, which * means it must be included in the resource content when not specified explicitly in the create request. * * @param dnAttribute * The LDAP attribute which will be used for naming. * @return A reference to this object. */ public SubResourceCollection useClientDnNaming(final String dnAttribute) { this.namingStrategy = new DnNamingStrategy(dnAttribute); return this; } /** * Indicates that the JSON resource ID must be provided by the user, but will not be used for naming the * associated LDAP entry. Instead the JSON resource ID will be taken from the {@code idAttribute} in the LDAP * entry, and the LDAP entry name will be derived by appending a single RDN to the collection's base DN composed * of the {@code dnAttribute} taken from the LDAP entry once attribute mapping has been performed. *

* Note that this naming policy requires that the user provides the resource name when creating new resources, which * means it must be included in the resource content when not specified explicitly in the create request. * * @param dnAttribute * The attribute which will be used for naming LDAP entries. * @param idAttribute * The attribute which will be used for JSON resource IDs. * @return A reference to this object. */ public SubResourceCollection useClientNaming(final String dnAttribute, final String idAttribute) { this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, false); return this; } /** * Indicates that the JSON resource ID will be derived from the server provided "entryUUID" LDAP attribute. The * LDAP entry name will be derived by appending a single RDN to the collection's base DN composed of the {@code * dnAttribute} taken from the LDAP entry once attribute mapping has been performed. *

* Note that this naming policy requires that the server provides the resource name when creating new resources, * which means it must not be specified in the create request, nor included in the resource content. * * @param dnAttribute * The attribute which will be used for naming LDAP entries. * @return A reference to this object. */ public SubResourceCollection useServerEntryUuidNaming(final String dnAttribute) { return useServerNaming(dnAttribute, "entryUUID"); } /** * Indicates that the JSON resource ID must not be provided by the user, and will not be used for naming the * associated LDAP entry. Instead the JSON resource ID will be taken from the {@code idAttribute} in the LDAP * entry, and the LDAP entry name will be derived by appending a single RDN to the collection's base DN composed * of the {@code dnAttribute} taken from the LDAP entry once attribute mapping has been performed. *

* Note that this naming policy requires that the server provides the resource name when creating new resources, * which means it must not be specified in the create request, nor included in the resource content. * * @param dnAttribute * The attribute which will be used for naming LDAP entries. * @param idAttribute * The attribute which will be used for JSON resource IDs. * @return A reference to this object. */ public SubResourceCollection useServerNaming(final String dnAttribute, final String idAttribute) { this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, true); return this; } /** * Sets the relative URL template beneath which the sub-resources will be located. The template may be empty * indicating that the sub-resources will be located directly beneath the parent resource. Any URL template * variables will be substituted into the {@link #dnTemplate(String) DN template}. * * @param urlTemplate * The relative URL template. * @return A reference to this object. */ public SubResourceCollection urlTemplate(final String urlTemplate) { this.urlTemplate = urlTemplate; return this; } /** * Sets the relative DN template beneath which the sub-resource LDAP entries will be located. The template may be * empty indicating that the LDAP entries will be located directly beneath the parent LDAP entry. Any DN template * variables will be substituted using values extracted from the {@link #urlTemplate(String) URL template}. * * @param dnTemplate * The relative DN template. * @return A reference to this object. */ public SubResourceCollection dnTemplate(final String dnTemplate) { this.dnTemplateString = dnTemplate; return this; } /** * Specifies an LDAP object class which is to be associated with any intermediate "glue" entries forming the DN * template. Multiple object classes may be specified. * * @param objectClass * An LDAP object class which is to be associated with any intermediate "glue" entries forming the DN * template. * @return A reference to this object. */ public SubResourceCollection glueObjectClass(final String objectClass) { this.glueObjectClasses.add(objectClass); return this; } /** * Specifies one or more LDAP object classes which is to be associated with any intermediate "glue" entries * forming the DN template. Multiple object classes may be specified. * * @param objectClasses * The LDAP object classes which is to be associated with any intermediate "glue" entries forming the DN * template. * @return A reference to this object. */ public SubResourceCollection glueObjectClasses(final String... objectClasses) { this.glueObjectClasses.add((Object[]) objectClasses); return this; } /** * Indicates whether this sub-resource collection only supports read and query operations. * * @param isReadOnly * {@code true} if this sub-resource collection is read-only. * @return A reference to this object. */ 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; } @Override Router addRoutes(final Router router) { router.addRoute(requestUriMatcher(EQUALS, urlTemplate), readOnly(new CollectionHandler())); router.addRoute(requestUriMatcher(EQUALS, urlTemplate + "/{id}"), readOnly(new InstanceHandler())); router.addRoute(requestUriMatcher(STARTS_WITH, urlTemplate + "/{id}"), readOnly(new SubResourceHandler())); return router; } Promise route(final Context context) { final Connection conn = context.asContext(AuthenticatedConnectionContext.class).getConnection(); final SearchRequest searchRequest = namingStrategy.createSearchRequest(dnFrom(context), idFrom(context)); if (searchRequest.getScope().equals(BASE_OBJECT) && !resource.hasSubTypesWithSubResources()) { // There's no point in doing a search because we already know the DN and sub-resources. return newResultPromise(newRoutingContext(context, searchRequest.getName(), resource)); } searchRequest.addAttribute("objectClass"); return conn.searchSingleEntryAsync(searchRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply(SearchResultEntry entry) throws ResourceException { final Resource subType = resource.resolveSubTypeFromObjectClasses(entry); return newResultPromise(newRoutingContext(context, entry.getName(), subType)); } }, new AsyncFunction() { @Override public Promise apply(LdapException e) throws ResourceException { return asResourceException(e).asPromise(); } }); } private SubResourceImpl collection(final Context context) { return new SubResourceImpl( rest2Ldap, dnFrom(context), dnTemplateString.isEmpty() ? null : glueObjectClasses, namingStrategy, resource, flattenSubtree, baseSearchFilter); } private String idFrom(final Context context) { return context.asContext(UriRouterContext.class).getUriTemplateVariables().get("id"); } private static final class AttributeNamingStrategy implements NamingStrategy { private final AttributeDescription dnAttribute; private final AttributeDescription idAttribute; private final boolean isServerProvided; private AttributeNamingStrategy(final String dnAttribute, final String idAttribute, final boolean isServerProvided) { this.dnAttribute = AttributeDescription.valueOf(dnAttribute); this.idAttribute = AttributeDescription.valueOf(idAttribute); if (this.dnAttribute.equals(this.idAttribute)) { throw new LocalizedIllegalArgumentException(ERR_CONFIG_NAMING_STRATEGY_DN_AND_ID_NOT_DIFFERENT.get()); } this.isServerProvided = isServerProvided; } @Override public SearchRequest createSearchRequest(final DN baseDn, final String resourceId) { return newSearchRequest(baseDn, SINGLE_LEVEL, Filter.equality(idAttribute.toString(), resourceId)); } @Override public String getResourceIdLdapAttribute() { return idAttribute.toString(); } @Override public String decodeResourceId(final Entry entry) { return entry.parseAttribute(idAttribute).asString(); } @Override public void encodeResourceId(final DN baseDn, final String resourceId, final Entry entry) throws ResourceException { if (isServerProvided) { if (resourceId != null) { throw newBadRequestException(ERR_SERVER_PROVIDED_RESOURCE_ID_UNEXPECTED.get()); } } else { entry.addAttribute(new LinkedAttribute(idAttribute, ByteString.valueOfUtf8(resourceId))); } final String rdnValue = entry.parseAttribute(dnAttribute).asString(); final RDN rdn = new RDN(dnAttribute.getAttributeType(), rdnValue); entry.setName(baseDn.child(rdn)); } } private static final class DnNamingStrategy implements NamingStrategy { private final AttributeDescription attribute; private DnNamingStrategy(final String attribute) { this.attribute = AttributeDescription.valueOf(attribute); } @Override public SearchRequest createSearchRequest(final DN baseDn, final String resourceId) { return newSearchRequest(baseDn.child(rdn(resourceId)), BASE_OBJECT, objectClassPresent()); } @Override public String getResourceIdLdapAttribute() { return attribute.toString(); } @Override public String decodeResourceId(final Entry entry) { return entry.parseAttribute(attribute).asString(); } @Override public void encodeResourceId(final DN baseDn, final String resourceId, final Entry entry) throws ResourceException { if (resourceId != null) { entry.setName(baseDn.child(rdn(resourceId))); entry.addAttribute(new LinkedAttribute(attribute, ByteString.valueOfUtf8(resourceId))); } else if (entry.getAttribute(attribute) != null) { entry.setName(baseDn.child(rdn(entry.parseAttribute(attribute).asString()))); } else { throw newBadRequestException(ERR_CLIENT_PROVIDED_RESOURCE_ID_MISSING.get()); } } private RDN rdn(final String resourceId) { return new RDN(attribute.getAttributeType(), resourceId); } } /** * Responsible for routing collection requests (CQ) to this collection. More specifically, given the * URL template /collection/{id} then this handler processes requests against /collection. */ private final class CollectionHandler extends AbstractRequestHandler { @Override public Promise handleAction(final Context context, final ActionRequest request) { return new NotSupportedException(ERR_COLLECTION_ACTIONS_NOT_SUPPORTED.get().toString()).asPromise(); } @Override public Promise handleCreate(final Context context, final CreateRequest request) { return collection(context).create(context, request); } @Override public Promise handleQuery(final Context context, final QueryRequest request, final QueryResourceHandler handler) { return collection(context).query(context, request, handler); } @Override protected Promise handleRequest(final Context context, final Request request) { return new BadRequestException(ERR_UNSUPPORTED_REQUEST_AGAINST_COLLECTION.get().toString()).asPromise(); } @Override public ApiDescription api(ApiProducer producer) { return resource.collectionApi(isReadOnly); } } /** * Responsible for processing instance requests (RUDPA) against this collection and collection requests (CQ) to * any collections sharing the same base URL as an instance within this collection. More specifically, given the * URL template /collection/{parent}/{child} then this handler processes requests against {parent} since it is * both an instance within /collection and also a collection of {child}. */ private final class InstanceHandler extends AbstractRequestHandler { @Override public Promise handleAction(final Context context, final ActionRequest request) { return collection(context).action(context, idFrom(context), request); } @Override public Promise handleCreate(final Context context, final CreateRequest request) { return route(context) .thenAsync(new AsyncFunction() { @Override public Promise apply(final RoutingContext context) { return subResourceRouterFrom(context).handleCreate(context, request); } }).thenCatch(this.convert404To400()); } @Override public Promise handleDelete(final Context context, final DeleteRequest request) { return collection(context).delete(context, idFrom(context), request); } @Override public Promise handlePatch(final Context context, final PatchRequest request) { return collection(context).patch(context, idFrom(context), request); } @Override public Promise handleQuery(final Context context, final QueryRequest request, final QueryResourceHandler handler) { return route(context) .thenAsync(new AsyncFunction() { @Override public Promise apply(final RoutingContext context) { return subResourceRouterFrom(context).handleQuery(context, request, handler); } }).thenCatch(this.convert404To400()); } @Override public Promise handleRead(final Context context, final ReadRequest request) { return collection(context).read(context, idFrom(context), request); } @Override public Promise handleUpdate(final Context context, final UpdateRequest request) { return collection(context).update(context, idFrom(context), request); } private Function convert404To400() { return SubResource.convert404To400(ERR_UNSUPPORTED_REQUEST_AGAINST_INSTANCE.get()); } /** * Returns {@code null} because the corresponding {@link ApiDescription} * is returned by the {@link CollectionHandler#api(ApiProducer)} method. *

* This avoids problems when trying to {@link ApiProducer#merge(java.util.List) merge} * {@link ApiDescription}s with the same path. */ @Override public ApiDescription api(ApiProducer producer) { return null; } } }