/* * 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 2012-2016 ForgeRock AS. */ package org.forgerock.opendj.rest2ldap; import static org.forgerock.i18n.LocalizableMessage.raw; import static org.forgerock.opendj.ldap.ResultCode.Enum.NOT_ALLOWED_ON_NONLEAF; import static org.forgerock.opendj.ldap.SearchScope.BASE_OBJECT; import static org.forgerock.opendj.ldap.responses.Responses.newResult; import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise; import static org.forgerock.opendj.rest2ldap.FilterType.*; import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*; import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*; import static org.forgerock.json.resource.ResourceException.FORBIDDEN; import static org.forgerock.json.resource.ResourceException.newResourceException; import static org.forgerock.json.resource.Responses.newActionResponse; import static org.forgerock.json.resource.Responses.newQueryResponse; import static org.forgerock.json.resource.Responses.newResourceResponse; 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.Utils.newBadRequestException; import static org.forgerock.opendj.rest2ldap.Utils.newNotSupportedException; import static org.forgerock.opendj.rest2ldap.Utils.toFilter; import static org.forgerock.util.Utils.asEnum; import static org.forgerock.util.promise.Promises.newResultPromise; import static org.forgerock.util.promise.Promises.when; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.json.JsonPointer; import org.forgerock.json.JsonValue; import org.forgerock.json.JsonValueException; 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.PatchOperation; import org.forgerock.json.resource.PatchRequest; import org.forgerock.json.resource.PreconditionFailedException; 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.ResourceException; import org.forgerock.json.resource.ResourceResponse; import org.forgerock.json.resource.UncategorizedException; 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.DecodeException; import org.forgerock.opendj.ldap.DecodeOptions; import org.forgerock.opendj.ldap.Entry; import org.forgerock.opendj.ldap.EntryNotFoundException; import org.forgerock.opendj.ldap.Filter; import org.forgerock.opendj.ldap.LdapException; import org.forgerock.opendj.ldap.LdapPromise; import org.forgerock.opendj.ldap.Modification; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.SearchResultHandler; import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.controls.AssertionRequestControl; import org.forgerock.opendj.ldap.controls.PermissiveModifyRequestControl; import org.forgerock.opendj.ldap.controls.PostReadRequestControl; import org.forgerock.opendj.ldap.controls.PostReadResponseControl; import org.forgerock.opendj.ldap.controls.PreReadRequestControl; import org.forgerock.opendj.ldap.controls.PreReadResponseControl; import org.forgerock.opendj.ldap.controls.SimplePagedResultsControl; import org.forgerock.opendj.ldap.controls.SubtreeDeleteRequestControl; import org.forgerock.opendj.ldap.requests.AddRequest; import org.forgerock.opendj.ldap.requests.ModifyRequest; import org.forgerock.opendj.ldap.requests.PasswordModifyExtendedRequest; import org.forgerock.opendj.ldap.requests.SearchRequest; import org.forgerock.opendj.ldap.responses.PasswordModifyExtendedResult; import org.forgerock.opendj.ldap.responses.Result; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.forgerock.opendj.ldap.responses.SearchResultReference; import org.forgerock.opendj.ldif.ChangeRecord; import org.forgerock.services.context.ClientContext; import org.forgerock.services.context.Context; import org.forgerock.services.context.SecurityContext; import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; import org.forgerock.util.promise.ExceptionHandler; import org.forgerock.util.promise.Promise; import org.forgerock.util.promise.PromiseImpl; import org.forgerock.util.promise.ResultHandler; import org.forgerock.util.query.QueryFilter; import org.forgerock.util.query.QueryFilterVisitor; /** Implements the core CREST operations supported by singleton and collection sub-resources. */ final class SubResourceImpl { private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); /** Dummy exception used for signalling search success. */ private static final ResourceException SUCCESS = new UncategorizedException(0, null, null); private static final JsonPointer ROOT = new JsonPointer(); private final DN baseDn; private final AttributeDescription etagAttribute; private final NamingStrategy namingStrategy; private final DecodeOptions decodeOptions; private final ReadOnUpdatePolicy readOnUpdatePolicy; private final boolean useSubtreeDelete; private final boolean usePermissiveModify; private final Resource resource; private final Attribute glueObjectClasses; SubResourceImpl(final Rest2Ldap rest2Ldap, final DN baseDn, final Attribute glueObjectClasses, final NamingStrategy namingStrategy, final Resource resource) { 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); this.etagAttribute = rest2Ldap.getOptions().get(USE_MVCC) ? AttributeDescription.valueOf(rest2Ldap.getOptions().get(MVCC_ATTRIBUTE)) : null; this.decodeOptions = rest2Ldap.getOptions().get(DECODE_OPTIONS); this.baseDn = baseDn; this.glueObjectClasses = glueObjectClasses; this.namingStrategy = namingStrategy; this.resource = resource; } Promise action( final Context context, final String resourceId, final ActionRequest request) { try { final Action action = asEnum(request.getAction(), Action.class); if (resource.hasSupportedAction(action)) { switch (action) { case PASSWORDMODIFY: return passwordModify(context, resourceId, request); } } } catch (final IllegalArgumentException ignored) { // fall-through } return newNotSupportedException(ERR_ACTION_NOT_SUPPORTED.get(request.getAction())).asPromise(); } private Promise passwordModify( final Context context, final String resourceId, final ActionRequest request) { if (!context.containsContext(ClientContext.class) || !context.asContext(ClientContext.class).isSecure()) { return newResourceException(FORBIDDEN, ERR_PASSWORD_MODIFY_SECURE_CONNECTION.get().toString()).asPromise(); } if (!context.containsContext(SecurityContext.class) || context.asContext(SecurityContext.class).getAuthenticationId() == null) { return newResourceException(FORBIDDEN, ERR_PASSWORD_MODIFY_USER_AUTHENTICATED.get().toString()).asPromise(); } final JsonValue jsonContent = request.getContent(); final String oldPassword; final String newPassword; try { oldPassword = jsonContent.get("oldPassword").asString(); newPassword = jsonContent.get("newPassword").asString(); } catch (JsonValueException e) { final LocalizableMessage msg = ERR_PASSWORD_MODIFY_REQUEST_IS_INVALID.get(); final ResourceException ex = newBadRequestException(msg, e); logger.error(msg, e); return ex.asPromise(); } final Connection connection = connectionFrom(context); return resolveResourceDnAndType(context, connection, resourceId, null) .thenAsync(new AsyncFunction() { @Override public Promise apply(RoutingContext dnAndType) { final PasswordModifyExtendedRequest pwdModifyRequest = newPasswordModifyExtendedRequest() .setUserIdentity("dn: " + dnAndType.getDn()) .setOldPassword(asBytes(oldPassword)) .setNewPassword(asBytes(newPassword)); return connection.extendedRequestAsync(pwdModifyRequest) .thenCatchAsync(adaptLdapException(PasswordModifyExtendedResult.class)); } }).thenAsync(new AsyncFunction() { @Override public Promise apply(PasswordModifyExtendedResult r) { final JsonValue result = new JsonValue(new LinkedHashMap<>()); final byte[] generatedPwd = r.getGeneratedPassword(); if (generatedPwd != null) { result.put("generatedPassword", valueOfBytes(generatedPwd).toString()); } return newActionResponse(result).asPromise(); } }); } private byte[] asBytes(final String s) { return s != null ? s.getBytes(StandardCharsets.UTF_8) : null; } Promise create(final Context context, final CreateRequest request) { // First determine the type of resource being created. final Resource subType; try { subType = resource.resolveSubTypeFromJson(request.getContent()); } catch (final ResourceException e) { return e.asPromise(); } // Now build the LDAP representation and add it. final Connection connection = connectionFrom(context); return subType.getPropertyMapper() .create(connection, subType, ROOT, request.getContent()) .thenAsync(new AsyncFunction, ResourceResponse, ResourceException>() { @Override public Promise apply(final List attributes) { // Perform add operation. final AddRequest addRequest = newAddRequest(DN.rootDN()); addRequest.addAttribute(subType.getObjectClassAttribute()); for (final Attribute attribute : attributes) { addRequest.addAttribute(attribute); } try { namingStrategy.encodeResourceId(baseDn, request.getNewResourceId(), addRequest); } catch (final ResourceException e) { logger.error(raw(e.getLocalizedMessage()), e); return e.asPromise(); } if (readOnUpdatePolicy == CONTROLS) { final Set ldapAttributes = getLdapAttributesForKnownType(request.getFields(), subType); addRequest.addControl(PostReadRequestControl.newControl(false, ldapAttributes)); } return connection.addAsync(addRequest) .thenCatchAsync(lazilyAddGlueEntry(connection, addRequest)) .thenAsync(encodeUpdateResourceResponse(connection, subType), adaptLdapException(ResourceResponse.class)); } }); } /** * A resource and sub-resource may be separated by a "glue" entry in LDAP. This method detects when a glue entry * is missing, creates it, and then retries the original add operation. As a concrete example, consider the * backend configuration entry "ds-cfg-backend-id=userRoot,cn=backends,cn=config". Since its indexes are located * beneath "cn=Indexes,ds-cfg-backend-id=userRoot,cn=backends,cn=config" we need to add "cn=Indexes" before * adding an index entry. */ private AsyncFunction lazilyAddGlueEntry(final Connection connection, final AddRequest addRequest) { return new AsyncFunction() { @Override public Promise apply(final LdapException e) throws LdapException { if (glueObjectClasses != null && e instanceof EntryNotFoundException) { // The parent glue entry may be missing - lazily create it. final AddRequest glueAddRequest = newAddRequest(baseDn); glueAddRequest.addAttribute(glueObjectClasses); glueAddRequest.addAttribute(baseDn.rdn().getFirstAVA().toAttribute()); return connection.addAsync(glueAddRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply(final Result value) { return connection.addAsync(addRequest); } }); } // Something else happened, so rethrow. throw e; } }; } private Connection connectionFrom(final Context context) { return context.asContext(AuthenticatedConnectionContext.class).getConnection(); } Promise delete( final Context context, final String resourceId, final DeleteRequest request) { final Connection connection = connectionFrom(context); return resolveResourceDnAndType(context, connection, resourceId, request.getRevision()) .thenAsync(new AsyncFunction() { @Override public Promise apply(final RoutingContext dnAndType) throws ResourceException { final ChangeRecord deleteRequest = newDeleteRequest(dnAndType.getDn()); if (readOnUpdatePolicy == CONTROLS) { final Set attributes = getLdapAttributesForKnownType(request.getFields(), dnAndType.getType()); deleteRequest.addControl(PreReadRequestControl.newControl(false, attributes)); } if (resource.mayHaveSubResources() && useSubtreeDelete) { // Non-critical so that we can detect failure and retry without the control. Some backends, // such as cn=config, do not support the subtree delete control. deleteRequest.addControl(SubtreeDeleteRequestControl.newControl(false)); } addAssertionControl(deleteRequest, request.getRevision()); return connection.applyChangeAsync(deleteRequest) .thenCatchAsync(deleteSubtreeWithoutUsingSubtreeDeleteControl(connection, deleteRequest)) .thenAsync(encodeUpdateResourceResponse(connection, dnAndType.getType()), adaptLdapException(ResourceResponse.class)); } }); } /** * Detects whether a delete request failed because the targeted entry has children and the subtree delete control * could not be applied (e.g. due to ACIs or lack of support in the backend). On failure, fall-back to a search * and then a recursive bottom up delete of all subordinate entries, before finally retrying the original delete * request. */ private AsyncFunction deleteSubtreeWithoutUsingSubtreeDeleteControl( final Connection connection, final ChangeRecord deleteRequest) { return new AsyncFunction() { @Override public Promise apply(final LdapException e) throws LdapException { if (e.getResult().getResultCode().asEnum() != NOT_ALLOWED_ON_NONLEAF || !resource.mayHaveSubResources()) { throw e; } // Perform a subtree search and then delete entries one by one. final SearchRequest subordinates = newSearchRequest(deleteRequest.getName(), SearchScope.SUBORDINATES, Filter.objectClassPresent(), "1.1"); // This list does not need synchronization because search result notification is synchronized. final List subordinateEntries = new ArrayList<>(); return connection.searchAsync(subordinates, new SearchResultHandler() { @Override public boolean handleEntry(final SearchResultEntry entry) { subordinateEntries.add(entry.getName()); return true; } @Override public boolean handleReference(final SearchResultReference reference) { return false; } }).thenAsync(new AsyncFunction() { @Override public Promise apply(final Result result) { // Sort the entries in hierarchical order and then delete them in reverse, thus // always deleting children before parents. Collections.sort(subordinateEntries); LdapPromise promise = newSuccessfulLdapPromise(newResult(ResultCode.SUCCESS)); for (int i = subordinateEntries.size() - 1; i >= 0; i--) { final ChangeRecord subordinateDelete = newDeleteRequest(subordinateEntries.get(i)); promise = promise.thenAsync(new AsyncFunction() { @Override public Promise apply(final Result result) { return connection.applyChangeAsync(subordinateDelete); } }); } // And finally retry the original delete request. return promise.thenAsync(new AsyncFunction() { @Override public Promise apply(final Result result) { return connection.applyChangeAsync(deleteRequest); } }); } }); } }; } Promise patch( final Context context, final String resourceId, final PatchRequest request) { final Connection connection = connectionFrom(context); final AtomicReference dnAndTypeHolder = new AtomicReference<>(); return resolveResourceDnAndType(context, connection, resourceId, request.getRevision()) .thenAsync(new AsyncFunction>, ResourceException>() { @Override public Promise>, ResourceException> apply(final RoutingContext dnAndType) throws ResourceException { dnAndTypeHolder.set(dnAndType); // Convert the patch operations to LDAP modifications. final List, ResourceException>> promises = new ArrayList<>(request.getPatchOperations().size()); final Resource subType = dnAndType.getType(); final PropertyMapper propertyMapper = subType.getPropertyMapper(); for (final PatchOperation operation : request.getPatchOperations()) { promises.add(propertyMapper.patch(connection, subType, ROOT, operation)); } return when(promises); } }).thenAsync(new AsyncFunction>, ResourceResponse, ResourceException>() { @Override public Promise apply(final List> result) throws ResourceException { // The patch operations have been converted successfully. final RoutingContext dnAndType = dnAndTypeHolder.get(); final ModifyRequest modifyRequest = newModifyRequest(dnAndType.getDn()); // Add the modifications. for (final List modifications : result) { if (modifications != null) { modifyRequest.getModifications().addAll(modifications); } } final Resource subType = dnAndType.getType(); final Set attributes = getLdapAttributesForKnownType(request.getFields(), subType); if (modifyRequest.getModifications().isEmpty()) { // This patch is a no-op so just read the entry and check its version. return connection.readEntryAsync(dnAndType.getDn(), attributes) .thenAsync(encodeEmptyPatchResourceResponse(connection, subType, request), adaptLdapException(ResourceResponse.class)); } else { // Add controls and perform the modify request. if (readOnUpdatePolicy == CONTROLS) { modifyRequest.addControl(PostReadRequestControl.newControl(false, attributes)); } if (usePermissiveModify) { modifyRequest.addControl(PermissiveModifyRequestControl.newControl(true)); } addAssertionControl(modifyRequest, request.getRevision()); return connection.applyChangeAsync(modifyRequest) .thenAsync(encodeUpdateResourceResponse(connection, subType), adaptLdapException(ResourceResponse.class)); } } }); } private AsyncFunction encodeEmptyPatchResourceResponse( final Connection connection, final Resource resource, final PatchRequest request) { return new AsyncFunction() { @Override public Promise apply(Entry entry) throws ResourceException { try { ensureMvccVersionMatches(entry, request.getRevision()); return encodeResourceResponse(connection, resource, entry); } catch (final Exception e) { return asResourceException(e).asPromise(); } } }; } Promise query( final Context context, final QueryRequest request, final QueryResourceHandler resourceHandler) { final Connection connection = connectionFrom(context); return getLdapFilter(connection, request.getQueryFilter()) .thenAsync(runQuery(request, resourceHandler, connection)); } // FIXME: supporting assertions against sub-type properties. private Promise getLdapFilter( final Connection connection, final QueryFilter queryFilter) { if (queryFilter == null) { return new BadRequestException(ERR_QUERY_BY_ID_OR_EXPRESSION_NOT_SUPPORTED.get().toString()).asPromise(); } final PropertyMapper propertyMapper = resource.getPropertyMapper(); final QueryFilterVisitor, Void, JsonPointer> visitor = new QueryFilterVisitor, Void, JsonPointer>() { @Override 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)); } return when(promises).then(new Function, Filter, ResourceException>() { @Override 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(); case 1: return value.get(0); default: return Filter.and(value); } } }); } @Override public Promise visitBooleanLiteralFilter( final Void unused, final boolean value) { return newResultPromise(toFilter(value)); } @Override public Promise visitContainsFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, CONTAINS, null, valueAssertion); } @Override public Promise visitEqualsFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, EQUAL_TO, null, valueAssertion); } @Override public Promise visitExtendedMatchFilter(final Void unused, final JsonPointer field, final String operator, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, EXTENDED, operator, valueAssertion); } @Override public Promise visitGreaterThanFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, GREATER_THAN, null, valueAssertion); } @Override public Promise visitGreaterThanOrEqualToFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, GREATER_THAN_OR_EQUAL_TO, null, valueAssertion); } @Override public Promise visitLessThanFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, LESS_THAN, null, valueAssertion); } @Override public Promise visitLessThanOrEqualToFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, resource, ROOT, field, LESS_THAN_OR_EQUAL_TO, null, valueAssertion); } @Override public Promise visitNotFilter( final Void unused, final QueryFilter subFilter) { return subFilter.accept(this, unused).then(new Function() { @Override public Filter apply(final Filter value) { if (value == null || value == alwaysFalse()) { return alwaysTrue(); } else if (value == alwaysTrue()) { return alwaysFalse(); } else { return Filter.not(value); } } }); } @Override public Promise visitOrFilter( final Void unused, final List> subFilters) { final List> promises = new ArrayList<>(subFilters.size()); for (final QueryFilter subFilter : subFilters) { promises.add(subFilter.accept(this, unused)); } return when(promises).then(new Function, Filter, ResourceException>() { @Override 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()) { i.remove(); } else if (f == alwaysTrue()) { return alwaysTrue(); } } switch (value.size()) { case 0: return alwaysFalse(); case 1: return value.get(0); default: return Filter.or(value); } } }); } @Override public Promise visitPresentFilter( final Void unused, final JsonPointer field) { return propertyMapper.getLdapFilter(connection, resource, ROOT, field, PRESENT, null, null); } @Override public Promise visitStartsWithFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, 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); } private AsyncFunction runQuery( final QueryRequest request, final QueryResourceHandler resourceHandler, final Connection connection) { return new AsyncFunction() { // The following fields are guarded by sequenceLock. In addition, the sequenceLock ensures that // we send one JSON resource at a time back to the client. private final Object sequenceLock = new Object(); private String cookie; private ResourceException pendingResult; private int pendingResourceCount; private boolean resultSent; private int totalResourceCount; @Override public Promise apply(final Filter ldapFilter) { if (ldapFilter == null || ldapFilter == alwaysFalse()) { // Avoid performing a search if the filter could not be mapped or if it will never match. return newQueryResponse().asPromise(); } final PromiseImpl promise = PromiseImpl.create(); // Perform the search. 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); // Add the page results control. We can support the page offset by reading the next offset pages, or // offset x page size resources. final int pageResultStartIndex; final int pageSize = request.getPageSize(); if (request.getPageSize() > 0) { final int pageResultEndIndex; if (request.getPagedResultsOffset() > 0) { pageResultStartIndex = request.getPagedResultsOffset() * pageSize; pageResultEndIndex = pageResultStartIndex + pageSize; } else { pageResultStartIndex = 0; pageResultEndIndex = pageSize; } final ByteString cookie = request.getPagedResultsCookie() != null ? ByteString.valueOfBase64(request.getPagedResultsCookie()) : ByteString.empty(); final SimplePagedResultsControl control = SimplePagedResultsControl.newControl(true, pageResultEndIndex, cookie); searchRequest.addControl(control); } else { pageResultStartIndex = 0; } connection.searchAsync(searchRequest, new SearchResultHandler() { @Override public boolean handleEntry(final SearchResultEntry entry) { // Search result entries will be returned before the search result/error so the only reason // pendingResult will be non-null is if a mapping error has occurred. synchronized (sequenceLock) { if (pendingResult != null) { return false; } if (totalResourceCount++ < pageResultStartIndex) { // Haven't reached paged results threshold yet. return true; } pendingResourceCount++; } /* * FIXME: secondary asynchronous searches will complete in a non-deterministic order and * may cause the JSON resources to be returned in a different order to the order in which * the primary LDAP search results were received. This is benign at the moment, but will * need resolving when we implement server side sorting. A possible fix will be to use a * queue of pending resources (promises?). However, the queue cannot be unbounded in case * it grows very large, but it cannot be bounded either since that could cause a deadlock * between rest2ldap and the LDAP server (imagine the case where the server has a single * worker thread which is occupied processing the primary search). * The best solution is probably to process the primary search results in batches using * the paged results control. */ final String id = namingStrategy.decodeResourceId(entry); final String revision = getRevisionFromEntry(entry); final Resource subType = resource.resolveSubTypeFromObjectClasses(entry); final PropertyMapper propertyMapper = subType.getPropertyMapper(); propertyMapper.read(connection, subType, ROOT, entry) .thenOnResult(new ResultHandler() { @Override public void handleResult(final JsonValue result) { synchronized (sequenceLock) { pendingResourceCount--; if (!resultSent) { resourceHandler.handleResource( newResourceResponse(id, revision, result)); } completeIfNecessary(promise); } } }) .thenOnException(new ExceptionHandler() { @Override public void handleException(ResourceException exception) { synchronized (sequenceLock) { pendingResourceCount--; completeIfNecessary(exception, promise); } } }); return true; } @Override public boolean handleReference(final SearchResultReference reference) { // TODO: should this be classed as an error since rest2ldap assumes entries are all colocated? return true; } }).thenOnResult(new ResultHandler() { @Override public void handleResult(Result result) { synchronized (sequenceLock) { if (request.getPageSize() > 0) { try { final SimplePagedResultsControl control = result.getControl(SimplePagedResultsControl.DECODER, decodeOptions); if (control != null && !control.getCookie().isEmpty()) { cookie = control.getCookie().toBase64String(); } } catch (final DecodeException e) { logger.error(ERR_DECODING_CONTROL.get(e.getLocalizedMessage()), e); } } completeIfNecessary(SUCCESS, promise); } } }).thenOnException(new ExceptionHandler() { @Override public void handleException(final LdapException e) { synchronized (sequenceLock) { if (glueObjectClasses != null && e instanceof EntryNotFoundException) { // Glue entry does not exist, so treat this as an empty result set. completeIfNecessary(SUCCESS, promise); } else { completeIfNecessary(asResourceException(e), promise); } } } }); return promise; } /** This method must be invoked with the sequenceLock held. */ private void completeIfNecessary( final ResourceException e, final PromiseImpl handler) { if (pendingResult == null) { pendingResult = e; } completeIfNecessary(handler); } /** * Close out the query result set if there are no more pending * resources and the LDAP result has been received. * This method must be invoked with the sequenceLock held. */ private void completeIfNecessary(final PromiseImpl handler) { if (pendingResourceCount == 0 && pendingResult != null && !resultSent) { if (pendingResult == SUCCESS) { handler.handleResult(newQueryResponse(cookie)); } else { handler.handleException(pendingResult); } resultSent = true; } } }; } Promise read( final Context context, final String resourceId, final ReadRequest request) { final Connection connection = connectionFrom(context); return connection.searchSingleEntryAsync(searchRequestForUnknownType(resourceId, request.getFields())) .thenCatchAsync(adaptLdapException(SearchResultEntry.class)) .thenAsync(new AsyncFunction() { @Override public Promise apply(SearchResultEntry entry) { final Resource subType = resource.resolveSubTypeFromObjectClasses(entry); return encodeResourceResponse(connection, subType, entry); } }); } Promise update( final Context context, final String resourceId, final UpdateRequest request) { final Connection connection = connectionFrom(context); final AtomicReference entryHolder = new AtomicReference<>(); final AtomicReference subTypeHolder = new AtomicReference<>(); return connection .searchSingleEntryAsync(searchRequestForUnknownType(resourceId, Collections.emptyList())) .thenCatchAsync(adaptLdapException(SearchResultEntry.class)) .thenAsync(new AsyncFunction, ResourceException>() { @Override public Promise, ResourceException> apply(final SearchResultEntry entry) throws ResourceException { entryHolder.set(entry); // Fail-fast if there is a version mismatch. ensureMvccVersionMatches(entry, request.getRevision()); // Determine the type of resource and set of changes that need to be performed. final Resource subType = resource.resolveSubTypeFromObjectClasses(entry); subTypeHolder.set(subType); final PropertyMapper propertyMapper = subType.getPropertyMapper(); return propertyMapper.update(connection, subType , ROOT, entry, request.getContent()); } }).thenAsync(new AsyncFunction, ResourceResponse, ResourceException>() { @Override public Promise apply(List modifications) throws ResourceException { final Resource subType = subTypeHolder.get(); if (modifications.isEmpty()) { // No changes to be performed so just return the entry that we read. return encodeResourceResponse(connection, subType, entryHolder.get()); } // Perform the modify operation. final ModifyRequest modifyRequest = newModifyRequest(entryHolder.get().getName()); if (readOnUpdatePolicy == CONTROLS) { final Set attributes = getLdapAttributesForKnownType(request.getFields(), subType); modifyRequest.addControl(PostReadRequestControl.newControl(false, attributes)); } if (usePermissiveModify) { modifyRequest.addControl(PermissiveModifyRequestControl.newControl(true)); } addAssertionControl(modifyRequest, request.getRevision()); modifyRequest.getModifications().addAll(modifications); return connection.applyChangeAsync(modifyRequest) .thenAsync(encodeUpdateResourceResponse(connection, subType), adaptLdapException(ResourceResponse.class)); } }); } private Promise encodeResourceResponse( final Connection connection, final Resource resource, final Entry entry) { final PropertyMapper propertyMapper = resource.getPropertyMapper(); return propertyMapper.read(connection, resource, ROOT, entry) .then(new Function() { @Override public ResourceResponse apply(final JsonValue value) { final String revision = getRevisionFromEntry(entry); final String actualResourceId = namingStrategy.decodeResourceId(entry); return newResourceResponse(actualResourceId, revision, new JsonValue(value)); } }); } private void addAssertionControl(final ChangeRecord request, final String expectedRevision) throws ResourceException { if (expectedRevision != null) { ensureMvccSupported(); final Filter filter = Filter.equality(etagAttribute.toString(), expectedRevision); request.addControl(AssertionRequestControl.newControl(true, filter)); } } private Promise resolveResourceDnAndType( final Context context, final Connection connection, final String resourceId, final String revision) { final SearchRequest searchRequest = namingStrategy.createSearchRequest(baseDn, resourceId); if (searchRequest.getScope().equals(BASE_OBJECT) && !resource.hasSubTypes()) { // There's no point in doing a search because we already know the DN and sub-resources. return newResultPromise(new RoutingContext(context, searchRequest.getName(), resource)); } if (etagAttribute != null && revision != null) { searchRequest.addAttribute(etagAttribute.toString()); } // The resource type will be resolved from the LDAP entry's objectClass. searchRequest.addAttribute("objectClass"); return connection.searchSingleEntryAsync(searchRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply(final SearchResultEntry entry) throws ResourceException { // Fail-fast if there is a version mismatch. ensureMvccVersionMatches(entry, revision); final Resource subType = resource.resolveSubTypeFromObjectClasses(entry); return newResultPromise(new RoutingContext(context, entry.getName(), subType)); } }, adaptLdapException(RoutingContext.class)); } private void ensureMvccSupported() throws NotSupportedException { if (etagAttribute == null) { throw newNotSupportedException(ERR_MVCC_NOT_SUPPORTED.get()); } } private void ensureMvccVersionMatches(final Entry entry, final String expectedRevision) throws ResourceException { if (expectedRevision != null) { ensureMvccSupported(); final String actualRevision = entry.parseAttribute(etagAttribute).asString(); if (actualRevision == null) { throw new PreconditionFailedException(ERR_MVCC_NO_VERSION_INFORMATION.get(expectedRevision).toString()); } else if (!expectedRevision.equals(actualRevision)) { throw new PreconditionFailedException( ERR_MVCC_VERSIONS_MISMATCH.get(expectedRevision, actualRevision).toString()); } } } private Set getLdapAttributesForUnknownType(final Collection fields) { final Set ldapAttributes = getLdapAttributesForKnownType(fields, resource); getLdapAttributesForUnknownType(fields, resource, ldapAttributes); return ldapAttributes; } private void getLdapAttributesForUnknownType(final Collection fields, final Resource resource, final Set ldapAttributes) { for (final Resource subType : resource.getSubTypes()) { addLdapAttributesForFields(fields, subType, ldapAttributes); getLdapAttributesForUnknownType(fields, subType, ldapAttributes); } } private Set getLdapAttributesForKnownType(final Collection fields, final Resource resource) { // Includes the LDAP attributes required by the type, etag, and name strategies. final Set ldapAttributes = new LinkedHashSet<>(); ldapAttributes.add("objectClass"); final String resourceIdLdapAttribute = namingStrategy.getResourceIdLdapAttribute(); if (resourceIdLdapAttribute != null) { ldapAttributes.add(resourceIdLdapAttribute); } if (etagAttribute != null) { ldapAttributes.add(etagAttribute.toString()); } addLdapAttributesForFields(fields, resource, ldapAttributes); return ldapAttributes; } /** Includes the LDAP attributes required for the specified JSON fields for all sub-types. */ private void addLdapAttributesForFields(final Collection fields, final Resource resource, final Set ldapAttributes) { final PropertyMapper propertyMapper = resource.getPropertyMapper(); if (fields.isEmpty()) { // Full read. propertyMapper.getLdapAttributes(ROOT, ROOT, ldapAttributes); } else { // Partial read. for (final JsonPointer field : fields) { propertyMapper.getLdapAttributes(ROOT, field, ldapAttributes); } } } private String getRevisionFromEntry(final Entry entry) { return etagAttribute != null ? entry.parseAttribute(etagAttribute).asString() : null; } private AsyncFunction encodeUpdateResourceResponse( final Connection connection, final Resource resource) { return new AsyncFunction() { @Override public Promise apply(Result result) { // FIXME: handle USE_SEARCH policy. try { final PostReadResponseControl postReadControl = result.getControl(PostReadResponseControl.DECODER, decodeOptions); if (postReadControl != null) { return encodeResourceResponse(connection, resource, postReadControl.getEntry()); } final PreReadResponseControl preReadControl = result.getControl(PreReadResponseControl.DECODER, decodeOptions); if (preReadControl != null) { return encodeResourceResponse(connection, resource, preReadControl.getEntry()); } } catch (final DecodeException e) { logger.error(ERR_DECODING_CONTROL.get(e.getLocalizedMessage()), e); } // Return an empty resource response. return newResourceResponse(null, null, new JsonValue(Collections.emptyMap())).asPromise(); } }; } private SearchRequest searchRequestForUnknownType(final String resourceId, final List fields) { final String[] attributes = getLdapAttributesForUnknownType(fields).toArray(new String[0]); return namingStrategy.createSearchRequest(baseDn, resourceId).addAttribute(attributes); } @SuppressWarnings("unused") private static AsyncFunction adaptLdapException(final Class clazz) { return new AsyncFunction() { @Override public Promise apply(final LdapException ldapException) { return asResourceException(ldapException).asPromise(); } }; } }