View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2016 ForgeRock AS.
15   */
16  
17  package org.forgerock.audit.handlers.json;
18  
19  import static org.forgerock.audit.util.JsonValueUtils.JSONVALUE_FILTER_VISITOR;
20  import static org.forgerock.json.JsonValue.*;
21  import static org.forgerock.json.resource.ResourceException.*;
22  import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_ID;
23  import static org.forgerock.json.resource.Responses.*;
24  
25  import java.io.BufferedReader;
26  import java.io.IOException;
27  import java.io.InputStreamReader;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.Files;
30  import java.nio.file.Path;
31  import java.util.Map;
32  import java.util.regex.Matcher;
33  import java.util.regex.Pattern;
34  
35  import com.fasterxml.jackson.databind.ObjectMapper;
36  import org.forgerock.audit.events.EventTopicsMetaData;
37  import org.forgerock.audit.events.handlers.AuditEventHandler;
38  import org.forgerock.audit.events.handlers.AuditEventHandlerBase;
39  import org.forgerock.audit.util.ElasticsearchUtil;
40  import org.forgerock.json.JsonPointer;
41  import org.forgerock.json.JsonValue;
42  import org.forgerock.json.resource.ActionRequest;
43  import org.forgerock.json.resource.ActionResponse;
44  import org.forgerock.json.resource.CountPolicy;
45  import org.forgerock.json.resource.QueryRequest;
46  import org.forgerock.json.resource.QueryResourceHandler;
47  import org.forgerock.json.resource.QueryResponse;
48  import org.forgerock.json.resource.ResourceException;
49  import org.forgerock.json.resource.ResourceResponse;
50  import org.forgerock.services.context.Context;
51  import org.forgerock.util.promise.Promise;
52  import org.forgerock.util.query.QueryFilter;
53  
54  /**
55   * {@link AuditEventHandler} for persisting raw JSON events to a file.
56   * <p>
57   * The file format is a UTF-8 text-file, with one JSON event per line, and each line terminated by a newline character.
58   */
59  public class JsonAuditEventHandler extends AuditEventHandlerBase {
60  
61      static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
62  
63      /**
64       * Name of the {@code _eventId} JSON field.
65       * <p>
66       * When {@link #elasticsearchCompatible} is enabled, this handler renames the {@code _id} field to {@code _eventId},
67       * because {@code _id} is reserved by ElasticSearch. The operation is reversed after JSON serialization, so that
68       * other handlers will see the original field name.
69       */
70      static final String EVENT_ID_FIELD = "_eventId";
71  
72      /**
73       * Name of action to force file rotation.
74       */
75      public static final String ROTATE_FILE_ACTION_NAME = "rotate";
76  
77      /**
78       * Name of action to force flushing of file-buffer, which is not the same as flushing buffered audit events,
79       * and is primarily used for testing purposes.
80       */
81      public static final String FLUSH_FILE_ACTION_NAME = "flush";
82  
83      private static final String ID_FIELD_PATTERN_PREFIX = "\"" + FIELD_CONTENT_ID + "\"\\s*:\\s*\"";
84      private static final String EVENT_ID_FIELD_PATTERN_PREFIX = "\"" + EVENT_ID_FIELD + "\"\\s*:\\s*\"";
85      private static final String FIELD_PATTERN_SUFFIX = "\"";
86  
87      private final JsonFileWriter jsonFileWriter;
88      private final boolean elasticsearchCompatible;
89  
90      /**
91       * Creates a {@code JsonAuditEventHandler} instances.
92       *
93       * @param configuration Configuration
94       * @param eventTopicsMetaData Provides meta-data describing the audit event topics this handler may have to handle.
95       */
96      public JsonAuditEventHandler(
97              final JsonAuditEventHandlerConfiguration configuration,
98              final EventTopicsMetaData eventTopicsMetaData) {
99          super(configuration.getName(), eventTopicsMetaData, configuration.getTopics(), configuration.isEnabled());
100         jsonFileWriter = new JsonFileWriter(configuration.getTopics(), configuration, true);
101         elasticsearchCompatible = configuration.isElasticsearchCompatible();
102     }
103 
104     @Override
105     public void startup() throws ResourceException {
106         jsonFileWriter.startup();
107     }
108 
109     @Override
110     public void shutdown() throws ResourceException {
111         jsonFileWriter.shutdown();
112     }
113 
114     @Override
115     public Promise<ResourceResponse, ResourceException> publishEvent(final Context context, final String topic,
116             final JsonValue event) {
117         try {
118             jsonFileWriter.put(topic, event);
119         } catch (Exception e) {
120             return newResourceException(INTERNAL_ERROR, "Failed to add event to queue", e).asPromise();
121         }
122         return newResourceResponse(event.get(FIELD_CONTENT_ID).asString(), null, event).asPromise();
123     }
124 
125     @Override
126     public Promise<ResourceResponse, ResourceException> readEvent(final Context context, final String topic,
127             final String resourceId) {
128         final Path jsonFilePath = jsonFileWriter.getTopicFilePath(topic);
129         if (jsonFilePath == null) {
130             return newResourceException(NOT_FOUND, "Topic not found: " + topic).asPromise();
131         }
132         final String fieldPatternPrefix = elasticsearchCompatible
133                 ? EVENT_ID_FIELD_PATTERN_PREFIX : ID_FIELD_PATTERN_PREFIX;
134         final Matcher idMatcher = Pattern.compile(fieldPatternPrefix + resourceId + FIELD_PATTERN_SUFFIX).matcher("");
135         String line;
136         try (final BufferedReader reader = new BufferedReader(new InputStreamReader(
137                 Files.newInputStream(jsonFilePath), StandardCharsets.UTF_8))) {
138             line = reader.readLine();
139             while (line != null) {
140                 if (idMatcher.reset(line).find()) {
141                     final JsonValue event = denormalizeJsonEvent(new JsonValue(
142                             OBJECT_MAPPER.readValue(line, Map.class)));
143                     return newResourceResponse(resourceId, null, event).asPromise();
144                 }
145                 line = reader.readLine();
146             }
147             return newResourceException(NOT_FOUND, "Resource not found with ID: " + resourceId).asPromise();
148         } catch (Exception e) {
149             return newResourceException(INTERNAL_ERROR, "Failed to read json file: " + jsonFilePath, e).asPromise();
150         }
151     }
152 
153     @Override
154     public Promise<QueryResponse, ResourceException> queryEvents(final Context context, final String topic,
155             final QueryRequest query, final QueryResourceHandler handler) {
156         final Path jsonFilePath = jsonFileWriter.getTopicFilePath(topic);
157         if (jsonFilePath == null) {
158             return newResourceException(NOT_FOUND, "Topic not found: " + topic).asPromise();
159         }
160         final QueryFilter<JsonPointer> queryFilter = query.getQueryFilter();
161         int results = 0;
162         String line;
163         try (final BufferedReader reader = new BufferedReader(new InputStreamReader(
164                 Files.newInputStream(jsonFilePath), StandardCharsets.UTF_8))) {
165             line = reader.readLine();
166             while (line != null) {
167                 final JsonValue event = denormalizeJsonEvent(new JsonValue(OBJECT_MAPPER.readValue(line, Map.class)));
168                 if (queryFilter.accept(JSONVALUE_FILTER_VISITOR, event)) {
169                     ++results;
170                     final ResourceResponse resourceResponse =
171                             newResourceResponse(event.get(FIELD_CONTENT_ID).asString(), null, event);
172                     if (!handler.handleResource(resourceResponse)) {
173                         break;
174                     }
175                 }
176                 line = reader.readLine();
177             }
178         } catch (Exception e) {
179             return newResourceException(INTERNAL_ERROR, "Failed to read json file: " + jsonFilePath, e).asPromise();
180         }
181         return newQueryResponse(null, CountPolicy.EXACT, results).asPromise();
182     }
183 
184     @Override
185     public Promise<ActionResponse, ResourceException> handleAction(final Context context, final String topic,
186             final ActionRequest request) {
187         final Path jsonFilePath = jsonFileWriter.getTopicFilePath(topic);
188         if (jsonFilePath == null) {
189             return newResourceException(NOT_FOUND, "Topic not found: " + topic).asPromise();
190         }
191         try {
192             switch (request.getAction()) {
193             case ROTATE_FILE_ACTION_NAME:
194                 if (!jsonFileWriter.rotateFile(topic)) {
195                     return newResourceException(BAD_REQUEST, "Rotation not enabled").asPromise();
196                 }
197                 break;
198             case FLUSH_FILE_ACTION_NAME:
199                 jsonFileWriter.flushFileBuffer(topic);
200                 break;
201             default:
202                 return newResourceException(BAD_REQUEST, "Unsupported action: " + request.getAction()).asPromise();
203             }
204             return newActionResponse(json(object(field("status", "OK")))).asPromise();
205         } catch (Exception e) {
206             return newResourceException(INTERNAL_ERROR, "Failed to invoke action", e).asPromise();
207         }
208     }
209 
210     /**
211      * Reverses all ElasticSearch JSON normalization, if {@link #elasticsearchCompatible} is enabled.
212      *
213      * @param event Audit event
214      * @return Audit event
215      * @throws IOException Failure while processing JSON
216      * @see JsonFileWriter#put(String, JsonValue)
217      */
218     private JsonValue denormalizeJsonEvent(JsonValue event) throws IOException {
219         if (elasticsearchCompatible) {
220             // reverse all ElasticSearch JSON normalization
221             event = ElasticsearchUtil.denormalizeJson(event);
222             ElasticsearchUtil.renameField(event, EVENT_ID_FIELD, FIELD_CONTENT_ID);
223         }
224         return event;
225     }
226 }