Skip to content

Commit e1c852b

Browse files
authored
Add audit log testing for cert-based cross-cluster authentication (#137302)
* Add audit log assertions to RemoteClusterSecurityCrossClusterApiKeySigningIT * Add comprehensive audit log testing for certificate-based cross-cluster authentication flows * Update docs/changelog/137302.yaml * Rename helper method
1 parent 58fe751 commit e1c852b

File tree

2 files changed

+93
-1
lines changed

2 files changed

+93
-1
lines changed

docs/changelog/137302.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 137302
2+
summary: Add audit log testing for cert-based cross-cluster authentication
3+
area: Security
4+
type: enhancement
5+
issues: []

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@
1414
import org.elasticsearch.client.RequestOptions;
1515
import org.elasticsearch.client.Response;
1616
import org.elasticsearch.client.ResponseException;
17+
import org.elasticsearch.common.io.Streams;
1718
import org.elasticsearch.common.settings.Settings;
19+
import org.elasticsearch.common.xcontent.XContentHelper;
1820
import org.elasticsearch.core.Strings;
1921
import org.elasticsearch.search.SearchHit;
2022
import org.elasticsearch.search.SearchResponseUtils;
2123
import org.elasticsearch.test.cluster.ElasticsearchCluster;
24+
import org.elasticsearch.test.cluster.LogType;
2225
import org.elasticsearch.test.cluster.util.resource.Resource;
26+
import org.elasticsearch.xcontent.XContentType;
27+
import org.elasticsearch.xpack.security.audit.AuditLevel;
2328
import org.junit.ClassRule;
2429
import org.junit.rules.RuleChain;
2530
import org.junit.rules.TestRule;
@@ -39,6 +44,12 @@
3944

4045
public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRemoteClusterSecurityTestCase {
4146

47+
// Date Time Formatter used for audit log timestamps.
48+
// Copied from AuditIT for consistent parsing.
49+
private static final java.time.format.DateTimeFormatter TSTAMP_FORMATTER = java.time.format.DateTimeFormatter.ofPattern(
50+
"yyyy-MM-dd'T'HH:mm:ss,SSSZ"
51+
);
52+
4253
private static final AtomicReference<Map<String, Object>> MY_REMOTE_API_KEY_MAP_REF = new AtomicReference<>();
4354
private static final String TEST_ACCESS_JSON = """
4455
{
@@ -177,7 +188,6 @@ public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Excepti
177188
// Change the CA to the default trust store to make sure untrusted signature fails auth even if it's not required
178189
updateClusterSettingsFulfillingCluster(Settings.builder().putNull("cluster.remote.signing.certificate_authorities").build());
179190
assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [(");
180-
181191
// Reset
182192
updateClusterSettingsFulfillingCluster(
183193
Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build()
@@ -200,21 +210,43 @@ public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Excepti
200210
}
201211

202212
private void assertCrossClusterAuthFail(String expectedMessage) {
213+
final long startTimeMillis = System.currentTimeMillis();
203214
var responseException = assertThrows(ResponseException.class, () -> simpleCrossClusterSearch(randomBoolean()));
204215
assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(401));
205216
assertThat(responseException.getMessage(), containsString(expectedMessage));
217+
218+
try {
219+
assertAuditLogContainsNewEvent(startTimeMillis, AuditLevel.AUTHENTICATION_FAILED.name().toLowerCase(Locale.ROOT));
220+
} catch (Exception e) {
221+
fail(e, "Audit log assertion failed due to an underlying exception (e.g. IOException) when reading the log file.");
222+
}
206223
}
207224

208225
private void assertCrossClusterSearchSuccessfulWithoutResult() throws IOException {
226+
final long startTimeMillis = System.currentTimeMillis();
209227
boolean alsoSearchLocally = randomBoolean();
210228
final Response response = simpleCrossClusterSearch(alsoSearchLocally);
211229
assertOK(response);
230+
try {
231+
assertAuditLogContainsNewEvent(startTimeMillis, AuditLevel.AUTHENTICATION_FAILED.name().toLowerCase(Locale.ROOT));
232+
} catch (Exception e) {
233+
fail(e, "Audit log assertion failed due to an underlying exception (e.g. IOException) when reading the log file.");
234+
}
235+
212236
}
213237

214238
private void assertCrossClusterSearchSuccessfulWithResult() throws IOException {
239+
final long startTimeMillis = System.currentTimeMillis();
215240
boolean alsoSearchLocally = randomBoolean();
216241
final Response response = simpleCrossClusterSearch(alsoSearchLocally);
217242
assertOK(response);
243+
244+
try {
245+
assertAuditLogContainsNewEvent(startTimeMillis, AuditLevel.AUTHENTICATION_SUCCESS.name().toLowerCase(Locale.ROOT));
246+
} catch (Exception e) {
247+
fail(e, "Audit log assertion failed due to an underlying exception (e.g. IOException) when reading the log file.");
248+
}
249+
218250
final SearchResponse searchResponse;
219251
try (var parser = responseAsParser(response)) {
220252
searchResponse = SearchResponseUtils.parseSearchResponse(parser);
@@ -318,6 +350,61 @@ private Response performRequestWithRemoteAccessUser(final Request request) throw
318350
return client().performRequest(request);
319351
}
320352

353+
private String extractAuditLogTimestamp(String jsonLine, String fieldName) {
354+
Map<String, Object> jsonMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), jsonLine, false);
355+
Object value = jsonMap.get(fieldName);
356+
if (value == null) {
357+
throw new IllegalArgumentException("Field [" + fieldName + "] not found in log line: " + jsonLine);
358+
}
359+
360+
return value.toString();
361+
}
362+
363+
private long parseLogTimestamp(String timestamp) {
364+
try {
365+
return java.time.ZonedDateTime.parse(timestamp, TSTAMP_FORMATTER).toInstant().toEpochMilli();
366+
} catch (java.time.format.DateTimeParseException e) {
367+
throw new RuntimeException("Failed to parse log timestamp: " + timestamp, e);
368+
}
369+
}
370+
371+
private void assertAuditLogContainsNewEvent(long startTimeMillis, String eventAction) throws Exception {
372+
assertBusy(() -> {
373+
// Iterate over all nodes in the fulfilling cluster
374+
for (int i = 0; i < fulfillingCluster.getNumNodes(); i++) {
375+
try (var auditLog = fulfillingCluster.getNodeLog(i, LogType.AUDIT)) {
376+
final List<String> allLines = Streams.readAllLines(auditLog);
377+
378+
String expectedLogFragment = "event.action\":\"" + eventAction + "\"";
379+
380+
boolean foundNewDetailedLog = allLines.stream()
381+
.filter(line -> line.contains(expectedLogFragment))
382+
.filter(line -> line.contains("request.name"))
383+
.anyMatch(line -> {
384+
try {
385+
String tsString = extractAuditLogTimestamp(line, "timestamp");
386+
long logTimeMillis = parseLogTimestamp(tsString);
387+
388+
// Make sure log occurred after the test had started
389+
return logTimeMillis >= startTimeMillis;
390+
} catch (Exception e) {
391+
return false;
392+
}
393+
});
394+
395+
assertThat(
396+
"Audit log must contain the expected NEW detailed " + eventAction.replace("_", " ") + " entry.",
397+
foundNewDetailedLog,
398+
equalTo(true)
399+
);
400+
} catch (IOException e) {
401+
logger.warn("Failed to read audit log for node [{}].", i, e);
402+
throw e;
403+
}
404+
}
405+
});
406+
}
407+
321408
protected static Map<String, Object> createCrossClusterAccessApiKey(String accessJson, String certificateIdentity) {
322409
initFulfillingClusterClient();
323410
final var createCrossClusterApiKeyRequest = new Request("POST", "/_security/cross_cluster/api_key");

0 commit comments

Comments
 (0)