001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2012-2016 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap;
017
018import static org.forgerock.opendj.ldap.ResultCode.ADMIN_LIMIT_EXCEEDED;
019import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
020import static org.forgerock.opendj.ldap.LdapException.newLdapException;
021import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
022import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
023import static org.forgerock.util.Reject.checkNotNull;
024import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
025import static org.forgerock.util.promise.Promises.newResultPromise;
026
027import java.util.ArrayList;
028import java.util.LinkedHashSet;
029import java.util.LinkedList;
030import java.util.List;
031import java.util.Set;
032import java.util.concurrent.atomic.AtomicInteger;
033import java.util.concurrent.atomic.AtomicReference;
034
035import org.forgerock.json.JsonPointer;
036import org.forgerock.json.JsonValue;
037import org.forgerock.json.resource.ResourceException;
038import org.forgerock.opendj.ldap.Attribute;
039import org.forgerock.opendj.ldap.AttributeDescription;
040import org.forgerock.opendj.ldap.ByteString;
041import org.forgerock.opendj.ldap.Connection;
042import org.forgerock.opendj.ldap.DN;
043import org.forgerock.opendj.ldap.Entry;
044import org.forgerock.opendj.ldap.EntryNotFoundException;
045import org.forgerock.opendj.ldap.Filter;
046import org.forgerock.opendj.ldap.LdapException;
047import org.forgerock.opendj.ldap.LinkedAttribute;
048import org.forgerock.opendj.ldap.MultipleEntriesFoundException;
049import org.forgerock.opendj.ldap.SearchResultHandler;
050import org.forgerock.opendj.ldap.SearchScope;
051import org.forgerock.opendj.ldap.requests.SearchRequest;
052import org.forgerock.opendj.ldap.responses.Result;
053import org.forgerock.opendj.ldap.responses.SearchResultEntry;
054import org.forgerock.opendj.ldap.responses.SearchResultReference;
055import org.forgerock.opendj.ldap.schema.Schema;
056import org.forgerock.util.AsyncFunction;
057import org.forgerock.util.Function;
058import org.forgerock.util.promise.ExceptionHandler;
059import org.forgerock.util.promise.Promise;
060import org.forgerock.util.promise.PromiseImpl;
061import org.forgerock.util.promise.Promises;
062import org.forgerock.util.promise.ResultHandler;
063
064/**
065 * An property mapper which provides a mapping from a JSON value to a single DN
066 * valued LDAP attribute.
067 */
068public final class ReferencePropertyMapper extends AbstractLdapPropertyMapper<ReferencePropertyMapper> {
069    /**
070     * The maximum number of candidate references to allow in search filters.
071     */
072    private static final int SEARCH_MAX_CANDIDATES = 1000;
073
074    private final DN baseDn;
075    private final Schema schema;
076    private Filter filter;
077    private final PropertyMapper mapper;
078    private final AttributeDescription primaryKey;
079    private SearchScope scope = SearchScope.WHOLE_SUBTREE;
080
081    ReferencePropertyMapper(final Schema schema, final AttributeDescription ldapAttributeName, final DN baseDn,
082                            final AttributeDescription primaryKey, final PropertyMapper mapper) {
083        super(ldapAttributeName);
084        this.schema = schema;
085        this.baseDn = baseDn;
086        this.primaryKey = primaryKey;
087        this.mapper = mapper;
088    }
089
090    /**
091     * Sets the filter which should be used when searching for referenced LDAP
092     * entries. The default is {@code (objectClass=*)}.
093     *
094     * @param filter
095     *            The filter which should be used when searching for referenced
096     *            LDAP entries.
097     * @return This property mapper.
098     */
099    public ReferencePropertyMapper searchFilter(final Filter filter) {
100        this.filter = checkNotNull(filter);
101        return this;
102    }
103
104    /**
105     * Sets the filter which should be used when searching for referenced LDAP
106     * entries. The default is {@code (objectClass=*)}.
107     *
108     * @param filter
109     *            The filter which should be used when searching for referenced
110     *            LDAP entries.
111     * @return This property mapper.
112     */
113    public ReferencePropertyMapper searchFilter(final String filter) {
114        return searchFilter(Filter.valueOf(filter));
115    }
116
117    /**
118     * Sets the search scope which should be used when searching for referenced
119     * LDAP entries. The default is {@link SearchScope#WHOLE_SUBTREE}.
120     *
121     * @param scope
122     *            The search scope which should be used when searching for
123     *            referenced LDAP entries.
124     * @return This property mapper.
125     */
126    public ReferencePropertyMapper searchScope(final SearchScope scope) {
127        this.scope = checkNotNull(scope);
128        return this;
129    }
130
131    @Override
132    public String toString() {
133        return "reference(" + ldapAttributeName + ")";
134    }
135
136    @Override
137    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
138                                                     final JsonPointer path, final JsonPointer subPath,
139                                                     final FilterType type, final String operator,
140                                                     final Object valueAssertion) {
141        return mapper.getLdapFilter(connection, resource, path, subPath, type, operator, valueAssertion)
142                .thenAsync(new AsyncFunction<Filter, Filter, ResourceException>() {
143                    @Override
144                    public Promise<Filter, ResourceException> apply(final Filter result) {
145                        // Search for all referenced entries and construct a filter.
146                        final SearchRequest request = createSearchRequest(result);
147                        final List<Filter> subFilters = new LinkedList<>();
148
149                        return connection.searchAsync(request, new SearchResultHandler() {
150                            @Override
151                            public boolean handleEntry(final SearchResultEntry entry) {
152                                if (subFilters.size() < SEARCH_MAX_CANDIDATES) {
153                                    subFilters.add(Filter.equality(ldapAttributeName.toString(), entry.getName()));
154                                    return true;
155                                } else {
156                                    // No point in continuing - maximum candidates reached.
157                                    return false;
158                                }
159                            }
160                            @Override
161                            public boolean handleReference(final SearchResultReference reference) {
162                                // Ignore references.
163                                return true;
164                            }
165                        }).then(new Function<Result, Filter, ResourceException>() {
166                            @Override
167                            public Filter apply(Result result) throws ResourceException {
168                                if (subFilters.size() >= SEARCH_MAX_CANDIDATES) {
169                                    throw asResourceException(newLdapException(ADMIN_LIMIT_EXCEEDED));
170                                } else if (subFilters.size() == 1) {
171                                    return subFilters.get(0);
172                                } else {
173                                    return Filter.or(subFilters);
174                                }
175                            }
176                        }, new Function<LdapException, Filter, ResourceException>() {
177                            @Override
178                            public Filter apply(LdapException exception) throws ResourceException {
179                                throw asResourceException(exception);
180                            }
181                        });
182                    }
183                });
184    }
185
186    @Override
187    Promise<Attribute, ResourceException> getNewLdapAttributes(final Connection connection, final Resource resource,
188                                                               final JsonPointer path, final List<Object> newValues) {
189        /*
190         * For each value use the subordinate mapper to obtain the LDAP primary
191         * key, the perform a search for each one to find the corresponding entries.
192         */
193        final Attribute newLDAPAttribute = new LinkedAttribute(ldapAttributeName);
194        final AtomicInteger pendingSearches = new AtomicInteger(newValues.size());
195        final AtomicReference<ResourceException> exception = new AtomicReference<>();
196        final PromiseImpl<Attribute, ResourceException> promise = PromiseImpl.create();
197
198        for (final Object value : newValues) {
199            mapper.create(connection, resource, path, new JsonValue(value))
200                  .thenOnResult(new ResultHandler<List<Attribute>>() {
201                      @Override
202                      public void handleResult(List<Attribute> result) {
203                          Attribute primaryKeyAttribute = null;
204                          for (final Attribute attribute : result) {
205                              if (attribute.getAttributeDescription().equals(primaryKey)) {
206                                  primaryKeyAttribute = attribute;
207                                  break;
208                              }
209                          }
210
211                          if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
212                              promise.handleException(newBadRequestException(
213                                      ERR_REFERENCE_FIELD_NO_PRIMARY_KEY.get(path)));
214                              return;
215                          }
216
217                          if (primaryKeyAttribute.size() > 1) {
218                              promise.handleException(
219                                      newBadRequestException(ERR_REFERENCE_FIELD_MULTIPLE_PRIMARY_KEYS.get(path)));
220                              return;
221                          }
222
223                          // Now search for the referenced entry in to get its DN.
224                          final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
225                          final Filter filter = Filter.equality(primaryKey.toString(), primaryKeyValue);
226                          final SearchRequest search = createSearchRequest(filter);
227                          connection.searchSingleEntryAsync(search)
228                                    .thenOnResult(new ResultHandler<SearchResultEntry>() {
229                                        @Override
230                                        public void handleResult(final SearchResultEntry result) {
231                                            synchronized (newLDAPAttribute) {
232                                                newLDAPAttribute.add(result.getName());
233                                            }
234                                            completeIfNecessary();
235                                        }
236                                    })
237                                    .thenOnException(new ExceptionHandler<LdapException>() {
238                                        @Override
239                                        public void handleException(final LdapException error) {
240                                            ResourceException re;
241                                            try {
242                                                throw error;
243                                            } catch (final EntryNotFoundException e) {
244                                                re = newBadRequestException(
245                                                        ERR_REFERENCE_FIELD_DOES_NOT_EXIST.get(primaryKeyValue, path));
246                                            } catch (final MultipleEntriesFoundException e) {
247                                                re = newBadRequestException(
248                                                        ERR_REFERENCE_FIELD_AMBIGUOUS.get(primaryKeyValue, path));
249                                            } catch (final LdapException e) {
250                                                re = asResourceException(e);
251                                            }
252                                            exception.compareAndSet(null, re);
253                                            completeIfNecessary();
254                                        }
255                                    });
256                      }
257
258                      private void completeIfNecessary() {
259                          if (pendingSearches.decrementAndGet() == 0) {
260                              if (exception.get() != null) {
261                                  promise.handleException(exception.get());
262                              } else {
263                                  promise.handleResult(newLDAPAttribute);
264                              }
265                          }
266                      }
267                  });
268        }
269        return promise;
270    }
271
272    @Override
273    ReferencePropertyMapper getThis() {
274        return this;
275    }
276
277    @SuppressWarnings("fallthrough")
278    @Override
279    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
280                                               final JsonPointer path, final Entry e) {
281        final Set<DN> dns = e.parseAttribute(ldapAttributeName).usingSchema(schema).asSetOfDN();
282        switch (dns.size()) {
283        case 0:
284            return newResultPromise(null);
285        case 1:
286            if (attributeIsSingleValued()) {
287                try {
288                    return readEntry(connection, resource, path, dns.iterator().next());
289                } catch (final Exception ex) {
290                    // The LDAP attribute could not be decoded.
291                    return Promises.newExceptionPromise(asResourceException(ex));
292                }
293            }
294            // Fall-though: unexpectedly got multiple values. It's probably best to just return them.
295        default:
296            try {
297                final List<Promise<JsonValue, ResourceException>> promises = new ArrayList<>(dns.size());
298                for (final DN dn : dns) {
299                    promises.add(readEntry(connection, resource, path, dn));
300                }
301                return Promises.when(promises)
302                               .then(new Function<List<JsonValue>, JsonValue, ResourceException>() {
303                                   @Override
304                                   public JsonValue apply(final List<JsonValue> value) {
305                                       if (value.isEmpty()) {
306                                           // No values, so omit the entire JSON object from the resource.
307                                           return null;
308                                       } else {
309                                           // Combine values into a single JSON array.
310                                           final List<Object> result = new ArrayList<>(value.size());
311                                           for (final JsonValue e : value) {
312                                               if (e != null) {
313                                                   result.add(e.getObject());
314                                               }
315                                           }
316                                           return result.isEmpty() ? null : new JsonValue(result);
317                                       }
318                                   }
319                               });
320            } catch (final Exception ex) {
321                // The LDAP attribute could not be decoded.
322                return Promises.newExceptionPromise(asResourceException(ex));
323            }
324        }
325    }
326
327    private SearchRequest createSearchRequest(final Filter result) {
328        final Filter searchFilter = filter != null ? Filter.and(filter, result) : result;
329        return newSearchRequest(baseDn, scope, searchFilter, "1.1");
330    }
331
332    private Promise<JsonValue, ResourceException> readEntry(
333            final Connection connection, final Resource resource, final JsonPointer path, final DN dn) {
334        final Set<String> requestedLDAPAttributes = new LinkedHashSet<>();
335        mapper.getLdapAttributes(path, new JsonPointer(), requestedLDAPAttributes);
336
337        final Filter searchFilter = filter != null ? filter : Filter.alwaysTrue();
338        final String[] attributes = requestedLDAPAttributes.toArray(new String[requestedLDAPAttributes.size()]);
339        final SearchRequest request = newSearchRequest(dn, SearchScope.BASE_OBJECT, searchFilter, attributes);
340
341        return connection
342                .searchSingleEntryAsync(request)
343                .thenAsync(new AsyncFunction<SearchResultEntry, JsonValue, ResourceException>() {
344                    @Override
345                    public Promise<JsonValue, ResourceException> apply(final SearchResultEntry result) {
346                        return mapper.read(connection, resource, path, result);
347                    }
348                }, new AsyncFunction<LdapException, JsonValue, ResourceException>() {
349                    @Override
350                    public Promise<JsonValue, ResourceException> apply(final LdapException error) {
351                        if (error instanceof EntryNotFoundException) {
352                            // Ignore missing entry since it cannot be mapped.
353                            return Promises.newResultPromise(null);
354                        }
355                        return Promises.newExceptionPromise(asResourceException(error));
356                    }
357                });
358    }
359}