1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 package org.forgerock.audit.handlers.csv;
17
18 import static java.lang.String.format;
19 import static org.forgerock.audit.events.AuditEventHelper.ARRAY_TYPE;
20 import static org.forgerock.audit.events.AuditEventHelper.OBJECT_TYPE;
21 import static org.forgerock.audit.events.AuditEventHelper.dotNotationToJsonPointer;
22 import static org.forgerock.audit.events.AuditEventHelper.getAuditEventProperties;
23 import static org.forgerock.audit.events.AuditEventHelper.getAuditEventSchema;
24 import static org.forgerock.audit.events.AuditEventHelper.getPropertyType;
25 import static org.forgerock.audit.events.AuditEventHelper.jsonPointerToDotNotation;
26 import static org.forgerock.audit.util.JsonSchemaUtils.generateJsonPointers;
27 import static org.forgerock.audit.util.JsonValueUtils.JSONVALUE_FILTER_VISITOR;
28 import static org.forgerock.audit.util.JsonValueUtils.expand;
29 import static org.forgerock.json.JsonValue.field;
30 import static org.forgerock.json.JsonValue.json;
31 import static org.forgerock.json.JsonValue.object;
32 import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_ID;
33 import static org.forgerock.json.resource.Responses.newQueryResponse;
34 import static org.forgerock.json.resource.Responses.newResourceResponse;
35 import static org.forgerock.util.Utils.isNullOrEmpty;
36
37 import java.io.File;
38 import java.io.FileReader;
39 import java.io.IOException;
40 import java.security.NoSuchAlgorithmException;
41 import java.security.SecureRandom;
42 import java.util.ArrayList;
43 import java.util.Collection;
44 import java.util.Collections;
45 import java.util.HashMap;
46 import java.util.HashSet;
47 import java.util.LinkedHashMap;
48 import java.util.LinkedHashSet;
49 import java.util.List;
50 import java.util.Map;
51 import java.util.Random;
52 import java.util.Set;
53 import java.util.concurrent.ConcurrentHashMap;
54 import java.util.concurrent.ConcurrentMap;
55
56 import javax.inject.Inject;
57
58 import org.forgerock.audit.Audit;
59 import org.forgerock.audit.events.EventTopicsMetaData;
60 import org.forgerock.audit.events.handlers.AuditEventHandlerBase;
61 import org.forgerock.audit.handlers.csv.CsvAuditEventHandlerConfiguration.CsvSecurity;
62 import org.forgerock.audit.handlers.csv.CsvAuditEventHandlerConfiguration.EventBufferingConfiguration;
63 import org.forgerock.audit.providers.KeyStoreHandlerProvider;
64 import org.forgerock.audit.retention.TimeStampFileNamingPolicy;
65 import org.forgerock.audit.secure.JcaKeyStoreHandler;
66 import org.forgerock.audit.secure.KeyStoreHandler;
67 import org.forgerock.audit.util.JsonValueUtils;
68 import org.forgerock.json.JsonPointer;
69 import org.forgerock.json.JsonValue;
70 import org.forgerock.json.resource.ActionRequest;
71 import org.forgerock.json.resource.ActionResponse;
72 import org.forgerock.json.resource.BadRequestException;
73 import org.forgerock.json.resource.InternalServerErrorException;
74 import org.forgerock.json.resource.NotFoundException;
75 import org.forgerock.json.resource.QueryFilters;
76 import org.forgerock.json.resource.QueryRequest;
77 import org.forgerock.json.resource.QueryResourceHandler;
78 import org.forgerock.json.resource.QueryResponse;
79 import org.forgerock.json.resource.ResourceException;
80 import org.forgerock.json.resource.ResourceResponse;
81 import org.forgerock.json.resource.Responses;
82 import org.forgerock.services.context.Context;
83 import org.forgerock.util.Reject;
84 import org.forgerock.util.promise.Promise;
85 import org.forgerock.util.query.QueryFilter;
86 import org.forgerock.util.time.Duration;
87 import org.slf4j.Logger;
88 import org.slf4j.LoggerFactory;
89 import org.supercsv.cellprocessor.Optional;
90 import org.supercsv.cellprocessor.ift.CellProcessor;
91 import org.supercsv.io.CsvMapReader;
92 import org.supercsv.io.ICsvMapReader;
93 import org.supercsv.prefs.CsvPreference;
94 import org.supercsv.quote.AlwaysQuoteMode;
95 import org.supercsv.util.CsvContext;
96
97 import com.fasterxml.jackson.databind.ObjectMapper;
98
99
100
101
102 public class CsvAuditEventHandler extends AuditEventHandlerBase {
103
104 private static final Logger LOGGER = LoggerFactory.getLogger(CsvAuditEventHandler.class);
105
106
107 public static final String ROTATE_FILE_ACTION_NAME = "rotate";
108
109 static final String SECURE_CSV_FILENAME_PREFIX = "tamper-evident-";
110
111 private static final ObjectMapper MAPPER = new ObjectMapper();
112 private static final Random RANDOM;
113
114 static {
115 try {
116 RANDOM = SecureRandom.getInstance("SHA1PRNG");
117 } catch (NoSuchAlgorithmException ex) {
118 throw new RuntimeException(ex);
119 }
120 }
121
122 private final CsvAuditEventHandlerConfiguration configuration;
123 private final CsvPreference csvPreference;
124 private final ConcurrentMap<String, CsvWriter> writers = new ConcurrentHashMap<>();
125 private final Map<String, Set<String>> fieldOrderByTopic;
126
127 private final Map<String, JsonPointer> jsonPointerByField;
128
129 private final Map<String, String> fieldDotNotationByField;
130 private KeyStoreHandler keyStoreHandler;
131
132
133
134
135
136
137
138
139
140
141
142 @Inject
143 public CsvAuditEventHandler(
144 final CsvAuditEventHandlerConfiguration configuration,
145 final EventTopicsMetaData eventTopicsMetaData,
146 @Audit KeyStoreHandlerProvider keyStoreHandlerProvider) {
147
148 super(configuration.getName(), eventTopicsMetaData, configuration.getTopics(), configuration.isEnabled());
149 this.configuration = configuration;
150 this.csvPreference = createCsvPreference(this.configuration);
151 CsvSecurity security = configuration.getSecurity();
152 if (security.isEnabled()) {
153 Duration duration = security.getSignatureIntervalDuration();
154 Reject.ifTrue(duration.isZero() || duration.isUnlimited(),
155 "The signature interval can't be zero or unlimited");
156 Reject.ifFalse(
157 !isNullOrEmpty(security.getKeyStoreHandlerName())
158 ^ (!isNullOrEmpty(security.getFilename()) && !isNullOrEmpty(security.getPassword())),
159 "Either keyStoreHandlerName or filename/password security settings must be specified, "
160 + "but not both");
161
162 if (security.getKeyStoreHandlerName() != null) {
163 this.keyStoreHandler = keyStoreHandlerProvider.getKeystoreHandler(security.getKeyStoreHandlerName());
164 Reject.ifTrue(keyStoreHandler == null,
165 "No keystore configured for keyStoreHandlerName: "
166 + security.getKeyStoreHandlerName());
167 } else {
168 try {
169 keyStoreHandler = new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE, security.getFilename(),
170 security.getPassword());
171 } catch (Exception e) {
172 throw new IllegalArgumentException(
173 "Unable to create secure storage from file: " + security.getFilename(), e);
174 }
175 }
176 }
177
178 Map<String, Set<String>> fieldOrderByTopic = new HashMap<>();
179 Map<String, JsonPointer> jsonPointerByField = new HashMap<>();
180 Map<String, String> fieldDotNotationByField = new HashMap<>();
181 for (String topic : this.eventTopicsMetaData.getTopics()) {
182 try {
183 Set<String> fieldOrder = getFieldOrder(topic, this.eventTopicsMetaData);
184 for (String field : fieldOrder) {
185 if (!jsonPointerByField.containsKey(field)) {
186 jsonPointerByField.put(field, new JsonPointer(field));
187 fieldDotNotationByField.put(field, jsonPointerToDotNotation(field));
188 }
189 }
190 fieldOrderByTopic.put(topic, Collections.unmodifiableSet(fieldOrder));
191 } catch (ResourceException e) {
192 LOGGER.error(topic + " topic schema meta-data misconfigured.");
193 }
194 }
195 this.fieldOrderByTopic = Collections.unmodifiableMap(fieldOrderByTopic);
196 this.jsonPointerByField = Collections.unmodifiableMap(jsonPointerByField);
197 this.fieldDotNotationByField = Collections.unmodifiableMap(fieldDotNotationByField);
198 }
199
200 private CsvPreference createCsvPreference(final CsvAuditEventHandlerConfiguration config) {
201 return new CsvPreference.Builder(
202 config.getFormatting().getQuoteChar(),
203 config.getFormatting().getDelimiterChar(),
204 config.getFormatting().getEndOfLineSymbols())
205 .useQuoteMode(new AlwaysQuoteMode())
206 .build();
207 }
208
209
210
211
212 @Override
213 public void startup() throws ResourceException {
214 LOGGER.trace("Audit logging to: {}", configuration.getLogDirectory());
215 File file = new File(configuration.getLogDirectory());
216 if (!file.isDirectory()) {
217 if (file.exists()) {
218 LOGGER.warn("Specified path is file but should be a directory: {}", configuration.getLogDirectory());
219 } else {
220 if (!file.mkdirs()) {
221 LOGGER.warn("Unable to create audit directory in the path: {}", configuration.getLogDirectory());
222 }
223 }
224 }
225 for (String topic : eventTopicsMetaData.getTopics()) {
226 File auditLogFile = getAuditLogFile(topic);
227 try {
228 openWriter(topic, auditLogFile);
229 } catch (IOException e) {
230 LOGGER.error("Error when creating audit file: {}", auditLogFile, e);
231 }
232 }
233 }
234
235
236 @Override
237 public void shutdown() throws ResourceException {
238 cleanup();
239 }
240
241
242
243
244
245 @Override
246 public Promise<ResourceResponse, ResourceException> publishEvent(Context context, String topic, JsonValue event) {
247 try {
248 checkTopic(topic);
249 publishEventWithRetry(topic, event);
250 return newResourceResponse(
251 event.get(ResourceResponse.FIELD_CONTENT_ID).asString(), null, event).asPromise();
252 } catch (ResourceException e) {
253 return e.asPromise();
254 }
255 }
256
257 private void checkTopic(String topic) throws ResourceException {
258 final JsonValue auditEventProperties = getAuditEventProperties(eventTopicsMetaData.getSchema(topic));
259 if (auditEventProperties == null || auditEventProperties.isNull()) {
260 throw new InternalServerErrorException("No audit event properties defined for audit event: " + topic);
261 }
262 }
263
264
265
266
267 private void publishEventWithRetry(final String topic, final JsonValue event)
268 throws ResourceException {
269 final CsvWriter csvWriter = getWriter(topic);
270 try {
271 writeEvent(topic, csvWriter, event);
272 } catch (IOException ex) {
273
274 LOGGER.debug("IOException while writing ({})", ex.getMessage());
275 CsvWriter newCsvWriter;
276
277
278
279 synchronized (this) {
280
281 newCsvWriter = writers.get(topic);
282 if (newCsvWriter == csvWriter) {
283
284 newCsvWriter = resetAndReopenWriter(topic, false);
285 LOGGER.debug("Resetting writer");
286 } else {
287 LOGGER.debug("Writer reset by another thread");
288 }
289 }
290 try {
291 writeEvent(topic, newCsvWriter, event);
292 } catch (IOException e) {
293 throw new BadRequestException(e);
294 }
295 }
296 }
297
298
299
300
301
302
303
304
305
306 private CsvWriter getWriter(String topic) throws BadRequestException {
307 CsvWriter csvWriter = writers.get(topic);
308 if (csvWriter == null) {
309 LOGGER.debug("CSV file writer for {} topic is null; checking for reset by another thread", topic);
310 synchronized (this) {
311 csvWriter = writers.get(topic);
312 if (csvWriter == null) {
313 LOGGER.debug("CSV file writer for {} topic not reset by another thread; resetting", topic);
314 csvWriter = resetAndReopenWriter(topic, false);
315 }
316 }
317 }
318 return csvWriter;
319 }
320
321 private CsvWriter writeEvent(final String topic, CsvWriter csvWriter, final JsonValue event)
322 throws IOException {
323 writeEntry(topic, csvWriter, event);
324 EventBufferingConfiguration bufferConfig = configuration.getBuffering();
325 if (!bufferConfig.isEnabled() || !bufferConfig.isAutoFlush()) {
326 csvWriter.flush();
327 }
328 return csvWriter;
329 }
330
331 private Set<String> getFieldOrder(final String topic, final EventTopicsMetaData eventTopicsMetaData)
332 throws ResourceException {
333 final Set<String> fieldOrder = new LinkedHashSet<>();
334 fieldOrder.addAll(generateJsonPointers(getAuditEventSchema(eventTopicsMetaData.getSchema(topic))));
335 return fieldOrder;
336 }
337
338 private synchronized CsvWriter openWriter(final String topic, final File auditFile) throws IOException {
339 final CsvWriter writer = createCsvWriter(auditFile, topic);
340 writers.put(topic, writer);
341 return writer;
342 }
343
344 private synchronized CsvWriter createCsvWriter(final File auditFile, String topic) throws IOException {
345 String[] headers = buildHeaders(fieldOrderByTopic.get(topic));
346 if (configuration.getSecurity().isEnabled()) {
347 return new SecureCsvWriter(auditFile, headers, csvPreference, configuration, keyStoreHandler, RANDOM);
348 } else {
349 return new StandardCsvWriter(auditFile, headers, csvPreference, configuration);
350 }
351 }
352
353 private ICsvMapReader createCsvMapReader(final File auditFile) throws IOException {
354 CsvMapReader csvReader = new CsvMapReader(new FileReader(auditFile), csvPreference);
355
356 if (configuration.getSecurity().isEnabled()) {
357 return new CsvSecureMapReader(csvReader);
358 } else {
359 return csvReader;
360 }
361 }
362
363 private String[] buildHeaders(final Collection<String> fieldOrder) {
364 final String[] headers = new String[fieldOrder.size()];
365 fieldOrder.toArray(headers);
366 for (int i = 0; i < headers.length; i++) {
367 headers[i] = jsonPointerToDotNotation(headers[i]);
368 }
369 return headers;
370 }
371
372
373
374
375
376 @Override
377 public Promise<QueryResponse, ResourceException> queryEvents(
378 Context context,
379 String topic,
380 QueryRequest query,
381 QueryResourceHandler handler) {
382 try {
383 for (final JsonValue value : getEntries(topic, query.getQueryFilter())) {
384 handler.handleResource(newResourceResponse(value.get(FIELD_CONTENT_ID).asString(), null, value));
385 }
386 return newQueryResponse().asPromise();
387 } catch (Exception e) {
388 return new BadRequestException(e).asPromise();
389 }
390 }
391
392
393
394
395
396 @Override
397 public Promise<ResourceResponse, ResourceException> readEvent(Context context, String topic, String resourceId) {
398 try {
399 final Set<JsonValue> entry = getEntries(topic, QueryFilters.parse("/_id eq \"" + resourceId + "\""));
400 if (entry.isEmpty()) {
401 throw new NotFoundException(topic + " audit log not found");
402 }
403 final JsonValue resource = entry.iterator().next();
404 return newResourceResponse(resource.get(FIELD_CONTENT_ID).asString(), null, resource).asPromise();
405 } catch (ResourceException e) {
406 return e.asPromise();
407 } catch (IOException e) {
408 return new BadRequestException(e).asPromise();
409 }
410 }
411
412 @Override
413 public Promise<ActionResponse, ResourceException> handleAction(
414 Context context, String topic, ActionRequest request) {
415 try {
416 String action = request.getAction();
417 if (topic == null) {
418 return new BadRequestException(format("Topic is required for action %s", action)).asPromise();
419 }
420 if (action.equals(ROTATE_FILE_ACTION_NAME)) {
421 return handleRotateAction(topic).asPromise();
422 }
423 final String error = format("This action is unknown for the CSV handler: %s", action);
424 return new BadRequestException(error).asPromise();
425 } catch (BadRequestException e) {
426 return e.asPromise();
427 }
428 }
429
430 private ActionResponse handleRotateAction(String topic)
431 throws BadRequestException {
432 CsvWriter csvWriter = writers.get(topic);
433 if (csvWriter == null) {
434 LOGGER.debug("Unable to rotate file for topic: {}", topic);
435 throw new BadRequestException("Unable to rotate file for topic: " + topic);
436 }
437 if (configuration.getFileRotation().isRotationEnabled()) {
438 try {
439 if (!csvWriter.forceRotation()) {
440 throw new BadRequestException("Unable to rotate file for topic: " + topic);
441 }
442 } catch (IOException e) {
443 throw new BadRequestException("Error when rotating file for topic: " + topic, e);
444 }
445 } else {
446
447 resetAndReopenWriter(topic, true);
448 }
449 return Responses.newActionResponse(json(object(field("rotated", "true"))));
450 }
451
452 private File getAuditLogFile(final String type) {
453 final String prefix = configuration.getSecurity().isEnabled() ? SECURE_CSV_FILENAME_PREFIX : "";
454 return new File(configuration.getLogDirectory(), prefix + type + ".csv");
455 }
456
457 private void writeEntry(final String topic, final CsvWriter csvWriter, final JsonValue obj) throws IOException {
458 Set<String> fieldOrder = fieldOrderByTopic.get(topic);
459 Map<String, String> cells = new HashMap<>(fieldOrder.size());
460 for (Map.Entry<String, JsonPointer> columnKey : jsonPointerByField.entrySet()) {
461 cells.put(fieldDotNotationByField.get(columnKey.getKey()),
462 JsonValueUtils.extractValueAsString(obj, columnKey.getValue()));
463 }
464 csvWriter.writeEvent(cells);
465 }
466
467 private synchronized CsvWriter resetAndReopenWriter(final String topic, boolean forceRotation)
468 throws BadRequestException {
469 closeWriter(topic);
470 try {
471 File auditLogFile = getAuditLogFile(topic);
472 if (forceRotation) {
473 TimeStampFileNamingPolicy namingPolicy = new TimeStampFileNamingPolicy(auditLogFile, null, null);
474 File rotatedFile = namingPolicy.getNextName();
475 if (!auditLogFile.renameTo(rotatedFile)) {
476 throw new BadRequestException(
477 format("Unable to rename file %s to %s when rotating", auditLogFile, rotatedFile));
478 }
479 }
480 return openWriter(topic, auditLogFile);
481 } catch (IOException e) {
482 throw new BadRequestException(e);
483 }
484 }
485
486 private synchronized void closeWriter(final String topic) {
487 CsvWriter writerToClose = writers.remove(topic);
488 if (writerToClose != null) {
489
490 try {
491 writerToClose.close();
492 } catch (Exception ex) {
493
494 LOGGER.debug("File writer close in closeWriter reported failure ", ex);
495 }
496 }
497 }
498
499
500
501
502
503
504
505
506
507 private Set<JsonValue> getEntries(final String auditEntryType, QueryFilter<JsonPointer> queryFilter)
508 throws IOException {
509 final File auditFile = getAuditLogFile(auditEntryType);
510 final Set<JsonValue> results = new HashSet<>();
511 if (queryFilter == null) {
512 queryFilter = QueryFilter.alwaysTrue();
513 }
514 if (auditFile.exists()) {
515 try (ICsvMapReader reader = createCsvMapReader(auditFile)) {
516
517 final String[] header = convertDotNotationToSlashes(reader.getHeader(true));
518 final CellProcessor[] processors = createCellProcessors(auditEntryType, header);
519 Map<String, Object> entry;
520 while ((entry = reader.read(header, processors)) != null) {
521 entry = convertDotNotationToSlashes(entry);
522 final JsonValue jsonEntry = expand(entry);
523 if (queryFilter.accept(JSONVALUE_FILTER_VISITOR, jsonEntry)) {
524 results.add(jsonEntry);
525 }
526 }
527
528 }
529 }
530 return results;
531 }
532
533 private CellProcessor[] createCellProcessors(final String auditEntryType, final String[] headers)
534 throws ResourceException {
535 final List<CellProcessor> cellProcessors = new ArrayList<>();
536 final JsonValue auditEvent = eventTopicsMetaData.getSchema(auditEntryType);
537
538 for (String header: headers) {
539 final String propertyType = getPropertyType(auditEvent, new JsonPointer(header));
540 if ((propertyType.equals(OBJECT_TYPE) || propertyType.equals(ARRAY_TYPE))) {
541 cellProcessors.add(new Optional(new ParseJsonValue()));
542 } else {
543 cellProcessors.add(new Optional());
544 }
545 }
546
547 return cellProcessors.toArray(new CellProcessor[cellProcessors.size()]);
548 }
549
550
551
552
553 public class ParseJsonValue implements CellProcessor {
554
555 @Override
556 public Object execute(final Object value, final CsvContext context) {
557 JsonValue jv = null;
558
559 if (((String) value).startsWith("{") && ((String) value).endsWith("}")) {
560 try {
561 jv = new JsonValue(MAPPER.readValue((String) value, Map.class));
562 } catch (Exception e) {
563 LOGGER.debug("Error parsing JSON string: " + e.getMessage());
564 }
565 } else if (((String) value).startsWith("[") && ((String) value).endsWith("]")) {
566 try {
567 jv = new JsonValue(MAPPER.readValue((String) value, List.class));
568 } catch (Exception e) {
569 LOGGER.debug("Error parsing JSON string: " + e.getMessage());
570 }
571 }
572 if (jv == null) {
573 return value;
574 }
575 return jv.getObject();
576 }
577
578 }
579
580 private synchronized void cleanup() throws ResourceException {
581 try {
582 for (CsvWriter csvWriter : writers.values()) {
583 if (csvWriter != null) {
584 csvWriter.flush();
585 csvWriter.close();
586 }
587 }
588 } catch (IOException e) {
589 LOGGER.error("Unable to close filewriters during {} cleanup", this.getClass().getName(), e);
590 throw new InternalServerErrorException(
591 "Unable to close filewriters during " + this.getClass().getName() + " cleanup", e);
592 }
593 }
594
595 private Map<String, Object> convertDotNotationToSlashes(final Map<String, Object> entries) {
596 final Map<String, Object> newEntry = new LinkedHashMap<>();
597 for (Map.Entry<String, Object> entry : entries.entrySet()) {
598 final String key = dotNotationToJsonPointer(entry.getKey());
599 newEntry.put(key, entry.getValue());
600 }
601 return newEntry;
602 }
603
604 private String[] convertDotNotationToSlashes(final String[] entries) {
605 String[] result = new String[entries.length];
606 for (int i = 0; i < entries.length; i++) {
607 result[i] = dotNotationToJsonPointer(entries[i]);
608 }
609 return result;
610 }
611
612 }