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