This is an automated email from the ASF dual-hosted git repository.

somandal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 388497cabfc [audit] Implement support for comma-separated header 
filtering (#16694)
388497cabfc is described below

commit 388497cabfceab313d31194b4e67b1626865f069
Author: Suvodeep Pyne <[email protected]>
AuthorDate: Wed Aug 27 11:28:20 2025 -0700

    [audit] Implement support for comma-separated header filtering (#16694)
    
    * [audit] Update captureRequestHeaders to accept comma-separated values and 
filter headers accordingly
    
    * [audit] Enhance header capture functionality with comprehensive tests for 
various string formats and edge cases
---
 .../org/apache/pinot/common/audit/AuditConfig.java |   8 +-
 .../pinot/common/audit/AuditRequestProcessor.java  |  72 +++++++++--
 .../pinot/common/audit/AuditConfigManagerTest.java |  43 +++++--
 .../common/audit/AuditRequestProcessorTest.java    | 131 ++++++++++++++++++++-
 4 files changed, 228 insertions(+), 26 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditConfig.java 
b/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditConfig.java
index 957b2be5195..4de6a5848f0 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditConfig.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditConfig.java
@@ -36,7 +36,7 @@ public final class AuditConfig {
   private boolean _captureRequestPayload = false;
 
   @JsonProperty("capture.request.headers")
-  private boolean _captureRequestHeaders = false;
+  private String _captureRequestHeaders = "";
 
   @JsonProperty("payload.size.max.bytes")
   private int _maxPayloadSize = 10_240;
@@ -60,11 +60,11 @@ public final class AuditConfig {
     _captureRequestPayload = captureRequestPayload;
   }
 
-  public boolean isCaptureRequestHeaders() {
+  public String getCaptureRequestHeaders() {
     return _captureRequestHeaders;
   }
 
-  public void setCaptureRequestHeaders(boolean captureRequestHeaders) {
+  public void setCaptureRequestHeaders(String captureRequestHeaders) {
     _captureRequestHeaders = captureRequestHeaders;
   }
 
@@ -87,7 +87,7 @@ public final class AuditConfig {
   @Override
   public String toString() {
     return "AuditConfig{" + "enabled=" + _enabled + ", captureRequestPayload=" 
+ _captureRequestPayload
-        + ", captureRequestHeaders=" + _captureRequestHeaders + ", 
maxPayloadSize=" + _maxPayloadSize
+        + ", captureRequestHeaders='" + _captureRequestHeaders + "', 
maxPayloadSize=" + _maxPayloadSize
         + ", excludedEndpoints='" + _excludedEndpoints + "'}";
   }
 }
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditRequestProcessor.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditRequestProcessor.java
index fea1cd69840..d7a8ecbde2d 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditRequestProcessor.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/audit/AuditRequestProcessor.java
@@ -18,10 +18,14 @@
  */
 package org.apache.pinot.common.audit;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.time.Instant;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.inject.Inject;
 import javax.inject.Singleton;
 import javax.ws.rs.container.ContainerRequestContext;
@@ -52,19 +56,40 @@ public class AuditRequestProcessor {
    * If a key has multiple values, the list of values is added instead.
    *
    * @param multimap the input MultivaluedMap containing keys and their 
associated values
+   * @param allowedKeys optional set of allowed keys for case-insensitive 
filtering.
+   *                    If null or empty, all keys are included
    * @return a Map where each key is mapped to either a single value or a list 
of values
    */
-  private static Map<String, Object> toMap(MultivaluedMap<String, String> 
multimap) {
-    Map<String, Object> queryMap = new HashMap<>();
+  private static Map<String, Object> toMap(MultivaluedMap<String, String> 
multimap, Set<String> allowedKeys) {
+    Map<String, Object> resultMap = new HashMap<>();
+    boolean filterKeys = allowedKeys != null && !allowedKeys.isEmpty();
+
     for (Map.Entry<String, List<String>> entry : multimap.entrySet()) {
+      String key = entry.getKey();
+      // Skip if filtering is enabled and key is not in allowed list 
(case-insensitive)
+      if (filterKeys && !allowedKeys.contains(key.toLowerCase())) {
+        continue;
+      }
+
       List<String> values = entry.getValue();
       if (values.size() == 1) {
-        queryMap.put(entry.getKey(), values.get(0));
+        resultMap.put(key, values.get(0));
       } else {
-        queryMap.put(entry.getKey(), values);
+        resultMap.put(key, values);
       }
     }
-    return queryMap;
+    return resultMap;
+  }
+
+  /**
+   * Converts a MultivaluedMap into a Map of query parameters without 
filtering.
+   * Backward compatibility method.
+   *
+   * @param multimap the input MultivaluedMap containing keys and their 
associated values
+   * @return a Map where each key is mapped to either a single value or a list 
of values
+   */
+  private static Map<String, Object> toMap(MultivaluedMap<String, String> 
multimap) {
+    return toMap(multimap, null);
   }
 
   public AuditEvent processRequest(ContainerRequestContext requestContext, 
String remoteAddr) {
@@ -140,10 +165,15 @@ public class AuditRequestProcessor {
       }
 
       final AuditConfig config = _configManager.getCurrentConfig();
-      if (config.isCaptureRequestHeaders()) {
-        MultivaluedMap<String, String> headers = requestContext.getHeaders();
-        if (!headers.isEmpty()) {
-          payload.setHeaders(toMap(headers));
+
+      Set<String> allowedHeaders = 
parseAllowedHeaders(config.getCaptureRequestHeaders());
+      if (!allowedHeaders.isEmpty()) {
+        MultivaluedMap<String, String> allHeaders = 
requestContext.getHeaders();
+        if (!allHeaders.isEmpty()) {
+          Map<String, Object> filteredHeaders = toMap(allHeaders, 
allowedHeaders);
+          if (!filteredHeaders.isEmpty()) {
+            payload.setHeaders(filteredHeaders);
+          }
         }
       }
 
@@ -174,6 +204,30 @@ public class AuditRequestProcessor {
    * @param maxPayloadSize maximum bytes to read from the request body
    * @return the request body as string (potentially truncated)
    */
+  /**
+   * Parses a comma-separated list of headers into a Set of lowercase header 
names
+   * for case-insensitive comparison.
+   *
+   * @param headerList comma-separated list of header names
+   * @return Set of lowercase header names, empty if headerList is blank
+   */
+  @VisibleForTesting
+  static Set<String> parseAllowedHeaders(String headerList) {
+    if (StringUtils.isBlank(headerList)) {
+      return Collections.emptySet();
+    }
+
+    Set<String> headers = new HashSet<>();
+    for (String header : headerList.split(",")) {
+      String trimmed = header.trim();
+      if (!trimmed.isEmpty()) {
+        // Store as lowercase for case-insensitive comparison
+        headers.add(trimmed.toLowerCase());
+      }
+    }
+    return headers;
+  }
+
   private String readRequestBody(ContainerRequestContext requestContext, int 
maxPayloadSize) {
     // TODO spyne to be implemented
     return null;
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditConfigManagerTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditConfigManagerTest.java
index 84dfbffc9a5..a3ebd0e3932 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditConfigManagerTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditConfigManagerTest.java
@@ -38,7 +38,7 @@ public class AuditConfigManagerTest {
     Map<String, String> properties = new HashMap<>();
     properties.put("pinot.audit.enabled", "true");
     properties.put("pinot.audit.capture.request.payload.enabled", "true");
-    properties.put("pinot.audit.capture.request.headers", "true");
+    properties.put("pinot.audit.capture.request.headers", 
"Content-Type,X-Request-Id,Authorization");
     properties.put("pinot.audit.payload.size.max.bytes", "20480");
     properties.put("pinot.audit.excluded.endpoints", "/health,/metrics");
     properties.put("some.other.config", "value");
@@ -53,7 +53,7 @@ public class AuditConfigManagerTest {
     AuditConfig config = manager.getCurrentConfig();
     assertThat(config.isEnabled()).isTrue();
     assertThat(config.isCaptureRequestPayload()).isTrue();
-    assertThat(config.isCaptureRequestHeaders()).isTrue();
+    
assertThat(config.getCaptureRequestHeaders()).isEqualTo("Content-Type,X-Request-Id,Authorization");
     assertThat(config.getMaxPayloadSize()).isEqualTo(20480);
     assertThat(config.getExcludedEndpoints()).isEqualTo("/health,/metrics");
   }
@@ -78,7 +78,7 @@ public class AuditConfigManagerTest {
     assertThat(config.getMaxPayloadSize()).isEqualTo(5000);
     // Verify defaults for unspecified configs
     assertThat(config.isCaptureRequestPayload()).isFalse();
-    assertThat(config.isCaptureRequestHeaders()).isFalse();
+    assertThat(config.getCaptureRequestHeaders()).isEmpty();
     assertThat(config.getExcludedEndpoints()).isEmpty();
   }
 
@@ -97,7 +97,7 @@ public class AuditConfigManagerTest {
     AuditConfig config = manager.getCurrentConfig();
     assertThat(config.isEnabled()).isFalse();
     assertThat(config.isCaptureRequestPayload()).isFalse();
-    assertThat(config.isCaptureRequestHeaders()).isFalse();
+    assertThat(config.getCaptureRequestHeaders()).isEmpty();
     assertThat(config.getMaxPayloadSize()).isEqualTo(10240);
     assertThat(config.getExcludedEndpoints()).isEmpty();
   }
@@ -132,7 +132,7 @@ public class AuditConfigManagerTest {
     Map<String, String> properties = new HashMap<>();
     properties.put("pinot.audit.enabled", "true");
     properties.put("pinot.audit.capture.request.payload.enabled", "false");
-    properties.put("pinot.audit.capture.request.headers", "true");
+    properties.put("pinot.audit.capture.request.headers", 
"X-User-Id,X-Session-Token");
     properties.put("some.other.config", "value");
     properties.put("another.config", "123");
 
@@ -142,7 +142,7 @@ public class AuditConfigManagerTest {
     // Then
     assertThat(config.isEnabled()).isTrue();
     assertThat(config.isCaptureRequestPayload()).isFalse();
-    assertThat(config.isCaptureRequestHeaders()).isTrue();
+    
assertThat(config.getCaptureRequestHeaders()).isEqualTo("X-User-Id,X-Session-Token");
     // Verify defaults for unspecified fields
     assertThat(config.getMaxPayloadSize()).isEqualTo(10240);
     assertThat(config.getExcludedEndpoints()).isEmpty();
@@ -211,7 +211,7 @@ public class AuditConfigManagerTest {
     Map<String, String> customProperties = new HashMap<>();
     customProperties.put("pinot.audit.enabled", "true");
     customProperties.put("pinot.audit.capture.request.payload.enabled", 
"true");
-    customProperties.put("pinot.audit.capture.request.headers", "true");
+    customProperties.put("pinot.audit.capture.request.headers", 
"X-Trace-Id,X-Correlation-Id");
     customProperties.put("pinot.audit.payload.size.max.bytes", "50000");
     customProperties.put("pinot.audit.excluded.endpoints", "/test,/debug");
     manager.onChange(customProperties.keySet(), customProperties);
@@ -220,7 +220,7 @@ public class AuditConfigManagerTest {
     AuditConfig customConfig = manager.getCurrentConfig();
     assertThat(customConfig.isEnabled()).isTrue();
     assertThat(customConfig.isCaptureRequestPayload()).isTrue();
-    assertThat(customConfig.isCaptureRequestHeaders()).isTrue();
+    
assertThat(customConfig.getCaptureRequestHeaders()).isEqualTo("X-Trace-Id,X-Correlation-Id");
     assertThat(customConfig.getMaxPayloadSize()).isEqualTo(50000);
     assertThat(customConfig.getExcludedEndpoints()).isEqualTo("/test,/debug");
 
@@ -233,8 +233,33 @@ public class AuditConfigManagerTest {
     AuditConfig defaultConfig = manager.getCurrentConfig();
     assertThat(defaultConfig.isEnabled()).isFalse();
     assertThat(defaultConfig.isCaptureRequestPayload()).isFalse();
-    assertThat(defaultConfig.isCaptureRequestHeaders()).isFalse();
+    assertThat(defaultConfig.getCaptureRequestHeaders()).isEmpty();
     assertThat(defaultConfig.getMaxPayloadSize()).isEqualTo(10240);
     assertThat(defaultConfig.getExcludedEndpoints()).isEmpty();
   }
+
+  @Test
+  public void testConfigurationStringFormats() {
+    Map<String, String> properties = new HashMap<>();
+
+    // Test various string formats
+    properties.put("pinot.audit.capture.request.headers", 
"Header1,Header2,Header3");
+    AuditConfig config1 = 
AuditConfigManager.buildFromClusterConfig(properties);
+    
assertThat(config1.getCaptureRequestHeaders()).isEqualTo("Header1,Header2,Header3");
+
+    // Test empty string
+    properties.put("pinot.audit.capture.request.headers", "");
+    AuditConfig config2 = 
AuditConfigManager.buildFromClusterConfig(properties);
+    assertThat(config2.getCaptureRequestHeaders()).isEmpty();
+
+    // Test single header
+    properties.put("pinot.audit.capture.request.headers", "Content-Type");
+    AuditConfig config3 = 
AuditConfigManager.buildFromClusterConfig(properties);
+    assertThat(config3.getCaptureRequestHeaders()).isEqualTo("Content-Type");
+
+    // Test headers with mixed case and special characters
+    properties.put("pinot.audit.capture.request.headers", 
"Content-Type,X-Request-ID,User-Agent,X-Custom-123");
+    AuditConfig config4 = 
AuditConfigManager.buildFromClusterConfig(properties);
+    
assertThat(config4.getCaptureRequestHeaders()).isEqualTo("Content-Type,X-Request-ID,User-Agent,X-Custom-123");
+  }
 }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditRequestProcessorTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditRequestProcessorTest.java
index 2e01c08a4d1..3e1edc9e332 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditRequestProcessorTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/audit/AuditRequestProcessorTest.java
@@ -21,6 +21,7 @@ package org.apache.pinot.common.audit;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.ws.rs.container.ContainerRequestContext;
 import javax.ws.rs.core.MultivaluedHashMap;
 import javax.ws.rs.core.MultivaluedMap;
@@ -68,7 +69,7 @@ public class AuditRequestProcessorTest {
     _defaultConfig = new AuditConfig();
     _defaultConfig.setEnabled(true);
     _defaultConfig.setCaptureRequestPayload(false);
-    _defaultConfig.setCaptureRequestHeaders(false);
+    _defaultConfig.setCaptureRequestHeaders("");
     _defaultConfig.setMaxPayloadSize(10240);
     _defaultConfig.setExcludedEndpoints("");
 
@@ -139,7 +140,7 @@ public class AuditRequestProcessorTest {
 
   @Test
   public void testCaptureHeadersWhenEnabled() {
-    _defaultConfig.setCaptureRequestHeaders(true);
+    
_defaultConfig.setCaptureRequestHeaders("Content-Type,X-Custom-Header,Authorization,X-Password");
     MultivaluedMap<String, String> headers =
         createHeaders("Content-Type", "application/json", "X-Custom-Header", 
"custom-value", "Authorization",
             "Bearer token123",  // Should be filtered out
@@ -167,7 +168,7 @@ public class AuditRequestProcessorTest {
 
   @Test
   public void testSkipHeadersWhenDisabled() {
-    _defaultConfig.setCaptureRequestHeaders(false);
+    _defaultConfig.setCaptureRequestHeaders("");
     MultivaluedMap<String, String> headers = createHeaders("Content-Type", 
"application/json");
 
     when(_requestContext.getUriInfo()).thenReturn(_uriInfo);
@@ -187,7 +188,8 @@ public class AuditRequestProcessorTest {
 
   @Test
   public void testFilterSensitiveHeaders() {
-    _defaultConfig.setCaptureRequestHeaders(true);
+    _defaultConfig.setCaptureRequestHeaders(
+        
"authorization,x-auth-token,password-header,api-secret,x-api-key,content-type");
     MultivaluedMap<String, String> headers =
         createHeaders("authorization", "Bearer token123", "x-auth-token", 
"token456", "password-header", "pass123",
             "api-secret", "secret789", "x-api-key", "key123",
@@ -240,4 +242,125 @@ public class AuditRequestProcessorTest {
     // The main processRequest catches all exceptions and returns null, so 
test for that
     assertThat(result).isNull();
   }
+
+  @Test
+  public void testParseAllowedHeadersEdgeCases() {
+    // Empty/null/whitespace
+    assertThat(AuditRequestProcessor.parseAllowedHeaders("")).isEmpty();
+    assertThat(AuditRequestProcessor.parseAllowedHeaders("   ")).isEmpty();
+    assertThat(AuditRequestProcessor.parseAllowedHeaders(null)).isEmpty();
+
+    // Single header
+    Set<String> singleHeader = 
AuditRequestProcessor.parseAllowedHeaders("Content-Type");
+    assertThat(singleHeader).containsExactly("content-type");
+
+    // Malformed comma separation
+    Set<String> malformed1 = 
AuditRequestProcessor.parseAllowedHeaders("Header1,,Header2");
+    assertThat(malformed1).containsExactlyInAnyOrder("header1", "header2");
+
+    Set<String> malformed2 = 
AuditRequestProcessor.parseAllowedHeaders(",Header1,Header2,");
+    assertThat(malformed2).containsExactlyInAnyOrder("header1", "header2");
+
+    // Whitespace handling
+    Set<String> withWhitespace = AuditRequestProcessor.parseAllowedHeaders(" 
Content-Type , X-Custom ");
+    assertThat(withWhitespace).containsExactly("content-type", "x-custom");
+  }
+
+  @Test
+  public void testHeaderFilteringCaseInsensitive() {
+    
_defaultConfig.setCaptureRequestHeaders("content-type,authorization,x-custom-header");
+
+    MultivaluedMap<String, String> headers = createHeaders(
+        "Content-Type", "application/json",      // Different case
+        "AUTHORIZATION", "Bearer token",         // All caps
+        "x-custom-header", "value",              // All lower
+        "X-Ignored-Header", "ignored"            // Not in config
+    );
+
+    when(_requestContext.getUriInfo()).thenReturn(_uriInfo);
+    when(_uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>());
+    when(_uriInfo.getPath()).thenReturn("/test");
+    when(_requestContext.getMethod()).thenReturn("GET");
+    when(_requestContext.getHeaders()).thenReturn(headers);
+
+    AuditEvent result = _processor.processRequest(_requestContext, "10.0.0.1");
+
+    assertThat(result).isNotNull();
+    AuditEvent.AuditRequestPayload payload = result.getRequest();
+    assertThat(payload).isNotNull();
+    Map<String, Object> capturedHeaders = payload.getHeaders();
+
+    // Should capture first 3, ignore the 4th
+    assertThat(capturedHeaders).hasSize(3);
+    assertThat(capturedHeaders).containsKeys("Content-Type", "AUTHORIZATION", 
"x-custom-header");
+    assertThat(capturedHeaders).doesNotContainKey("X-Ignored-Header");
+  }
+
+  @Test
+  public void testHeaderValueHandling() {
+    
_defaultConfig.setCaptureRequestHeaders("single-value,multi-value,empty-value");
+
+    MultivaluedMap<String, String> headers = new MultivaluedHashMap<>();
+    headers.add("single-value", "value1");
+    headers.addAll("multi-value", Arrays.asList("val1", "val2", "val3"));
+    headers.add("empty-value", "");
+
+    when(_requestContext.getUriInfo()).thenReturn(_uriInfo);
+    when(_uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>());
+    when(_uriInfo.getPath()).thenReturn("/test");
+    when(_requestContext.getMethod()).thenReturn("GET");
+    when(_requestContext.getHeaders()).thenReturn(headers);
+
+    AuditEvent result = _processor.processRequest(_requestContext, "10.0.0.1");
+
+    assertThat(result).isNotNull();
+    AuditEvent.AuditRequestPayload payload = result.getRequest();
+    assertThat(payload).isNotNull();
+    Map<String, Object> capturedHeaders = payload.getHeaders();
+
+    // Single value stored as String
+    assertThat(capturedHeaders.get("single-value")).isEqualTo("value1");
+
+    // Multiple values stored as List
+    @SuppressWarnings("unchecked")
+    List<String> multiValues = (List<String>) 
capturedHeaders.get("multi-value");
+    assertThat(multiValues).containsExactly("val1", "val2", "val3");
+
+    // Empty value still captured
+    assertThat(capturedHeaders.get("empty-value")).isEqualTo("");
+  }
+
+  @Test
+  public void testCompleteHeaderCaptureFlow() {
+    // Configure specific headers
+    
_defaultConfig.setCaptureRequestHeaders("Content-Type,X-Request-ID,User-Agent");
+
+    // Create request with mixed case headers + extras
+    MultivaluedMap<String, String> headers = createHeaders(
+        "content-type", "application/json",
+        "X-REQUEST-ID", "req-123",
+        "user-agent", "test-client",
+        "Cookie", "session=abc",           // Should be ignored
+        "Accept", "application/json"       // Should be ignored
+    );
+
+    when(_requestContext.getUriInfo()).thenReturn(_uriInfo);
+    when(_uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>());
+    when(_uriInfo.getPath()).thenReturn("/test");
+    when(_requestContext.getMethod()).thenReturn("GET");
+    when(_requestContext.getHeaders()).thenReturn(headers);
+
+    AuditEvent result = _processor.processRequest(_requestContext, "10.0.0.1");
+
+    assertThat(result).isNotNull();
+    AuditEvent.AuditRequestPayload payload = result.getRequest();
+    assertThat(payload).isNotNull();
+    Map<String, Object> capturedHeaders = payload.getHeaders();
+
+    assertThat(capturedHeaders).hasSize(3);
+    assertThat(capturedHeaders).containsOnlyKeys("content-type", 
"X-REQUEST-ID", "user-agent");
+    assertThat(capturedHeaders).containsEntry("content-type", 
"application/json");
+    assertThat(capturedHeaders).containsEntry("X-REQUEST-ID", "req-123");
+    assertThat(capturedHeaders).containsEntry("user-agent", "test-client");
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to