/* * 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.json.resource.Responses.newResourceResponse; import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*; import static java.util.Arrays.asList; import static org.forgerock.opendj.ldap.Filter.alwaysFalse; import static org.forgerock.opendj.ldap.Filter.alwaysTrue; import static org.forgerock.opendj.ldap.requests.Requests.newAddRequest; import static org.forgerock.opendj.ldap.requests.Requests.newDeleteRequest; import static org.forgerock.opendj.ldap.requests.Requests.newModifyRequest; import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest; import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS; import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException; 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 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 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.CollectionResourceProvider; 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.Responses; 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.Filter; import org.forgerock.opendj.ldap.LdapException; import org.forgerock.opendj.ldap.Modification; 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.Requests; 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.Promises; import org.forgerock.util.promise.ResultHandler; import org.forgerock.util.query.QueryFilter; import org.forgerock.util.query.QueryFilterVisitor; /** * A {@code CollectionResourceProvider} implementation which maps a JSON * resource collection to LDAP entries beneath a base DN. */ final class SubResourceImpl implements CollectionResourceProvider { private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); /** Dummy exception used for signalling search success. */ private static final ResourceException SUCCESS = new UncategorizedException(0, null, null); /** Empty decode options required for decoding response controls. */ private static final DecodeOptions DECODE_OPTIONS = new DecodeOptions(); private final List additionalLDAPAttributes; private final PropertyMapper propertyMapper; private final DN baseDn; // TODO: support template variables. private final Config config; private final AttributeDescription etagAttribute; private final NamingStrategy namingStrategy; SubResourceImpl(final DN baseDn, final PropertyMapper mapper, final NamingStrategy namingStrategy, final AttributeDescription etagAttribute, final Config config, final List additionalLDAPAttributes) { this.baseDn = baseDn; this.propertyMapper = mapper; this.config = config; this.namingStrategy = namingStrategy; this.etagAttribute = etagAttribute; this.additionalLDAPAttributes = additionalLDAPAttributes; } @Override public Promise actionCollection( final Context context, final ActionRequest request) { return Promises. newExceptionPromise( newNotSupportedException(ERR_NOT_YET_IMPLEMENTED.get())); } @Override public Promise actionInstance( final Context context, final String resourceId, final ActionRequest request) { String actionId = request.getAction(); if (actionId.equals("passwordModify")) { return passwordModify(context, resourceId, request); } return Promises. newExceptionPromise( newNotSupportedException(ERR_ACTION_NOT_SUPPORTED.get(actionId))); } private Promise passwordModify( final Context context, final String resourceId, final ActionRequest request) { if (!context.containsContext(ClientContext.class) || !context.asContext(ClientContext.class).isSecure()) { return Promises.newExceptionPromise(ResourceException.newResourceException( ResourceException.FORBIDDEN, ERR_PASSWORD_MODIFY_SECURE_CONNECTION.get().toString())); } if (!context.containsContext(SecurityContext.class) || context.asContext(SecurityContext.class).getAuthenticationId() == null) { return Promises.newExceptionPromise(ResourceException.newResourceException( ResourceException.FORBIDDEN, ERR_PASSWORD_MODIFY_USER_AUTHENTICATED.get().toString())); } 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 Promises.newExceptionPromise(ex); } final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); List attrs = Collections.emptyList(); return connection.searchSingleEntryAsync(searchRequest(connection, resourceId, attrs)) .thenAsync(new AsyncFunction() { @Override public Promise apply( final SearchResultEntry entry) { PasswordModifyExtendedRequest pwdModifyRequest = Requests.newPasswordModifyExtendedRequest(); pwdModifyRequest.setUserIdentity("dn: " + entry.getName()); pwdModifyRequest.setOldPassword(asBytes(oldPassword)); pwdModifyRequest.setNewPassword(asBytes(newPassword)); return connection.extendedRequestAsync(pwdModifyRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply( PasswordModifyExtendedResult value) throws ResourceException { JsonValue result = new JsonValue(new LinkedHashMap<>()); byte[] generatedPwd = value.getGeneratedPassword(); if (generatedPwd != null) { result = result.put("generatedPassword", ByteString.valueOfBytes(generatedPwd).toString()); } return Responses.newActionResponse(result).asPromise(); } }, Exceptions.toResourceException()); } }, Exceptions.toResourceException()); } private byte[] asBytes(final String s) { return s != null ? s.getBytes(StandardCharsets.UTF_8) : null; } @Override public Promise createInstance(final Context context, final CreateRequest request) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); // Calculate entry content. return propertyMapper .create(connection, new JsonPointer(), request.getContent()) .thenAsync(new AsyncFunction, ResourceResponse, ResourceException>() { @Override public Promise apply(final List attributes) { // Perform add operation. final AddRequest addRequest = newAddRequest(DN.rootDN()); for (final Attribute attribute : additionalLDAPAttributes) { addRequest.addAttribute(attribute); } for (final Attribute attribute : attributes) { addRequest.addAttribute(attribute); } try { namingStrategy.setResourceId(connection, getBaseDn(), request.getNewResourceId(), addRequest); } catch (final ResourceException e) { logger.error(raw(e.getLocalizedMessage()), e); return Promises.newExceptionPromise(e); } if (config.readOnUpdatePolicy() == CONTROLS) { addRequest.addControl(PostReadRequestControl.newControl( false, getLdapAttributes(connection, request.getFields()))); } return connection.applyChangeAsync(addRequest) .thenAsync( postUpdateResultAsyncFunction(connection), Exceptions.toResourceException()); } }); } @Override public Promise deleteInstance( final Context context, final String resourceId, final DeleteRequest request) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); return doUpdateFunction(connection, resourceId, request.getRevision()) .thenAsync(new AsyncFunction() { @Override public Promise apply(DN dn) throws ResourceException { try { final ChangeRecord deleteRequest = newDeleteRequest(dn); if (config.readOnUpdatePolicy() == CONTROLS) { final String[] attributes = getLdapAttributes(connection, request.getFields()); deleteRequest.addControl(PreReadRequestControl.newControl(false, attributes)); } if (config.useSubtreeDelete()) { deleteRequest.addControl(SubtreeDeleteRequestControl.newControl(true)); } addAssertionControl(deleteRequest, request.getRevision()); return connection.applyChangeAsync(deleteRequest) .thenAsync( postUpdateResultAsyncFunction(connection), Exceptions.toResourceException()); } catch (final Exception e) { return Promises.newExceptionPromise(asResourceException(e)); } } }); } @Override public Promise patchInstance( final Context context, final String resourceId, final PatchRequest request) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); if (request.getPatchOperations().isEmpty()) { return emptyPatchInstance(connection, resourceId, request); } return doUpdateFunction(connection, resourceId, request.getRevision()) .thenAsync(new AsyncFunction() { @Override public Promise apply(final DN dn) throws ResourceException { // Convert the patch operations to LDAP modifications. List, ResourceException>> promises = new ArrayList<>(request.getPatchOperations().size()); for (final PatchOperation operation : request.getPatchOperations()) { promises.add(propertyMapper.patch(connection, new JsonPointer(), operation)); } return Promises.when(promises).thenAsync( new AsyncFunction>, ResourceResponse, ResourceException>() { @Override public Promise apply( final List> result) { // The patch operations have been converted successfully. try { final ModifyRequest modifyRequest = newModifyRequest(dn); // Add the modifications. for (final List modifications : result) { if (modifications != null) { modifyRequest.getModifications().addAll(modifications); } } final List attributes = asList(getLdapAttributes(connection, request.getFields())); if (modifyRequest.getModifications().isEmpty()) { // This patch is a no-op so just read the entry and check its version. return connection .readEntryAsync(dn, attributes) .thenAsync(postEmptyPatchAsyncFunction(connection, request), Exceptions.toResourceException()); } else { // Add controls and perform the modify request. if (config.readOnUpdatePolicy() == CONTROLS) { modifyRequest.addControl( PostReadRequestControl.newControl(false, attributes)); } if (config.usePermissiveModify()) { modifyRequest.addControl( PermissiveModifyRequestControl.newControl(true)); } addAssertionControl(modifyRequest, request.getRevision()); return connection .applyChangeAsync(modifyRequest) .thenAsync( postUpdateResultAsyncFunction(connection), Exceptions.toResourceException()); } } catch (final Exception e) { return Promises.newExceptionPromise(asResourceException(e)); } } }); } }); } /** Just read the entry and check its version. */ private Promise emptyPatchInstance(final Connection connection, final String resourceId, final PatchRequest request) { final SearchRequest searchRequest = searchRequest(connection, resourceId, request.getFields()); return connection .searchSingleEntryAsync(searchRequest) .thenAsync(postEmptyPatchAsyncFunction(connection, request), Exceptions.toResourceException()); } private AsyncFunction postEmptyPatchAsyncFunction( final Connection connection, final PatchRequest request) { return new AsyncFunction() { @Override public Promise apply(SearchResultEntry entry) throws ResourceException { try { // Fail if there is a version mismatch. ensureMvccVersionMatches(entry, request.getRevision()); return adaptEntry(connection, entry); } catch (final Exception e) { return Promises.newExceptionPromise(asResourceException(e)); } } }; } @Override public Promise queryCollection( final Context context, final QueryRequest request, final QueryResourceHandler resourceHandler) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); // Calculate the filter (this may require the connection). return getLdapFilter(connection, request.getQueryFilter()) .thenAsync(runQuery(request, resourceHandler, connection)); } private Promise getLdapFilter(final Connection connection, final QueryFilter queryFilter) { 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 Promises.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 Promises.newResultPromise(toFilter(value)); } @Override public Promise visitContainsFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.CONTAINS, null, valueAssertion); } @Override public Promise visitEqualsFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.EQUAL_TO, null, valueAssertion); } @Override public Promise visitExtendedMatchFilter(final Void unused, final JsonPointer field, final String operator, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.EXTENDED, operator, valueAssertion); } @Override public Promise visitGreaterThanFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.GREATER_THAN, null, valueAssertion); } @Override public Promise visitGreaterThanOrEqualToFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter(connection, new JsonPointer(), field, FilterType.GREATER_THAN_OR_EQUAL_TO, null, valueAssertion); } @Override public Promise visitLessThanFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.LESS_THAN, null, valueAssertion); } @Override public Promise visitLessThanOrEqualToFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter(connection, new JsonPointer(), field, FilterType.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 Promises.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, new JsonPointer(), field, FilterType.PRESENT, null, null); } @Override public Promise visitStartsWithFilter( final Void unused, final JsonPointer field, final Object valueAssertion) { return propertyMapper.getLdapFilter( connection, new JsonPointer(), field, FilterType.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 Promises.newResultPromise(Responses.newQueryResponse()); } final PromiseImpl promise = PromiseImpl.create(); // Perform the search. final String[] attributes = getLdapAttributes(connection, request.getFields()); final Filter searchFilter = ldapFilter == Filter.alwaysTrue() ? Filter.objectClassPresent() : ldapFilter; final SearchRequest searchRequest = newSearchRequest( getBaseDn(), SearchScope.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.getResourceId(connection, entry); final String revision = getRevisionFromEntry(entry); propertyMapper.read(connection, new JsonPointer(), entry) .thenOnResult(new ResultHandler() { @Override public void handleResult(final JsonValue result) { synchronized (sequenceLock) { pendingResourceCount--; if (!resultSent) { resourceHandler.handleResource( Responses.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, DECODE_OPTIONS); 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(LdapException exception) { synchronized (sequenceLock) { completeIfNecessary(asResourceException(exception), 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(Responses.newQueryResponse(cookie)); } else { handler.handleException(pendingResult); } resultSent = true; } } }; } @Override public Promise readInstance( final Context context, final String resourceId, final ReadRequest request) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); // Do the search. SearchRequest searchRequest = searchRequest(connection, resourceId, request.getFields()); return connection .searchSingleEntryAsync(searchRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply(SearchResultEntry entry) throws ResourceException { return adaptEntry(connection, entry); } }, Exceptions.toResourceException()); } @Override public Promise updateInstance( final Context context, final String resourceId, final UpdateRequest request) { final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection(); List attrs = Collections.emptyList(); SearchRequest searchRequest = searchRequest(connection, resourceId, attrs); return connection .searchSingleEntryAsync(searchRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply( final SearchResultEntry entry) { try { // Fail-fast if there is a version mismatch. ensureMvccVersionMatches(entry, request.getRevision()); // Create the modify request. final ModifyRequest modifyRequest = newModifyRequest(entry.getName()); if (config.readOnUpdatePolicy() == CONTROLS) { final String[] attributes = getLdapAttributes(connection, request.getFields()); modifyRequest.addControl( PostReadRequestControl.newControl(false, attributes)); } if (config.usePermissiveModify()) { modifyRequest.addControl( PermissiveModifyRequestControl.newControl(true)); } addAssertionControl(modifyRequest, request.getRevision()); // Determine the set of changes that need to be performed. return propertyMapper.update( connection, new JsonPointer(), entry, request.getContent()) .thenAsync(new AsyncFunction< List, ResourceResponse, ResourceException>() { @Override public Promise apply( List modifications) throws ResourceException { if (modifications.isEmpty()) { // No changes to be performed so just return // the entry that we read. return adaptEntry(connection, entry); } // Perform the modify operation. modifyRequest.getModifications().addAll(modifications); return connection .applyChangeAsync(modifyRequest) .thenAsync( postUpdateResultAsyncFunction(connection), Exceptions.toResourceException()); } }); } catch (final Exception e) { return Promises.newExceptionPromise(asResourceException(e)); } } }, Exceptions.toResourceException()); } private Promise adaptEntry(final Connection connection, final Entry entry) { final String actualResourceId = namingStrategy.getResourceId(connection, entry); final String revision = getRevisionFromEntry(entry); return propertyMapper.read(connection, new JsonPointer(), entry) .then(new Function() { @Override public ResourceResponse apply(final JsonValue value) { return newResourceResponse( actualResourceId, revision, new JsonValue(value)); } }); } private void addAssertionControl(final ChangeRecord request, final String expectedRevision) throws ResourceException { if (expectedRevision != null) { ensureMvccSupported(); request.addControl(AssertionRequestControl.newControl(true, Filter.equality( etagAttribute.toString(), expectedRevision))); } } private Promise doUpdateFunction(final Connection connection, final String resourceId, final String revision) { final String ldapAttribute = (etagAttribute != null && revision != null) ? etagAttribute.toString() : "1.1"; final SearchRequest searchRequest = namingStrategy.createSearchRequest(connection, getBaseDn(), resourceId) .addAttribute(ldapAttribute); if (searchRequest.getScope().equals(SearchScope.BASE_OBJECT)) { // There's no point in doing a search because we already know the DN. return Promises.newResultPromise(searchRequest.getName()); } return connection .searchSingleEntryAsync(searchRequest) .thenAsync(new AsyncFunction() { @Override public Promise apply(SearchResultEntry entry) throws ResourceException { try { // Fail-fast if there is a version mismatch. ensureMvccVersionMatches(entry, revision); // Perform update operation. return Promises.newResultPromise(entry.getName()); } catch (final Exception e) { return Promises.newExceptionPromise(asResourceException(e)); } } }, new AsyncFunction() { @Override public Promise apply(LdapException ldapException) throws ResourceException { return Promises.newExceptionPromise(asResourceException(ldapException)); } }); } 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 DN getBaseDn() { return baseDn; } /** * Determines the set of LDAP attributes to request in an LDAP read (search, * post-read), based on the provided list of JSON pointers. * * @param connection * The request state. * @param requestedAttributes * The list of resource attributes to be read. * @return The set of LDAP attributes associated with the resource * attributes. */ private String[] getLdapAttributes(final Connection connection, final Collection requestedAttributes) { // Get all the LDAP attributes required by the property mappers. final Set requestedLDAPAttributes; if (requestedAttributes.isEmpty()) { // Full read. requestedLDAPAttributes = new LinkedHashSet<>(); propertyMapper.getLdapAttributes(connection, new JsonPointer(), new JsonPointer(), requestedLDAPAttributes); } else { // Partial read. requestedLDAPAttributes = new LinkedHashSet<>(requestedAttributes.size()); for (final JsonPointer requestedAttribute : requestedAttributes) { propertyMapper.getLdapAttributes(connection, new JsonPointer(), requestedAttribute, requestedLDAPAttributes); } } // Get the LDAP attributes required by the Etag and name stategies. namingStrategy.getLdapAttributes(connection, requestedLDAPAttributes); if (etagAttribute != null) { requestedLDAPAttributes.add(etagAttribute.toString()); } return requestedLDAPAttributes.toArray(new String[requestedLDAPAttributes.size()]); } private String getRevisionFromEntry(final Entry entry) { return etagAttribute != null ? entry.parseAttribute(etagAttribute).asString() : null; } private AsyncFunction postUpdateResultAsyncFunction( final Connection connection) { // The handler which will be invoked for the LDAP add result. return new AsyncFunction() { @Override public Promise apply(Result result) throws ResourceException { // FIXME: handle USE_SEARCH policy. Entry entry; try { final PostReadResponseControl postReadControl = result.getControl(PostReadResponseControl.DECODER, config.decodeOptions()); if (postReadControl != null) { entry = postReadControl.getEntry(); } else { final PreReadResponseControl preReadControl = result.getControl(PreReadResponseControl.DECODER, config.decodeOptions()); if (preReadControl != null) { entry = preReadControl.getEntry(); } else { entry = null; } } } catch (final DecodeException e) { logger.error(ERR_DECODING_CONTROL.get(e.getLocalizedMessage()), e); entry = null; } if (entry != null) { return adaptEntry(connection, entry); } else { return Promises.newResultPromise( newResourceResponse(null, null, new JsonValue(Collections.emptyMap()))); } } }; } private SearchRequest searchRequest( final Connection connection, final String resourceId, final List requestedAttributes) { final String[] attributes = getLdapAttributes(connection, requestedAttributes); return namingStrategy.createSearchRequest(connection, getBaseDn(), resourceId).addAttribute(attributes); } private static final class Exceptions { private static AsyncFunction toResourceException() { // The handler which will be invoked for the LDAP add result. return new AsyncFunction() { @Override public Promise apply(final LdapException ldapException) throws ResourceException { return Promises.newExceptionPromise(asResourceException(ldapException)); } }; } } }