001/*
002 * (C) Copyright 2017 Nuxeo SA (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 *     Kevin Leturc
018 */
019package org.nuxeo.mongodb.audit;
020
021import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_CATEGORY;
022import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_DOC_PATH;
023import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_DOC_UUID;
024import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_EVENT_DATE;
025import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_EVENT_ID;
026import static org.nuxeo.mongodb.audit.LogEntryConstants.PROPERTY_REPOSITORY_ID;
027
028import java.io.Serializable;
029import java.text.SimpleDateFormat;
030import java.util.ArrayList;
031import java.util.Calendar;
032import java.util.Collections;
033import java.util.Date;
034import java.util.HashMap;
035import java.util.List;
036import java.util.Map;
037import java.util.Map.Entry;
038import java.util.TimeZone;
039import java.util.stream.Collectors;
040import java.util.stream.StreamSupport;
041
042import org.apache.commons.collections.MapUtils;
043import org.apache.commons.logging.Log;
044import org.apache.commons.logging.LogFactory;
045import org.bson.Document;
046import org.bson.conversions.Bson;
047import org.nuxeo.common.utils.TextTemplate;
048import org.nuxeo.ecm.core.uidgen.UIDGeneratorService;
049import org.nuxeo.ecm.core.uidgen.UIDSequencer;
050import org.nuxeo.ecm.platform.audit.api.ExtendedInfo;
051import org.nuxeo.ecm.platform.audit.api.FilterMapEntry;
052import org.nuxeo.ecm.platform.audit.api.LogEntry;
053import org.nuxeo.ecm.platform.audit.api.query.AuditQueryException;
054import org.nuxeo.ecm.platform.audit.api.query.DateRangeParser;
055import org.nuxeo.ecm.platform.audit.service.AbstractAuditBackend;
056import org.nuxeo.ecm.platform.audit.service.AuditBackend;
057import org.nuxeo.ecm.platform.audit.service.BaseLogEntryProvider;
058import org.nuxeo.ecm.platform.audit.service.NXAuditEventsService;
059import org.nuxeo.ecm.platform.audit.service.extension.AuditBackendDescriptor;
060import org.nuxeo.mongodb.core.MongoDBComponent;
061import org.nuxeo.mongodb.core.MongoDBConnectionService;
062import org.nuxeo.mongodb.core.MongoDBSerializationHelper;
063import org.nuxeo.runtime.api.Framework;
064import org.nuxeo.runtime.model.DefaultComponent;
065import org.nuxeo.runtime.services.config.ConfigurationService;
066
067import com.mongodb.client.FindIterable;
068import com.mongodb.client.MongoCollection;
069import com.mongodb.client.MongoDatabase;
070import com.mongodb.client.model.Filters;
071import com.mongodb.client.model.Sorts;
072
073/**
074 * Implementation of the {@link AuditBackend} interface using MongoDB persistence.
075 *
076 * @since 9.1
077 */
078public class MongoDBAuditBackend extends AbstractAuditBackend implements AuditBackend {
079
080    private static final Log log = LogFactory.getLog(MongoDBAuditBackend.class);
081
082    public static final String AUDIT_DATABASE_ID = "audit";
083
084    public static final String COLLECTION_NAME_PROPERTY = "nuxeo.mongodb.audit.collection.name";
085
086    public static final String DEFAULT_COLLECTION_NAME = "audit";
087
088    public static final String SEQ_NAME = "audit";
089
090    protected MongoCollection<Document> collection;
091
092    protected MongoDBLogEntryProvider provider = new MongoDBLogEntryProvider();
093
094    public MongoDBAuditBackend(NXAuditEventsService component, AuditBackendDescriptor config) {
095        super(component, config);
096    }
097
098    @Override
099    public int getApplicationStartedOrder() {
100        DefaultComponent component = (DefaultComponent) Framework.getRuntime().getComponent(MongoDBComponent.NAME);
101        return component.getApplicationStartedOrder() + 1;
102    }
103
104    @Override
105    public void onApplicationStarted() {
106        log.info("Activate MongoDB backend for Audit");
107        // First retrieve the collection name
108        ConfigurationService configurationService = Framework.getService(ConfigurationService.class);
109        String collName = configurationService.getProperty(COLLECTION_NAME_PROPERTY, DEFAULT_COLLECTION_NAME);
110        // Get a connection to MongoDB
111        MongoDBConnectionService mongoService = Framework.getService(MongoDBConnectionService.class);
112        MongoDatabase database = mongoService.getDatabase(AUDIT_DATABASE_ID);
113        collection = database.getCollection(collName);
114        // TODO migration ?
115    }
116
117    @Override
118    public void onShutdown() {
119        if (collection != null) {
120            collection = null;
121        }
122    }
123
124    /**
125     * @return the {@link MongoCollection} configured with audit settings.
126     */
127    public MongoCollection<Document> getAuditCollection() {
128        return collection;
129    }
130
131    @Override
132    public List<LogEntry> getLogEntriesFor(String uuid, String repositoryId) {
133        Bson docFilter = Filters.eq(PROPERTY_DOC_UUID, uuid);
134        Bson repoFilter = Filters.eq(PROPERTY_REPOSITORY_ID, repositoryId);
135        Bson query = Filters.and(docFilter, repoFilter);
136        return getLogEntries(query, false);
137    }
138
139    @Override
140    public List<LogEntry> getLogEntriesFor(String uuid, Map<String, FilterMapEntry> filterMap, boolean doDefaultSort) {
141        Bson docFilter = Filters.eq(PROPERTY_DOC_UUID, uuid);
142
143        Bson filter;
144        if (MapUtils.isEmpty(filterMap)) {
145            filter = docFilter;
146        } else {
147            List<Bson> list = new ArrayList<>(Collections.singleton(docFilter));
148            for (Entry<String, FilterMapEntry> entry : filterMap.entrySet()) {
149                FilterMapEntry filterEntry = entry.getValue();
150                list.add(Filters.eq(filterEntry.getColumnName(), filterEntry.getObject()));
151            }
152            filter = Filters.and(list);
153        }
154        return getLogEntries(filter, doDefaultSort);
155    }
156
157    @Override
158    public LogEntry getLogEntryByID(long id) {
159        Document document = collection.find(Filters.eq(MongoDBSerializationHelper.MONGODB_ID, Long.valueOf(id)))
160                                      .first();
161        if (document == null) {
162            return null;
163        }
164        return MongoDBAuditEntryReader.read(document);
165    }
166
167    @Override
168    public List<?> nativeQuery(String query, Map<String, Object> params, int pageNb, int pageSize) {
169        Bson filter = buildFilter(query, params);
170        logRequest(filter, pageNb, pageSize);
171        FindIterable<Document> iterable = collection.find(filter).skip(pageNb * pageSize).limit(pageSize);
172        return buildLogEntries(iterable);
173    }
174
175    public Bson buildFilter(String query, Map<String, Object> params) {
176        if (params != null && params.size() > 0) {
177            query = expandQueryVariables(query, params);
178        }
179        return Document.parse(query);
180    }
181
182    public String expandQueryVariables(String query, Object[] params) {
183        Map<String, Object> qParams = new HashMap<>();
184        for (int i = 0; i < params.length; i++) {
185            query = query.replaceFirst("\\?", "\\${param" + i + "}");
186            qParams.put("param" + i, params[i]);
187        }
188        return expandQueryVariables(query, qParams);
189    }
190
191    public String expandQueryVariables(String query, Map<String, Object> params) {
192        if (params != null && params.size() > 0) {
193            TextTemplate tmpl = new TextTemplate();
194            // MongoDB date formatter - copied from org.bson.json.JsonWriter
195            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'");
196            dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
197            for (Entry<String, Object> entry : params.entrySet()) {
198                String key = entry.getKey();
199                Object value = entry.getValue();
200                if (value instanceof Calendar) {
201                    tmpl.setVariable(key, dateFormat.format(((Calendar) value).getTime()));
202                } else if (value instanceof Date) {
203                    tmpl.setVariable(key, dateFormat.format(value));
204                } else if (value != null) {
205                    tmpl.setVariable(key, value.toString());
206                }
207            }
208            query = tmpl.processText(query);
209        }
210        return query;
211    }
212
213    @Override
214    public List<LogEntry> queryLogsByPage(String[] eventIds, Date limit, String[] categories, String path, int pageNb,
215            int pageSize) {
216        List<Bson> list = new ArrayList<>();
217        if (eventIds != null && eventIds.length > 0) {
218            if (eventIds.length == 1) {
219                list.add(Filters.eq(PROPERTY_EVENT_ID, eventIds[0]));
220            } else {
221                list.add(Filters.in(PROPERTY_EVENT_ID, eventIds));
222            }
223        }
224        if (categories != null && categories.length > 0) {
225            if (categories.length == 1) {
226                list.add(Filters.eq(PROPERTY_CATEGORY, categories[0]));
227            } else {
228                list.add(Filters.in(PROPERTY_CATEGORY, categories));
229            }
230        }
231        if (path != null) {
232            list.add(Filters.eq(PROPERTY_DOC_PATH, path));
233        }
234        if (limit != null) {
235            list.add(Filters.lt(PROPERTY_EVENT_DATE, limit));
236        }
237        Bson filter = list.size() == 1 ? list.get(0) : Filters.and(list);
238        logRequest(filter, pageNb, pageSize);
239        FindIterable<Document> iterable = collection.find(filter).skip(pageNb * pageSize).limit(pageSize);
240        return buildLogEntries(iterable);
241    }
242
243    @Override
244    public List<LogEntry> queryLogsByPage(String[] eventIds, String dateRange, String[] categories, String path,
245            int pageNb, int pageSize) {
246        // TODO maybe we can put this method in AbstratAuditBackend ?
247        Date limit = null;
248        if (dateRange != null) {
249            try {
250                limit = DateRangeParser.parseDateRangeQuery(new Date(), dateRange);
251            } catch (AuditQueryException aqe) {
252                aqe.addInfo("Wrong date range query. Query was " + dateRange);
253                throw aqe;
254            }
255        }
256        return queryLogsByPage(eventIds, limit, categories, path, pageNb, pageSize);
257    }
258
259    @Override
260    public void addLogEntries(List<LogEntry> entries) {
261        if (entries.isEmpty()) {
262            return;
263        }
264
265        UIDGeneratorService uidGeneratorService = Framework.getService(UIDGeneratorService.class);
266        UIDSequencer seq = uidGeneratorService.getSequencer();
267
268        List<Document> documents = new ArrayList<>(entries.size());
269        for (LogEntry entry : entries) {
270            entry.setId(seq.getNextLong(SEQ_NAME));
271            if (log.isDebugEnabled()) {
272                log.debug(String.format("Indexing log enry Id: %s, with logDate : %s, for docUUID: %s ",
273                        Long.valueOf(entry.getId()), entry.getLogDate(), entry.getDocUUID()));
274            }
275            documents.add(MongoDBAuditEntryWriter.asDocument(entry));
276        }
277        collection.insertMany(documents);
278    }
279
280    @Override
281    public Long getEventsCount(String eventId) {
282        return Long.valueOf(collection.count(Filters.eq("eventId", eventId)));
283    }
284
285    @Override
286    public long syncLogCreationEntries(String repoId, String path, Boolean recurs) {
287        return syncLogCreationEntries(provider, repoId, path, recurs);
288    }
289
290    @Override
291    public ExtendedInfo newExtendedInfo(Serializable value) {
292        return new MongoDBExtendedInfo(value);
293    }
294
295    private List<LogEntry> getLogEntries(Bson filter, boolean doDefaultSort) {
296        Bson orderBy = null;
297        if (doDefaultSort) {
298            orderBy = Sorts.descending(PROPERTY_EVENT_DATE);
299        }
300
301        logRequest(filter, orderBy);
302        FindIterable<Document> iterable = collection.find(filter).sort(orderBy);
303        return buildLogEntries(iterable);
304    }
305
306    private List<LogEntry> buildLogEntries(FindIterable<Document> iterable) {
307        return StreamSupport.stream(iterable.spliterator(), false)
308                            .map(MongoDBAuditEntryReader::read)
309                            .collect(Collectors.toList());
310    }
311
312    private void logRequest(Bson filter, Bson orderBy) {
313        if (log.isDebugEnabled()) {
314            log.debug("MongoDB: FILTER " + filter + (orderBy == null ? "" : " ORDER BY " + orderBy));
315        }
316    }
317
318    private void logRequest(Bson filter, int pageNb, int pageSize) {
319        if (log.isDebugEnabled()) {
320            log.debug("MongoDB: FILTER " + filter + " OFFSET " + pageNb + " LIMIT " + pageSize);
321        }
322    }
323
324    public class MongoDBLogEntryProvider implements BaseLogEntryProvider {
325
326        @Override
327        public int removeEntries(String eventId, String pathPattern) {
328            throw new UnsupportedOperationException("Not implemented yet!");
329        }
330
331        @Override
332        public void addLogEntry(LogEntry logEntry) {
333            List<LogEntry> entries = new ArrayList<>();
334            entries.add(logEntry);
335            addLogEntries(entries);
336        }
337
338        @Override
339        public List<LogEntry> getLogEntriesFor(String uuid, String repositoryId) {
340            throw new UnsupportedOperationException("Not implemented yet!");
341        }
342
343        @Override
344        public List<LogEntry> getLogEntriesFor(String uuid) {
345            throw new UnsupportedOperationException("Not implemented yet!");
346        }
347
348        @Override
349        public List<LogEntry> getLogEntriesFor(String uuid, Map<String, FilterMapEntry> filterMap,
350                boolean doDefaultSort) {
351            throw new UnsupportedOperationException("Not implemented yet!");
352        }
353
354    }
355
356}