1414import org .elasticsearch .client .RequestOptions ;
1515import org .elasticsearch .client .Response ;
1616import org .elasticsearch .client .ResponseException ;
17+ import org .elasticsearch .common .io .Streams ;
1718import org .elasticsearch .common .settings .Settings ;
19+ import org .elasticsearch .common .xcontent .XContentHelper ;
1820import org .elasticsearch .core .Strings ;
1921import org .elasticsearch .search .SearchHit ;
2022import org .elasticsearch .search .SearchResponseUtils ;
2123import org .elasticsearch .test .cluster .ElasticsearchCluster ;
24+ import org .elasticsearch .test .cluster .LogType ;
2225import org .elasticsearch .test .cluster .util .resource .Resource ;
26+ import org .elasticsearch .xcontent .XContentType ;
27+ import org .elasticsearch .xpack .security .audit .AuditLevel ;
2328import org .junit .ClassRule ;
2429import org .junit .rules .RuleChain ;
2530import org .junit .rules .TestRule ;
3944
4045public 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