001/* 002 * (C) Copyright 2017-2018 Nuxeo (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Funsho David 018 * 019 */ 020 021package org.nuxeo.directory.mongodb; 022 023import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_ID; 024import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_SEQ; 025 026import java.io.Serializable; 027import java.util.ArrayList; 028import java.util.Calendar; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.LinkedList; 032import java.util.List; 033import java.util.Map; 034import java.util.Set; 035import java.util.regex.Pattern; 036import java.util.stream.Collectors; 037 038import org.apache.commons.lang3.StringUtils; 039import org.bson.Document; 040import org.bson.conversions.Bson; 041import org.nuxeo.ecm.core.api.DocumentModel; 042import org.nuxeo.ecm.core.api.DocumentModelList; 043import org.nuxeo.ecm.core.api.PropertyException; 044import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl; 045import org.nuxeo.ecm.core.api.model.Property; 046import org.nuxeo.ecm.core.api.security.SecurityConstants; 047import org.nuxeo.ecm.core.schema.types.Field; 048import org.nuxeo.ecm.core.schema.types.Type; 049import org.nuxeo.ecm.core.schema.types.primitives.IntegerType; 050import org.nuxeo.ecm.core.schema.types.primitives.LongType; 051import org.nuxeo.ecm.core.schema.types.primitives.StringType; 052import org.nuxeo.ecm.directory.BaseSession; 053import org.nuxeo.ecm.directory.DirectoryException; 054import org.nuxeo.ecm.directory.OperationNotAllowedException; 055import org.nuxeo.ecm.directory.PasswordHelper; 056import org.nuxeo.ecm.directory.Reference; 057import org.nuxeo.ecm.directory.Session; 058import org.nuxeo.runtime.api.Framework; 059import org.nuxeo.runtime.mongodb.MongoDBConnectionHelper; 060import org.nuxeo.runtime.mongodb.MongoDBConnectionService; 061 062import com.mongodb.MongoWriteException; 063import com.mongodb.client.FindIterable; 064import com.mongodb.client.MongoCollection; 065import com.mongodb.client.MongoDatabase; 066import com.mongodb.client.model.FindOneAndUpdateOptions; 067import com.mongodb.client.model.ReturnDocument; 068import com.mongodb.client.model.Updates; 069import com.mongodb.client.result.DeleteResult; 070import com.mongodb.client.result.UpdateResult; 071 072/** 073 * MongoDB implementation of a {@link Session} 074 * 075 * @since 9.1 076 */ 077public class MongoDBSession extends BaseSession { 078 079 /** 080 * Prefix used to retrieve a MongoDB connection from {@link MongoDBConnectionService}. 081 * <p /> 082 * The connection id will be {@code directory/[DIRECTORY_NAME]}. 083 */ 084 public static final String DIRECTORY_CONNECTION_PREFIX = "directory/"; 085 086 protected final MongoDatabase database; 087 088 protected String countersCollectionName; 089 090 public MongoDBSession(MongoDBDirectory directory) { 091 super(directory, MongoDBReference.class); 092 MongoDBConnectionService mongoService = Framework.getService(MongoDBConnectionService.class); 093 database = mongoService.getDatabase(DIRECTORY_CONNECTION_PREFIX + directory.getDescriptor().name); 094 countersCollectionName = directory.getCountersCollectionName(); 095 } 096 097 @Override 098 public MongoDBDirectory getDirectory() { 099 return (MongoDBDirectory) directory; 100 } 101 102 @Override 103 public DocumentModel getEntryFromSource(String id, boolean fetchReferences) throws DirectoryException { 104 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 105 DocumentModelList result = doQuery(Collections.singletonMap(idFieldName, id), Collections.emptySet(), 106 Collections.emptyMap(), true, 1, 0, false); 107 108 if (result.isEmpty()) { 109 return null; 110 } 111 112 DocumentModel docModel = result.get(0); 113 114 if (isMultiTenant()) { 115 // check that the entry is from the current tenant, or no tenant 116 // at all 117 if (!checkEntryTenantId((String) docModel.getProperty(schemaName, TENANT_ID_FIELD))) { 118 return null; 119 } 120 } 121 return docModel; 122 } 123 124 @Override 125 protected DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) { 126 // Make a copy of fieldMap to avoid modifying it 127 fieldMap = new HashMap<>(fieldMap); 128 129 // Filter out reference fields for creation as we keep it in a different collection 130 Map<String, Object> newDocMap = fieldMap.entrySet() 131 .stream() 132 .filter(entry -> getDirectory().getReferences(entry.getKey()) == null) 133 .collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue()), 134 HashMap::putAll); 135 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 136 String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName(); 137 String id = String.valueOf(fieldMap.get(idFieldName)); 138 if (autoincrementId) { 139 Document filter = MongoDBSerializationHelper.fieldMapToBson(MONGODB_ID, directoryName); 140 Bson update = Updates.inc(MONGODB_SEQ, 1L); 141 FindOneAndUpdateOptions options = new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER); 142 Long longId = getCollection(countersCollectionName).findOneAndUpdate(filter, update, options) 143 .getLong(MONGODB_SEQ); 144 fieldMap.put(idFieldName, longId); 145 newDocMap.put(idFieldName, longId); 146 id = String.valueOf(longId); 147 } 148 149 if (isMultiTenant()) { 150 String tenantId = getCurrentTenantId(); 151 if (StringUtils.isNotBlank(tenantId)) { 152 fieldMap.put(TENANT_ID_FIELD, tenantId); 153 newDocMap.put(TENANT_ID_FIELD, tenantId); 154 if (computeMultiTenantId) { 155 id = computeMultiTenantDirectoryId(tenantId, id); 156 fieldMap.put(idFieldName, id); 157 newDocMap.put(idFieldName, id); 158 } 159 } 160 } 161 162 // Check if the entry already exists 163 if (hasEntry(String.valueOf(id))) { 164 throw new DirectoryException(String.format("Entry with id %s already exists", id)); 165 } 166 167 try { 168 169 for (Map.Entry<String, Field> entry : schemaFieldMap.entrySet()) { 170 Field field = entry.getValue(); 171 if (field != null) { 172 String fieldName = field.getName().getPrefixedName(); 173 Object value = newDocMap.get(fieldName); 174 Type type = field.getType(); 175 if (value instanceof String) { 176 String v = (String) value; 177 if (type instanceof IntegerType) { 178 newDocMap.put(fieldName, Integer.valueOf(v)); 179 } else if (type instanceof LongType) { 180 newDocMap.put(fieldName, Long.valueOf(v)); 181 } 182 } else if (value instanceof Number) { 183 if (type instanceof LongType && value instanceof Integer) { 184 newDocMap.put(fieldName, Long.valueOf((Integer) value)); 185 } else if (type instanceof StringType) { 186 newDocMap.put(fieldName, value.toString()); 187 } 188 } 189 // Load default values if defined and not present in the map 190 if (!newDocMap.containsKey(fieldName)) { 191 Object defaultValue = field.getDefaultValue(); 192 if (defaultValue != null) { 193 newDocMap.put(fieldName, defaultValue); 194 } 195 } 196 } 197 } 198 Document bson = MongoDBSerializationHelper.fieldMapToBson(newDocMap); 199 String password = (String) newDocMap.get(getPasswordField()); 200 if (password != null && !PasswordHelper.isHashed(password)) { 201 password = PasswordHelper.hashPassword(password, passwordHashAlgorithm); 202 bson.append(getPasswordField(), password); 203 } 204 getCollection().insertOne(bson); 205 } catch (MongoWriteException e) { 206 throw new DirectoryException(e); 207 } 208 return createEntryModel(null, schemaName, String.valueOf(fieldMap.get(idFieldName)), fieldMap, isReadOnly()); 209 } 210 211 @Override 212 protected List<String> updateEntryWithoutReferences(DocumentModel docModel) throws DirectoryException { 213 Map<String, Object> fieldMap = new HashMap<>(); 214 List<String> referenceFieldList = new LinkedList<>(); 215 216 if (isMultiTenant()) { 217 // can only update entry from the current tenant 218 String tenantId = getCurrentTenantId(); 219 if (StringUtils.isNotBlank(tenantId)) { 220 String entryTenantId = (String) docModel.getProperty(schemaName, TENANT_ID_FIELD); 221 if (StringUtils.isBlank(entryTenantId) || !entryTenantId.equals(tenantId)) { 222 throw new OperationNotAllowedException("Operation not allowed in the current tenant context", 223 "label.directory.error.multi.tenant.operationNotAllowed", null); 224 } 225 } 226 } 227 228 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 229 for (String fieldName : schemaFieldMap.keySet()) { 230 if (fieldName.equals(getIdField())) { 231 continue; 232 } 233 Property prop = docModel.getPropertyObject(schemaName, fieldName); 234 if (prop == null || !prop.isDirty() 235 || (fieldName.equals(getPasswordField()) && StringUtils.isEmpty((String) prop.getValue()))) { 236 continue; 237 } 238 if (getDirectory().isReference(fieldName)) { 239 referenceFieldList.add(fieldName); 240 } else { 241 Serializable value = prop.getValue(); 242 if (fieldName.equals(getPasswordField())) { 243 value = PasswordHelper.hashPassword((String) value, passwordHashAlgorithm); 244 } 245 if (value instanceof Calendar) { 246 value = ((Calendar) value).getTime(); 247 } 248 fieldMap.put(prop.getName(), value); 249 } 250 } 251 252 String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName(); 253 String id = docModel.getId(); 254 Document bson = MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id); 255 256 List<Bson> updates = fieldMap.entrySet().stream().map(e -> Updates.set(e.getKey(), e.getValue())).collect( 257 Collectors.toList()); 258 259 if (!updates.isEmpty()) { 260 try { 261 UpdateResult result = getCollection().updateOne(bson, Updates.combine(updates)); 262 // Throw an error if no document matched the update 263 if (!result.wasAcknowledged()) { 264 throw new DirectoryException( 265 "Error while updating the entry, the request has not been acknowledged by the server"); 266 } 267 if (result.getMatchedCount() == 0) { 268 throw new DirectoryException( 269 String.format("Error while updating the entry, no document was found with the id %s", id)); 270 } 271 } catch (MongoWriteException e) { 272 throw new DirectoryException(e); 273 } 274 } 275 return referenceFieldList; 276 } 277 278 @Override 279 public void deleteEntryWithoutReferences(String id) throws DirectoryException { 280 try { 281 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 282 DeleteResult result = getCollection().deleteOne( 283 MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id)); 284 if (!result.wasAcknowledged()) { 285 throw new DirectoryException( 286 "Error while deleting the entry, the request has not been acknowledged by the server"); 287 } 288 } catch (MongoWriteException e) { 289 throw new DirectoryException(e); 290 } 291 } 292 293 @Override 294 public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy, 295 boolean fetchReferences, int limit, int offset) throws DirectoryException { 296 return doQuery(filter, fulltext, orderBy, fetchReferences, limit, offset, true); 297 } 298 299 protected DocumentModelList doQuery(Map<String, Serializable> filter, Set<String> fulltext, 300 Map<String, String> orderBy, boolean fetchReferences, int limit, int offset, boolean checkTenantId) 301 throws DirectoryException { 302 303 if (!hasPermission(SecurityConstants.READ)) { 304 return new DocumentModelListImpl(); 305 } 306 307 Map<String, Serializable> filterMap = new HashMap<>(filter); 308 309 if (checkTenantId && isMultiTenant()) { 310 // filter entries on the tenantId field also 311 String tenantId = getCurrentTenantId(); 312 if (StringUtils.isNotBlank(tenantId)) { 313 filterMap.put(TENANT_ID_FIELD, tenantId); 314 } 315 } 316 317 // Remove password as it is not possible to do queries with it 318 filterMap.remove(getPasswordField()); 319 Document bson = buildQuery(filterMap, fulltext); 320 321 DocumentModelList entries = new DocumentModelListImpl(); 322 323 FindIterable<Document> results = getCollection().find(bson).skip(offset); 324 if (limit > 0) { 325 results.limit(limit); 326 } 327 for (Document resultDoc : results) { 328 329 // Cast object to document model 330 Map<String, Object> fieldMap = MongoDBSerializationHelper.bsonToFieldMap(resultDoc); 331 // Remove password from results 332 if (!readAllColumns) { 333 fieldMap.remove(getPasswordField()); 334 } 335 DocumentModel doc = fieldMapToDocumentModel(fieldMap); 336 337 if (fetchReferences) { 338 Map<String, List<String>> targetIdsMap = new HashMap<>(); 339 for (Reference reference : directory.getReferences()) { 340 List<String> targetIds; 341 if (reference instanceof MongoDBReference) { 342 MongoDBReference mongoReference = (MongoDBReference) reference; 343 targetIds = mongoReference.getTargetIdsForSource(doc.getId(), this); 344 } else { 345 targetIds = reference.getTargetIdsForSource(doc.getId()); 346 } 347 targetIds = new ArrayList<>(targetIds); 348 Collections.sort(targetIds); 349 String fieldName = reference.getFieldName(); 350 targetIdsMap.computeIfAbsent(fieldName, key -> new ArrayList<>()).addAll(targetIds); 351 } 352 for (Map.Entry<String, List<String>> entry : targetIdsMap.entrySet()) { 353 String fieldName = entry.getKey(); 354 List<String> targetIds = entry.getValue(); 355 try { 356 doc.setProperty(schemaName, fieldName, targetIds); 357 } catch (PropertyException e) { 358 throw new DirectoryException(e); 359 } 360 } 361 } 362 entries.add(doc); 363 } 364 365 if (orderBy != null && !orderBy.isEmpty()) { 366 getDirectory().orderEntries(entries, orderBy); 367 } 368 369 return entries; 370 } 371 372 protected Document buildQuery(Map<String, Serializable> fieldMap, Set<String> fulltext) { 373 Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap(); 374 Document bson = new Document(); 375 for (Map.Entry<String, Serializable> entry : fieldMap.entrySet()) { 376 Field field = schemaFieldMap.entrySet() 377 .stream() 378 .filter(e -> e.getValue().getName().getPrefixedName().equals(entry.getKey())) 379 .map(Map.Entry::getValue) 380 .findFirst() 381 .orElse(null); 382 383 Serializable v = entry.getValue(); 384 Object value = (field != null) ? MongoDBSerializationHelper.valueToBson(v, field.getType()) 385 : MongoDBSerializationHelper.valueToBson(v); 386 String key = entry.getKey(); 387 if (fulltext != null && fulltext.contains(key)) { 388 String val = String.valueOf(value); 389 if (val != null) { 390 val = val.replaceAll("%+", ".*"); 391 } 392 switch (substringMatchType) { 393 case subany: 394 addField(bson, key, Pattern.compile(val, Pattern.CASE_INSENSITIVE)); 395 break; 396 case subinitial: 397 addField(bson, key, Pattern.compile('^' + val, Pattern.CASE_INSENSITIVE)); 398 break; 399 case subfinal: 400 addField(bson, key, Pattern.compile(val + '$', Pattern.CASE_INSENSITIVE)); 401 break; 402 } 403 } else { 404 addField(bson, key, value); 405 } 406 } 407 return bson; 408 } 409 410 protected void addField(Document bson, String key, Object value) { 411 String keyFieldName = key; 412 Field field = directory.getSchemaFieldMap().get(key); 413 if (field != null) { 414 keyFieldName = field.getName().getPrefixedName(); 415 } 416 bson.put(keyFieldName, value); 417 } 418 419 @Override 420 public void close() throws DirectoryException { 421 getDirectory().removeSession(this); 422 } 423 424 @Override 425 public boolean authenticate(String username, String password) throws DirectoryException { 426 Document user = getCollection().find(MongoDBSerializationHelper.fieldMapToBson(getIdField(), username)).first(); 427 if (user == null) { 428 return false; 429 } 430 431 String storedPassword = user.getString(getPasswordField()); 432 if (isMultiTenant()) { 433 // check that the entry is from the current tenant, or no tenant at all 434 if(!checkEntryTenantId(user.getString(TENANT_ID_FIELD))) { 435 storedPassword = null; 436 } 437 } 438 439 return PasswordHelper.verifyPassword(password, storedPassword); 440 } 441 442 @Override 443 public boolean isAuthenticating() { 444 return directory.getSchemaFieldMap().containsKey(getPasswordField()); 445 } 446 447 @Override 448 public boolean hasEntry(String id) { 449 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 450 return getCollection().count(MongoDBSerializationHelper.fieldMapToBson(idFieldName, id)) > 0; 451 } 452 453 /** 454 * Retrieve a collection 455 * 456 * @param collection the collection name 457 * @return the MongoDB collection 458 */ 459 public MongoCollection<Document> getCollection(String collection) { 460 return database.getCollection(collection); 461 } 462 463 /** 464 * Retrieve the collection associated to this directory 465 * 466 * @return the MongoDB collection 467 */ 468 public MongoCollection<Document> getCollection() { 469 return getCollection(directoryName); 470 } 471 472 /** 473 * Check if the MongoDB server has the collection 474 * 475 * @param collection the collection name 476 * @return true if the server has the collection, false otherwise 477 */ 478 public boolean hasCollection(String collection) { 479 return MongoDBConnectionHelper.hasCollection(database, collection); 480 } 481 482 protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) { 483 String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName(); 484 if (!fieldMap.containsKey(idFieldName)) { 485 idFieldName = getIdField(); 486 } 487 String id = String.valueOf(fieldMap.get(idFieldName)); 488 return createEntryModel(null, schemaName, id, fieldMap, isReadOnly()); 489 } 490 491 protected boolean checkEntryTenantId(String entryTenantId) { 492 // check that the entry is from the current tenant, or no tenant at all 493 String tenantId = getCurrentTenantId(); 494 if (StringUtils.isNotBlank(tenantId)) { 495 if (StringUtils.isNotBlank(entryTenantId) && !entryTenantId.equals(tenantId)) { 496 return false; 497 } 498 } 499 return true; 500 } 501 502}