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}