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-2015 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap;
017
018import static org.forgerock.opendj.ldap.LdapException.newLdapException;
019import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
020import static org.forgerock.opendj.rest2ldap.Rest2LDAP.asResourceException;
021import static org.forgerock.opendj.rest2ldap.Utils.ensureNotNull;
022import static org.forgerock.opendj.rest2ldap.Utils.i18n;
023
024import java.util.ArrayList;
025import java.util.LinkedHashSet;
026import java.util.LinkedList;
027import java.util.List;
028import java.util.Set;
029import java.util.concurrent.atomic.AtomicInteger;
030import java.util.concurrent.atomic.AtomicReference;
031
032import org.forgerock.json.JsonPointer;
033import org.forgerock.json.JsonValue;
034import org.forgerock.json.resource.BadRequestException;
035import org.forgerock.json.resource.ResourceException;
036import org.forgerock.opendj.ldap.Attribute;
037import org.forgerock.opendj.ldap.AttributeDescription;
038import org.forgerock.opendj.ldap.ByteString;
039import org.forgerock.opendj.ldap.Connection;
040import org.forgerock.opendj.ldap.DN;
041import org.forgerock.opendj.ldap.Entry;
042import org.forgerock.opendj.ldap.EntryNotFoundException;
043import org.forgerock.opendj.ldap.Filter;
044import org.forgerock.opendj.ldap.LdapException;
045import org.forgerock.opendj.ldap.LinkedAttribute;
046import org.forgerock.opendj.ldap.MultipleEntriesFoundException;
047import org.forgerock.opendj.ldap.ResultCode;
048import org.forgerock.opendj.ldap.SearchResultHandler;
049import org.forgerock.opendj.ldap.SearchScope;
050import org.forgerock.opendj.ldap.requests.SearchRequest;
051import org.forgerock.opendj.ldap.responses.Result;
052import org.forgerock.opendj.ldap.responses.SearchResultEntry;
053import org.forgerock.opendj.ldap.responses.SearchResultReference;
054import org.forgerock.util.AsyncFunction;
055import org.forgerock.util.Function;
056import org.forgerock.util.promise.ExceptionHandler;
057import org.forgerock.util.promise.Promise;
058import org.forgerock.util.promise.PromiseImpl;
059import org.forgerock.util.promise.Promises;
060import org.forgerock.util.promise.ResultHandler;
061
062/**
063 * An attribute mapper which provides a mapping from a JSON value to a single DN
064 * valued LDAP attribute.
065 */
066public final class ReferenceAttributeMapper extends AbstractLDAPAttributeMapper<ReferenceAttributeMapper> {
067    /**
068     * The maximum number of candidate references to allow in search filters.
069     */
070    private static final int SEARCH_MAX_CANDIDATES = 1000;
071
072    private final DN baseDN;
073    private Filter filter;
074    private final AttributeMapper mapper;
075    private final AttributeDescription primaryKey;
076    private SearchScope scope = SearchScope.WHOLE_SUBTREE;
077
078    ReferenceAttributeMapper(final AttributeDescription ldapAttributeName, final DN baseDN,
079        final AttributeDescription primaryKey, final AttributeMapper mapper) {
080        super(ldapAttributeName);
081        this.baseDN = baseDN;
082        this.primaryKey = primaryKey;
083        this.mapper = mapper;
084    }
085
086    /**
087     * Sets the filter which should be used when searching for referenced LDAP
088     * entries. The default is {@code (objectClass=*)}.
089     *
090     * @param filter
091     *            The filter which should be used when searching for referenced
092     *            LDAP entries.
093     * @return This attribute mapper.
094     */
095    public ReferenceAttributeMapper searchFilter(final Filter filter) {
096        this.filter = ensureNotNull(filter);
097        return this;
098    }
099
100    /**
101     * Sets the filter which should be used when searching for referenced LDAP
102     * entries. The default is {@code (objectClass=*)}.
103     *
104     * @param filter
105     *            The filter which should be used when searching for referenced
106     *            LDAP entries.
107     * @return This attribute mapper.
108     */
109    public ReferenceAttributeMapper searchFilter(final String filter) {
110        return searchFilter(Filter.valueOf(filter));
111    }
112
113    /**
114     * Sets the search scope which should be used when searching for referenced
115     * LDAP entries. The default is {@link SearchScope#WHOLE_SUBTREE}.
116     *
117     * @param scope
118     *            The search scope which should be used when searching for
119     *            referenced LDAP entries.
120     * @return This attribute mapper.
121     */
122    public ReferenceAttributeMapper searchScope(final SearchScope scope) {
123        this.scope = ensureNotNull(scope);
124        return this;
125    }
126
127    @Override
128    public String toString() {
129        return "reference(" + ldapAttributeName + ")";
130    }
131
132    @Override
133    Promise<Filter, ResourceException> getLDAPFilter(final RequestState requestState, final JsonPointer path,
134            final JsonPointer subPath, final FilterType type, final String operator, final Object valueAssertion) {
135
136        return mapper.getLDAPFilter(requestState, path, subPath, type, operator, valueAssertion)
137                .thenAsync(new AsyncFunction<Filter, Filter, ResourceException>() {
138                    @Override
139                    public Promise<Filter, ResourceException> apply(final Filter result) {
140                        // Search for all referenced entries and construct a filter.
141                        final SearchRequest request = createSearchRequest(result);
142                        final List<Filter> subFilters = new LinkedList<>();
143
144                        return requestState.getConnection().thenAsync(
145                                new AsyncFunction<Connection, Filter, ResourceException>() {
146                                    @Override
147                                    public Promise<Filter, ResourceException> apply(final Connection connection)
148                                            throws ResourceException {
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(
154                                                            ldapAttributeName.toString(), entry.getName()));
155                                                    return true;
156                                                } else {
157                                                    // No point in continuing - maximum candidates reached.
158                                                    return false;
159                                                }
160                                            }
161
162                                            @Override
163                                            public boolean handleReference(final SearchResultReference reference) {
164                                                // Ignore references.
165                                                return true;
166                                            }
167                                        }).then(new Function<Result, Filter, ResourceException>() {
168                                            @Override
169                                            public Filter apply(Result result) throws ResourceException {
170                                                if (subFilters.size() >= SEARCH_MAX_CANDIDATES) {
171                                                    throw asResourceException(
172                                                            newLdapException(ResultCode.ADMIN_LIMIT_EXCEEDED));
173                                                } else if (subFilters.size() == 1) {
174                                                    return subFilters.get(0);
175                                                } else {
176                                                    return Filter.or(subFilters);
177                                                }
178                                            }
179                                        }, new Function<LdapException, Filter, ResourceException>() {
180                                            @Override
181                                            public Filter apply(LdapException exception) throws ResourceException {
182                                                throw asResourceException(exception);
183                                            }
184                                        });
185                                    }
186                                });
187                    }
188                });
189    }
190
191    @Override
192    Promise<Attribute, ResourceException> getNewLDAPAttributes(
193            final RequestState requestState, final JsonPointer path, final List<Object> newValues) {
194        /*
195         * For each value use the subordinate mapper to obtain the LDAP primary
196         * key, the perform a search for each one to find the corresponding entries.
197         */
198        final Attribute newLDAPAttribute = new LinkedAttribute(ldapAttributeName);
199        final AtomicInteger pendingSearches = new AtomicInteger(newValues.size());
200        final AtomicReference<ResourceException> exception = new AtomicReference<>();
201        final PromiseImpl<Attribute, ResourceException> promise = PromiseImpl.create();
202
203        for (final Object value : newValues) {
204            mapper.create(requestState, path, new JsonValue(value)).thenOnResult(new ResultHandler<List<Attribute>>() {
205                @Override
206                public void handleResult(List<Attribute> result) {
207                    Attribute primaryKeyAttribute = null;
208                    for (final Attribute attribute : result) {
209                        if (attribute.getAttributeDescription().equals(primaryKey)) {
210                            primaryKeyAttribute = attribute;
211                            break;
212                        }
213                    }
214
215                    if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
216                        promise.handleException(new BadRequestException(
217                                i18n("The request cannot be processed because the reference field '%s' contains "
218                                        + "a value which does not contain a primary key", path)));
219                    }
220
221                    if (primaryKeyAttribute.size() > 1) {
222                        promise.handleException(new BadRequestException(
223                                i18n("The request cannot be processed because the reference field '%s' contains "
224                                        + "a value which contains multiple primary keys", path)));
225                    }
226
227                    // Now search for the referenced entry in to get its DN.
228                    final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
229                    final Filter filter = Filter.equality(primaryKey.toString(), primaryKeyValue);
230                    final SearchRequest search = createSearchRequest(filter);
231                    requestState.getConnection().thenOnResult(new ResultHandler<Connection>() {
232                        @Override
233                        public void handleResult(Connection connection) {
234                            connection.searchSingleEntryAsync(search)
235                                      .thenOnResult(new ResultHandler<SearchResultEntry>() {
236                                          @Override
237                                          public void handleResult(final SearchResultEntry result) {
238                                              synchronized (newLDAPAttribute) {
239                                                  newLDAPAttribute.add(result.getName());
240                                              }
241                                              completeIfNecessary();
242                                          }
243                                      }).thenOnException(new ExceptionHandler<LdapException>() {
244                                          @Override
245                                          public void handleException(final LdapException error) {
246                                              ResourceException re;
247                                              try {
248                                                  throw error;
249                                              } catch (final EntryNotFoundException e) {
250                                                  re = new BadRequestException(i18n(
251                                                          "The request cannot be processed because the resource "
252                                                          + "'%s' referenced in field '%s' does not exist",
253                                                          primaryKeyValue.toString(), path));
254                                              } catch (final MultipleEntriesFoundException e) {
255                                                  re = new BadRequestException(i18n(
256                                                          "The request cannot be processed because the resource "
257                                                          + "'%s' referenced in field '%s' is ambiguous",
258                                                          primaryKeyValue.toString(), path));
259                                              } catch (final LdapException e) {
260                                                  re = asResourceException(e);
261                                              }
262                                              exception.compareAndSet(null, re);
263                                              completeIfNecessary();
264                                          }
265                                      });
266                        }
267                    });
268                }
269
270                private void completeIfNecessary() {
271                    if (pendingSearches.decrementAndGet() == 0) {
272                        if (exception.get() != null) {
273                            promise.handleException(exception.get());
274                        } else {
275                            promise.handleResult(newLDAPAttribute);
276                        }
277                    }
278                }
279            });
280        }
281        return promise;
282    }
283
284    @Override
285    ReferenceAttributeMapper getThis() {
286        return this;
287    }
288
289    @Override
290    Promise<JsonValue, ResourceException> read(final RequestState c, final JsonPointer path, final Entry e) {
291        final Attribute attribute = e.getAttribute(ldapAttributeName);
292        if (attribute == null || attribute.isEmpty()) {
293            return Promises.newResultPromise(null);
294        } else if (attributeIsSingleValued()) {
295            try {
296                final DN dn = attribute.parse().usingSchema(c.getConfig().schema()).asDN();
297                return readEntry(c, path, dn);
298            } catch (final Exception ex) {
299                // The LDAP attribute could not be decoded.
300                return Promises.newExceptionPromise(asResourceException(ex));
301            }
302        } else {
303            try {
304                final Set<DN> dns = attribute.parse().usingSchema(c.getConfig().schema()).asSetOfDN();
305
306                final List<Promise<JsonValue, ResourceException>> promises = new ArrayList<>(dns.size());
307                for (final DN dn : dns) {
308                    promises.add(readEntry(c, path, dn));
309                }
310
311                return Promises.when(promises)
312                               .then(new Function<List<JsonValue>, JsonValue, ResourceException>() {
313                                   @Override
314                                   public JsonValue apply(final List<JsonValue> value) {
315                                       if (value.isEmpty()) {
316                                           // No values, so omit the entire JSON object from the resource.
317                                           return null;
318                                       } else {
319                                           // Combine values into a single JSON array.
320                                           final List<Object> result = new ArrayList<>(value.size());
321                                           for (final JsonValue e : value) {
322                                               if (e != null) {
323                                                   result.add(e.getObject());
324                                               }
325                                           }
326                                           return result.isEmpty() ? null : new JsonValue(result);
327                                       }
328                                   }
329                               });
330            } catch (final Exception ex) {
331                // The LDAP attribute could not be decoded.
332                return Promises.newExceptionPromise(asResourceException(ex));
333            }
334        }
335    }
336
337    private SearchRequest createSearchRequest(final Filter result) {
338        final Filter searchFilter = filter != null ? Filter.and(filter, result) : result;
339        return newSearchRequest(baseDN, scope, searchFilter, "1.1");
340    }
341
342    private Promise<JsonValue, ResourceException> readEntry(
343            final RequestState requestState, final JsonPointer path, final DN dn) {
344        final Set<String> requestedLDAPAttributes = new LinkedHashSet<>();
345        mapper.getLDAPAttributes(requestState, path, new JsonPointer(), requestedLDAPAttributes);
346        return requestState.getConnection().thenAsync(new AsyncFunction<Connection, JsonValue, ResourceException>() {
347            @Override
348            public Promise<JsonValue, ResourceException> apply(Connection connection) throws ResourceException {
349                final Filter searchFilter = filter != null ? filter : Filter.alwaysTrue();
350                final String[] attributes = requestedLDAPAttributes.toArray(new String[requestedLDAPAttributes.size()]);
351                final SearchRequest request = newSearchRequest(dn, SearchScope.BASE_OBJECT, searchFilter, attributes);
352
353                return connection.searchSingleEntryAsync(request)
354                        .thenAsync(new AsyncFunction<SearchResultEntry, JsonValue, ResourceException>() {
355                            @Override
356                            public Promise<JsonValue, ResourceException> apply(final SearchResultEntry result) {
357                                return mapper.read(requestState, path, result);
358                            }
359                        }, new AsyncFunction<LdapException, JsonValue, ResourceException>() {
360                            @Override
361                            public Promise<JsonValue, ResourceException> apply(final LdapException error) {
362                                if (error instanceof EntryNotFoundException) {
363                                    // Ignore missing entry since it cannot be mapped.
364                                    return Promises.newResultPromise(null);
365                                }
366                                return Promises.newExceptionPromise(asResourceException(error));
367                            }
368                        });
369            }
370        });
371    }
372}