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}