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