Skip to content

Commit c86e2d5

Browse files
Structured audit logging (#31931)
Changes the format of log events in the audit logfile. It also changes the filename suffix from `_access` to `_audit`. The new entry format is consistent with Elastic Common Schema. Entries are formatted as JSON with no nested objects and field names have a dotted syntax. Moreover, log entries themselves are not spaced by commas and there is exactly one entry per line. In addition, entry fields are ordered, unlike a typical JSON doc, such that a human would not strain his eyes over jumbled fields from one line to the other; the order is defined in the log4j2 properties file. The implementation utilizes the log4j2's `StringMapMessage`. This means that the application builds the log event as a map and the log4j logic (the appender's layout) handle the format internally. The layout, such as the set of printed fields and their order, can be changed at runtime without restarting the node.
1 parent faa3c16 commit c86e2d5

File tree

15 files changed

+1387
-878
lines changed

15 files changed

+1387
-878
lines changed

x-pack/plugin/core/src/main/config/log4j2.properties

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,64 @@
11
appender.audit_rolling.type = RollingFile
22
appender.audit_rolling.name = audit_rolling
3-
appender.audit_rolling.fileName = ${sys:es.logs.base_path}${sys:file.separator}${sys:es.logs.cluster_name}_access.log
3+
appender.audit_rolling.fileName = ${sys:es.logs.base_path}${sys:file.separator}${sys:es.logs.cluster_name}_audit.log
44
appender.audit_rolling.layout.type = PatternLayout
5-
appender.audit_rolling.layout.pattern = [%d{ISO8601}] %m%n
6-
appender.audit_rolling.filePattern = ${sys:es.logs.base_path}${sys:file.separator}${sys:es.logs.cluster_name}_access-%d{yyyy-MM-dd}.log
5+
appender.audit_rolling.layout.pattern = {\
6+
"@timestamp":"%d{ISO8601}"\
7+
%varsNotEmpty{, "node.name":"%enc{%map{node.name}}{JSON}"}\
8+
%varsNotEmpty{, "node.id":"%enc{%map{node.id}}{JSON}"}\
9+
%varsNotEmpty{, "host.name":"%enc{%map{host.name}}{JSON}"}\
10+
%varsNotEmpty{, "host.ip":"%enc{%map{host.ip}}{JSON}"}\
11+
%varsNotEmpty{, "event.type":"%enc{%map{event.type}}{JSON}"}\
12+
%varsNotEmpty{, "event.action":"%enc{%map{event.action}}{JSON}"}\
13+
%varsNotEmpty{, "user.name":"%enc{%map{user.name}}{JSON}"}\
14+
%varsNotEmpty{, "user.run_by.name":"%enc{%map{user.run_by.name}}{JSON}"}\
15+
%varsNotEmpty{, "user.run_as.name":"%enc{%map{user.run_as.name}}{JSON}"}\
16+
%varsNotEmpty{, "user.realm":"%enc{%map{user.realm}}{JSON}"}\
17+
%varsNotEmpty{, "user.run_by.realm":"%enc{%map{user.run_by.realm}}{JSON}"}\
18+
%varsNotEmpty{, "user.run_as.realm":"%enc{%map{user.run_as.realm}}{JSON}"}\
19+
%varsNotEmpty{, "user.roles":%map{user.roles}}\
20+
%varsNotEmpty{, "origin.type":"%enc{%map{origin.type}}{JSON}"}\
21+
%varsNotEmpty{, "origin.address":"%enc{%map{origin.address}}{JSON}"}\
22+
%varsNotEmpty{, "realm":"%enc{%map{realm}}{JSON}"}\
23+
%varsNotEmpty{, "url.path":"%enc{%map{url.path}}{JSON}"}\
24+
%varsNotEmpty{, "url.query":"%enc{%map{url.query}}{JSON}"}\
25+
%varsNotEmpty{, "request.body":"%enc{%map{request.body}}{JSON}"}\
26+
%varsNotEmpty{, "action":"%enc{%map{action}}{JSON}"}\
27+
%varsNotEmpty{, "request.name":"%enc{%map{request.name}}{JSON}"}\
28+
%varsNotEmpty{, "indices":%map{indices}}\
29+
%varsNotEmpty{, "opaque_id":"%enc{%map{opaque_id}}{JSON}"}\
30+
%varsNotEmpty{, "transport.profile":"%enc{%map{transport.profile}}{JSON}"}\
31+
%varsNotEmpty{, "rule":"%enc{%map{rule}}{JSON}"}\
32+
%varsNotEmpty{, "event.category":"%enc{%map{event.category}}{JSON}"}\
33+
}%n
34+
# "node.name" node name from the `elasticsearch.yml` settings
35+
# "node.id" node id which should not change between cluster restarts
36+
# "host.name" unresolved hostname of the local node
37+
# "host.ip" the local bound ip (i.e. the ip listening for connections)
38+
# "event.type" a received REST request is translated into one or more transport requests. This indicates which processing layer generated the event "rest" or "transport" (internal)
39+
# "event.action" the name of the audited event, eg. "authentication_failed", "access_granted", "run_as_granted", etc.
40+
# "user.name" the subject name as authenticated by a realm
41+
# "user.run_by.name" the original authenticated subject name that is impersonating another one.
42+
# "user.run_as.name" if this "event.action" is of a run_as type, this is the subject name to be impersonated as.
43+
# "user.realm" the name of the realm that authenticated "user.name"
44+
# "user.run_by.realm" the realm name of the impersonating subject ("user.run_by.name")
45+
# "user.run_as.realm" if this "event.action" is of a run_as type, this is the realm name the impersonated user is looked up from
46+
# "user.roles" the roles array of the user; these are the roles that are granting privileges
47+
# "origin.type" it is "rest" if the event is originating (is in relation to) a REST request; possible other values are "transport" and "ip_filter"
48+
# "origin.address" the remote address and port of the first network hop, i.e. a REST proxy or another cluster node
49+
# "realm" name of a realm that has generated an "authentication_failed" or an "authentication_successful"; the subject is not yet authenticated
50+
# "url.path" the URI component between the port and the query string; it is percent (URL) encoded
51+
# "url.query" the URI component after the path and before the fragment; it is percent (URL) encoded
52+
# "request.body" the content of the request body entity, JSON escaped
53+
# "action" an action is the most granular operation that is authorized and this identifies it in a namespaced way (internal)
54+
# "request.name" if the event is in connection to a transport message this is the name of the request class, similar to how rest requests are identified by the url path (internal)
55+
# "indices" the array of indices that the "action" is acting upon
56+
# "opaque_id" opaque value conveyed by the "X-Opaque-Id" request header
57+
# "transport.profile" name of the transport profile in case this is a "connection_granted" or "connection_denied" event
58+
# "rule" name of the applied rulee if the "origin.type" is "ip_filter"
59+
# "event.category" fixed value "elasticsearch-audit"
60+
61+
appender.audit_rolling.filePattern = ${sys:es.logs.base_path}${sys:file.separator}${sys:es.logs.cluster_name}_audit-%d{yyyy-MM-dd}.log
762
appender.audit_rolling.policies.type = Policies
863
appender.audit_rolling.policies.time.type = TimeBasedTriggeringPolicy
964
appender.audit_rolling.policies.time.interval = 1

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/audit/logfile/CapturingLogger.java

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,51 @@
88
import org.apache.logging.log4j.Level;
99
import org.apache.logging.log4j.LogManager;
1010
import org.apache.logging.log4j.Logger;
11+
import org.apache.logging.log4j.core.Layout;
1112
import org.apache.logging.log4j.core.LogEvent;
1213
import org.apache.logging.log4j.core.LoggerContext;
14+
import org.apache.logging.log4j.core.StringLayout;
1315
import org.apache.logging.log4j.core.appender.AbstractAppender;
1416
import org.apache.logging.log4j.core.config.Configuration;
1517
import org.apache.logging.log4j.core.config.LoggerConfig;
1618
import org.apache.logging.log4j.core.filter.RegexFilter;
19+
import org.elasticsearch.common.Nullable;
1720
import org.elasticsearch.common.logging.ESLoggerFactory;
1821
import org.elasticsearch.common.logging.Loggers;
1922

2023
import java.util.ArrayList;
2124
import java.util.List;
2225

26+
/**
27+
* Logger that captures events and appends them to in memory lists, with one
28+
* list for each log level. This works with the global log manager context,
29+
* meaning that there could only be a single logger with the same name.
30+
*/
2331
public class CapturingLogger {
2432

25-
public static Logger newCapturingLogger(final Level level) throws IllegalAccessException {
33+
/**
34+
* Constructs a new {@link CapturingLogger} named as the fully qualified name of
35+
* the invoking method. One name can be assigned to a single logger globally, so
36+
* don't call this method multiple times in the same method.
37+
*
38+
* @param level
39+
* The minimum priority level of events that will be captured.
40+
* @param layout
41+
* Optional parameter allowing to set the layout format of events.
42+
* This is useful because events are captured to be inspected (and
43+
* parsed) later. When parsing, it is useful to be in control of the
44+
* printing format as well. If not specified,
45+
* {@code event.getMessage().getFormattedMessage()} is called to
46+
* format the event.
47+
* @return The new logger.
48+
*/
49+
public static Logger newCapturingLogger(final Level level, @Nullable StringLayout layout) throws IllegalAccessException {
50+
// careful, don't "bury" this on the call stack, unless you know what you're doing
2651
final StackTraceElement caller = Thread.currentThread().getStackTrace()[2];
2752
final String name = caller.getClassName() + "." + caller.getMethodName() + "." + level.toString();
2853
final Logger logger = ESLoggerFactory.getLogger(name);
2954
Loggers.setLevel(logger, level);
30-
final MockAppender appender = new MockAppender(name);
55+
final MockAppender appender = new MockAppender(name, layout);
3156
appender.start();
3257
Loggers.addAppender(logger, appender);
3358
return logger;
@@ -40,11 +65,27 @@ private static MockAppender getMockAppender(final String name) {
4065
return (MockAppender) loggerConfig.getAppenders().get(name);
4166
}
4267

68+
/**
69+
* Checks if the logger's appender has captured any events.
70+
*
71+
* @param name
72+
* The unique global name of the logger.
73+
* @return {@code true} if no event has been captured, {@code false} otherwise.
74+
*/
4375
public static boolean isEmpty(final String name) {
4476
final MockAppender appender = getMockAppender(name);
4577
return appender.isEmpty();
4678
}
4779

80+
/**
81+
* Gets the captured events for a logger by its name.
82+
*
83+
* @param name
84+
* The unique global name of the logger.
85+
* @param level
86+
* The priority level of the captured events to be returned.
87+
* @return A list of captured events formated to {@code String}.
88+
*/
4889
public static List<String> output(final String name, final Level level) {
4990
final MockAppender appender = getMockAppender(name);
5091
return appender.output(level);
@@ -58,8 +99,8 @@ private static class MockAppender extends AbstractAppender {
5899
public final List<String> debug = new ArrayList<>();
59100
public final List<String> trace = new ArrayList<>();
60101

61-
private MockAppender(final String name) throws IllegalAccessException {
62-
super(name, RegexFilter.createFilter(".*(\n.*)*", new String[0], false, null, null), null);
102+
private MockAppender(final String name, StringLayout layout) throws IllegalAccessException {
103+
super(name, RegexFilter.createFilter(".*(\n.*)*", new String[0], false, null, null), layout);
63104
}
64105

65106
@Override
@@ -68,25 +109,34 @@ public void append(LogEvent event) {
68109
// we can not keep a reference to the event here because Log4j is using a thread
69110
// local instance under the hood
70111
case "ERROR":
71-
error.add(event.getMessage().getFormattedMessage());
112+
error.add(formatMessage(event));
72113
break;
73114
case "WARN":
74-
warn.add(event.getMessage().getFormattedMessage());
115+
warn.add(formatMessage(event));
75116
break;
76117
case "INFO":
77-
info.add(event.getMessage().getFormattedMessage());
118+
info.add(formatMessage(event));
78119
break;
79120
case "DEBUG":
80-
debug.add(event.getMessage().getFormattedMessage());
121+
debug.add(formatMessage(event));
81122
break;
82123
case "TRACE":
83-
trace.add(event.getMessage().getFormattedMessage());
124+
trace.add(formatMessage(event));
84125
break;
85126
default:
86127
throw invalidLevelException(event.getLevel());
87128
}
88129
}
89130

131+
private String formatMessage(LogEvent event) {
132+
final Layout<?> layout = getLayout();
133+
if (layout instanceof StringLayout) {
134+
return ((StringLayout) layout).toSerializable(event);
135+
} else {
136+
return event.getMessage().getFormattedMessage();
137+
}
138+
}
139+
90140
private IllegalArgumentException invalidLevelException(Level level) {
91141
return new IllegalArgumentException("invalid level, expected [ERROR|WARN|INFO|DEBUG|TRACE] but was [" + level + "]");
92142
}

x-pack/plugin/security/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ artifacts {
140140
}
141141
sourceSets.test.resources {
142142
srcDir '../core/src/test/resources'
143+
srcDir '../core/src/main/config'
143144
}
144145
dependencyLicenses {
145146
mapping from: /java-support|opensaml-.*/, to: 'shibboleth'

0 commit comments

Comments
 (0)