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.rest2ldap.Rest2Ldap.simple; 019import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*; 020import static org.forgerock.json.resource.PatchOperation.operation; 021import static org.forgerock.opendj.ldap.Filter.alwaysFalse; 022import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException; 023import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException; 024import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase; 025import static org.forgerock.util.Utils.joinAsString; 026import static org.forgerock.util.promise.Promises.newResultPromise; 027 028import java.util.ArrayList; 029import java.util.Arrays; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.LinkedHashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.Set; 036import java.util.TreeSet; 037 038import org.forgerock.json.JsonPointer; 039import org.forgerock.json.JsonValue; 040import org.forgerock.json.resource.PatchOperation; 041import org.forgerock.json.resource.ResourceException; 042import org.forgerock.opendj.ldap.Attribute; 043import org.forgerock.opendj.ldap.Connection; 044import org.forgerock.opendj.ldap.Entry; 045import org.forgerock.opendj.ldap.Filter; 046import org.forgerock.opendj.ldap.Modification; 047import org.forgerock.util.Function; 048import org.forgerock.util.Pair; 049import org.forgerock.util.promise.Promise; 050import org.forgerock.util.promise.Promises; 051 052/** An property mapper which maps JSON objects to LDAP attributes. */ 053public final class ObjectPropertyMapper extends PropertyMapper { 054 private static final class Mapping { 055 private final PropertyMapper mapper; 056 private final String name; 057 058 private Mapping(final String name, final PropertyMapper mapper) { 059 this.name = name; 060 this.mapper = mapper; 061 } 062 063 @Override 064 public String toString() { 065 return name + " -> " + mapper; 066 } 067 } 068 069 private final Map<String, Mapping> mappings = new LinkedHashMap<>(); 070 071 private boolean includeAllUserAttributesByDefault = false; 072 private final Set<String> excludedDefaultUserAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 073 074 ObjectPropertyMapper() { 075 // Nothing to do. 076 } 077 078 /** 079 * Creates an explicit mapping for a property contained in the JSON object. When user attributes are 080 * {@link #includeAllUserAttributesByDefault included} by default, be careful to {@link 081 * #excludedDefaultUserAttributes exclude} any attributes which have explicit mappings defined using this method, 082 * otherwise they will be duplicated in the JSON representation. 083 * 084 * @param name 085 * The name of the JSON property to be mapped. 086 * @param mapper 087 * The property mapper responsible for mapping the JSON attribute to LDAP attribute(s). 088 * @return A reference to this property mapper. 089 */ 090 public ObjectPropertyMapper property(final String name, final PropertyMapper mapper) { 091 mappings.put(toLowerCase(name), new Mapping(name, mapper)); 092 return this; 093 } 094 095 /** 096 * Specifies whether all LDAP user attributes should be mapped by default using the default schema based mapping 097 * rules. Individual attributes can be excluded using {@link #excludedDefaultUserAttributes} in order to prevent 098 * attributes with explicit mappings being mapped twice. 099 * 100 * @param include {@code true} if all LDAP user attributes be mapped by default. 101 * @return A reference to this property mapper. 102 */ 103 public ObjectPropertyMapper includeAllUserAttributesByDefault(final boolean include) { 104 this.includeAllUserAttributesByDefault = include; 105 return this; 106 } 107 108 /** 109 * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when 110 * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be 111 * excluded in order to prevent duplication. 112 * 113 * @param attributeNames The list of attributes to be excluded. 114 * @return A reference to this property mapper. 115 */ 116 public ObjectPropertyMapper excludedDefaultUserAttributes(final String... attributeNames) { 117 return excludedDefaultUserAttributes(Arrays.asList(attributeNames)); 118 } 119 120 /** 121 * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when 122 * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be 123 * excluded in order to prevent duplication. 124 * 125 * @param attributeNames The list of attributes to be excluded. 126 * @return A reference to this property mapper. 127 */ 128 public ObjectPropertyMapper excludedDefaultUserAttributes(final Collection<String> attributeNames) { 129 excludedDefaultUserAttributes.addAll(attributeNames); 130 return this; 131 } 132 133 @Override 134 public String toString() { 135 return "object(" + joinAsString(", ", mappings.values()) + ")"; 136 } 137 138 @Override 139 Promise<List<Attribute>, ResourceException> create(final Connection connection, 140 final Resource resource, final JsonPointer path, 141 final JsonValue v) { 142 try { 143 // First check that the JSON value is an object and that the fields it contains are known by this mapper. 144 final Map<String, Mapping> missingMappings = validateJsonValue(path, v); 145 146 // Accumulate the results of the subordinate mappings. 147 final List<Promise<List<Attribute>, ResourceException>> promises = new ArrayList<>(); 148 149 // Invoke mappings for which there are values provided. 150 if (v != null && !v.isNull()) { 151 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) { 152 final Mapping mapping = getMapping(me.getKey()); 153 final JsonValue subValue = new JsonValue(me.getValue()); 154 promises.add(mapping.mapper.create(connection, resource, path.child(me.getKey()), 155 subValue)); 156 } 157 } 158 159 // Invoke mappings for which there were no values provided. 160 for (final Mapping mapping : missingMappings.values()) { 161 promises.add(mapping.mapper.create(connection, resource, path.child(mapping.name), null)); 162 } 163 164 return Promises.when(promises) 165 .then(this.<Attribute> accumulateResults()); 166 } catch (final Exception e) { 167 return asResourceException(e).asPromise(); 168 } 169 } 170 171 @Override 172 void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) { 173 if (subPath.isEmpty()) { 174 // Request all subordinate mappings. 175 if (includeAllUserAttributesByDefault) { 176 ldapAttributes.add("*"); 177 // Continue because there may be explicit mappings for operational attributes. 178 } 179 for (final Mapping mapping : mappings.values()) { 180 mapping.mapper.getLdapAttributes(path.child(mapping.name), subPath, ldapAttributes); 181 } 182 } else { 183 // Request single subordinate mapping. 184 final Mapping mapping = getMappingOrNull(subPath); 185 if (mapping != null) { 186 mapping.mapper.getLdapAttributes(path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes); 187 } 188 } 189 } 190 191 @Override 192 Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource, 193 final JsonPointer path, final JsonPointer subPath, 194 final FilterType type, final String operator, 195 final Object valueAssertion) { 196 final Mapping mapping = getMappingOrNull(subPath); 197 if (mapping != null) { 198 return mapping.mapper.getLdapFilter(connection, 199 resource, 200 path.child(subPath.get(0)), 201 subPath.relativePointer(), 202 type, 203 operator, 204 valueAssertion); 205 } else { 206 /* 207 * Either the filter targeted the entire object (i.e. it was "/"), 208 * or it targeted an unrecognized attribute within the object. 209 * Either way, the filter will never match. 210 */ 211 return newResultPromise(alwaysFalse()); 212 } 213 } 214 215 @Override 216 Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource, 217 final JsonPointer path, final PatchOperation operation) { 218 try { 219 final JsonPointer field = operation.getField(); 220 final JsonValue v = operation.getValue(); 221 222 if (field.isEmpty()) { 223 /* 224 * The patch operation applies to this object. We'll handle this 225 * by allowing the JSON value to be a partial object and 226 * add/remove/replace only the provided values. 227 */ 228 validateJsonValue(path, v); 229 230 // Accumulate the results of the subordinate mappings. 231 final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>(); 232 233 // Invoke mappings for which there are values provided. 234 if (!v.isNull()) { 235 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) { 236 final Mapping mapping = getMapping(me.getKey()); 237 final JsonValue subValue = new JsonValue(me.getValue()); 238 final PatchOperation subOperation = 239 operation(operation.getOperation(), field /* empty */, subValue); 240 promises.add(mapping.mapper.patch(connection, resource, path.child(me.getKey()), subOperation)); 241 } 242 } 243 244 return Promises.when(promises) 245 .then(this.<Modification> accumulateResults()); 246 } else { 247 /* 248 * The patch operation targets a subordinate field. Create a new 249 * patch operation targeting the field and forward it to the 250 * appropriate mapper. 251 */ 252 final String fieldName = field.get(0); 253 final Mapping mapping = getMappingOrNull(fieldName); 254 if (mapping == null) { 255 throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(fieldName))); 256 } 257 final PatchOperation subOperation = 258 operation(operation.getOperation(), field.relativePointer(), v); 259 return mapping.mapper.patch(connection, resource, path.child(fieldName), subOperation); 260 } 261 } catch (final Exception e) { 262 return asResourceException(e).asPromise(); 263 } 264 } 265 266 @Override 267 Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource, 268 final JsonPointer path, final Entry e) { 269 /* 270 * Use an accumulator which will aggregate the results from the 271 * subordinate mappers into a single list. On completion, the 272 * accumulator combines the results into a single JSON map object. 273 */ 274 final List<Promise<Pair<String, JsonValue>, ResourceException>> promises = 275 new ArrayList<>(mappings.size()); 276 277 for (final Mapping mapping : mappings.values()) { 278 promises.add(mapping.mapper.read(connection, resource, path.child(mapping.name), e) 279 .then(toProperty(mapping.name))); 280 } 281 282 if (includeAllUserAttributesByDefault) { 283 // Map all user attributes using a default simple mapping. It would be nice if we could automatically 284 // detect which attributes have been mapped already using explicit mappings, but it would require us to 285 // track which attributes have been accessed in the entry. Instead, we'll rely on the user to exclude 286 // attributes which have explicit mappings. 287 for (final Attribute attribute : e.getAllAttributes()) { 288 // Don't include operational attributes. They must have explicit mappings. 289 if (attribute.getAttributeDescription().getAttributeType().isOperational()) { 290 continue; 291 } 292 // Filter out excluded attributes. 293 final String attributeName = attribute.getAttributeDescriptionAsString(); 294 if (!excludedDefaultUserAttributes.isEmpty() && excludedDefaultUserAttributes.contains(attributeName)) { 295 continue; 296 } 297 // This attribute needs to be mapped. 298 final SimplePropertyMapper mapper = simple(attribute.getAttributeDescription()); 299 promises.add(mapper.read(connection, resource, path.child(attributeName), e) 300 .then(toProperty(attributeName))); 301 } 302 } 303 304 return Promises.when(promises) 305 .then(new Function<List<Pair<String, JsonValue>>, JsonValue, ResourceException>() { 306 @Override 307 public JsonValue apply(final List<Pair<String, JsonValue>> value) { 308 if (value.isEmpty()) { 309 // No subordinate attributes, so omit the entire JSON object from the resource. 310 return null; 311 } else { 312 // Combine the sub-attributes into a single JSON object. 313 final Map<String, Object> result = new LinkedHashMap<>(value.size()); 314 for (final Pair<String, JsonValue> e : value) { 315 if (e != null) { 316 result.put(e.getFirst(), e.getSecond().getObject()); 317 } 318 } 319 return new JsonValue(result); 320 } 321 } 322 }); 323 } 324 325 private Function<JsonValue, Pair<String, JsonValue>, ResourceException> toProperty(final String name) { 326 return new Function<JsonValue, Pair<String, JsonValue>, ResourceException>() { 327 @Override 328 public Pair<String, JsonValue> apply(final JsonValue value) { 329 return value != null ? Pair.of(name, value) : null; 330 } 331 }; 332 } 333 334 @Override 335 Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource, 336 final JsonPointer path, final Entry e, final JsonValue v) { 337 try { 338 // First check that the JSON value is an object and that the fields it contains are known by this mapper. 339 final Map<String, Mapping> missingMappings = validateJsonValue(path, v); 340 341 // Accumulate the results of the subordinate mappings. 342 final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>(); 343 344 // Invoke mappings for which there are values provided. 345 if (v != null && !v.isNull()) { 346 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) { 347 final Mapping mapping = getMapping(me.getKey()); 348 final JsonValue subValue = new JsonValue(me.getValue()); 349 promises.add(mapping.mapper.update(connection, resource, path.child(me.getKey()), e, subValue)); 350 } 351 } 352 353 // Invoke mappings for which there were no values provided. 354 for (final Mapping mapping : missingMappings.values()) { 355 promises.add(mapping.mapper.update(connection, resource, path.child(mapping.name), e, null)); 356 } 357 358 return Promises.when(promises) 359 .then(this.<Modification> accumulateResults()); 360 } catch (final Exception ex) { 361 return asResourceException(ex).asPromise(); 362 } 363 } 364 365 private <T> Function<List<List<T>>, List<T>, ResourceException> accumulateResults() { 366 return new Function<List<List<T>>, List<T>, ResourceException>() { 367 @Override 368 public List<T> apply(final List<List<T>> value) { 369 switch (value.size()) { 370 case 0: 371 return Collections.emptyList(); 372 case 1: 373 return value.get(0); 374 default: 375 final List<T> attributes = new ArrayList<>(value.size()); 376 for (final List<T> a : value) { 377 attributes.addAll(a); 378 } 379 return attributes; 380 } 381 } 382 }; 383 } 384 385 /** Fail immediately if the JSON value has the wrong type or contains unknown attributes. */ 386 private Map<String, Mapping> validateJsonValue(final JsonPointer path, final JsonValue v) throws ResourceException { 387 final Map<String, Mapping> missingMappings = new LinkedHashMap<>(mappings); 388 if (v != null && !v.isNull()) { 389 if (v.isMap()) { 390 for (final String attribute : v.asMap().keySet()) { 391 if (missingMappings.remove(toLowerCase(attribute)) == null 392 && !isIncludedDefaultUserAttribute(attribute)) { 393 throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(attribute))); 394 } 395 } 396 } else { 397 throw newBadRequestException(ERR_FIELD_WRONG_TYPE.get(path)); 398 } 399 } 400 return missingMappings; 401 } 402 403 private Mapping getMappingOrNull(final JsonPointer jsonAttribute) { 404 return jsonAttribute.isEmpty() ? null : getMappingOrNull(jsonAttribute.get(0)); 405 } 406 407 private Mapping getMappingOrNull(final String jsonAttribute) { 408 final Mapping mapping = mappings.get(toLowerCase(jsonAttribute)); 409 if (mapping != null) { 410 return mapping; 411 } 412 if (isIncludedDefaultUserAttribute(jsonAttribute)) { 413 return new Mapping(jsonAttribute, simple(jsonAttribute)); 414 } 415 return null; 416 } 417 418 private Mapping getMapping(final String jsonAttribute) { 419 final Mapping mappingOrNull = getMappingOrNull(jsonAttribute); 420 if (mappingOrNull != null) { 421 return mappingOrNull; 422 } 423 throw new IllegalStateException("Unexpected null mapping for jsonAttribute: " + jsonAttribute); 424 } 425 426 private boolean isIncludedDefaultUserAttribute(final String attributeName) { 427 return includeAllUserAttributesByDefault 428 && (excludedDefaultUserAttributes.isEmpty() || !excludedDefaultUserAttributes.contains(attributeName)); 429 } 430}