diff --git a/CHANGELOG.md b/CHANGELOG.md index cc07ec4c0e..9e32519311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased 3.x] ### Added +- Add support for Basic Authentication in webhook audit log sink using `plugins.security.audit.config.username` and `plugins.security.audit.config.password` ([#5792](https://github.com/opensearch-project/security/pull/5792)) ### Changed - Ensure all restHeaders from ActionPlugin.getRestHeaders are carried to threadContext for tracing ([#5396](https://github.com/opensearch-project/security/pull/5396)) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 9802e95680..6d786a554e 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -1827,14 +1827,14 @@ public List> getSettings() { ); // not filtered here settings.add( Setting.simpleString( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME, + ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME, Property.NodeScope, Property.Filtered ) ); settings.add( Setting.simpleString( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD, + ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD, Property.NodeScope, Property.Filtered ) diff --git a/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java b/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java index 7bde676399..744e2ab46e 100644 --- a/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java +++ b/src/main/java/org/opensearch/security/auditlog/sink/ExternalOpenSearchSink.java @@ -83,8 +83,8 @@ public ExternalOpenSearchSink( ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL_CLIENT_AUTH, ConfigConstants.OPENDISTRO_SECURITY_AUDIT_SSL_ENABLE_SSL_CLIENT_AUTH_DEFAULT ); - final String user = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME); - final String password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD); + final String user = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME); + final String password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD); final HttpClientBuilder builder = HttpClient.builder(servers.toArray(new String[0])); diff --git a/src/main/java/org/opensearch/security/auditlog/sink/WebhookSink.java b/src/main/java/org/opensearch/security/auditlog/sink/WebhookSink.java index 3e11feb85e..587acf14cd 100644 --- a/src/main/java/org/opensearch/security/auditlog/sink/WebhookSink.java +++ b/src/main/java/org/opensearch/security/auditlog/sink/WebhookSink.java @@ -18,6 +18,7 @@ import java.nio.file.Path; import java.security.KeyStore; import java.security.cert.X509Certificate; +import java.util.Base64; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLContext; @@ -60,6 +61,9 @@ public class WebhookSink extends AuditLogSink { WebhookFormat webhookFormat = null; final boolean verifySSL; final KeyStore effectiveTruststore; + private final String username; + private final String password; + private final String basicAuthHeader; public WebhookSink( final String name, @@ -77,6 +81,19 @@ public WebhookSink( final String webhookUrl = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_WEBHOOK_URL); final String format = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_WEBHOOK_FORMAT); + // Read basic auth credentials + this.username = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME); + this.password = sinkSettings.get(ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD); + + // Generate Basic Auth header if credentials are provided + if (this.username != null && this.password != null) { + String credentials = this.username + ":" + this.password; + String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + this.basicAuthHeader = "Basic " + encodedCredentials; + } else { + this.basicAuthHeader = null; + } + verifySSL = sinkSettings.getAsBoolean(ConfigConstants.SECURITY_AUDIT_WEBHOOK_SSL_VERIFY, true); httpClient = getHttpClient(); @@ -225,6 +242,12 @@ boolean get(AuditMessage msg) { protected boolean doGet(String url) { HttpGet httpGet = new HttpGet(url); + + // Add Basic Auth header if credentials are configured + if (basicAuthHeader != null) { + httpGet.setHeader("Authorization", basicAuthHeader); + } + CloseableHttpResponse serverResponse = null; try { serverResponse = httpClient.execute(httpGet); @@ -280,6 +303,11 @@ protected boolean doPost(String url, String payload) { HttpPost postRequest = new HttpPost(url); + // Add Basic Auth header if credentials are configured + if (basicAuthHeader != null) { + postRequest.setHeader("Authorization", basicAuthHeader); + } + StringEntity input = new StringEntity(payload, webhookFormat.contentType.withCharset(StandardCharsets.UTF_8)); postRequest.setEntity(input); diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 1116923033..a7a271c758 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -243,8 +243,8 @@ public class ConfigConstants { // External OpenSearch public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_HTTP_ENDPOINTS = "http_endpoints"; - public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME = "username"; - public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD = "password"; + public static final String SECURITY_AUDIT_CONFIG_USERNAME = "username"; + public static final String SECURITY_AUDIT_CONFIG_PASSWORD = "password"; public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL = "enable_ssl"; public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_VERIFY_HOSTNAMES = "verify_hostnames"; public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL_CLIENT_AUTH = "enable_ssl_client_auth"; diff --git a/src/test/java/org/opensearch/security/auditlog/helper/TestHttpHandler.java b/src/test/java/org/opensearch/security/auditlog/helper/TestHttpHandler.java index d4f68e8291..12cc5e6956 100644 --- a/src/test/java/org/opensearch/security/auditlog/helper/TestHttpHandler.java +++ b/src/test/java/org/opensearch/security/auditlog/helper/TestHttpHandler.java @@ -13,9 +13,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.io.HttpRequestHandler; @@ -26,12 +29,19 @@ public class TestHttpHandler implements HttpRequestHandler { public String method; public String uri; public String body; + public Map headers; @Override public void handle(ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context) throws HttpException, IOException { this.method = request.getMethod(); this.uri = request.getRequestUri(); + // Capture headers + this.headers = new HashMap<>(); + for (Header header : request.getHeaders()) { + this.headers.put(header.getName(), header.getValue()); + } + HttpEntity entity = request.getEntity(); body = EntityUtils.toString(entity, StandardCharsets.UTF_8); } @@ -40,5 +50,6 @@ public void reset() { this.body = null; this.uri = null; this.method = null; + this.headers = null; } } diff --git a/src/test/java/org/opensearch/security/auditlog/integration/SSLAuditlogTest.java b/src/test/java/org/opensearch/security/auditlog/integration/SSLAuditlogTest.java index 236643174c..5099cdc847 100644 --- a/src/test/java/org/opensearch/security/auditlog/integration/SSLAuditlogTest.java +++ b/src/test/java/org/opensearch/security/auditlog/integration/SSLAuditlogTest.java @@ -91,14 +91,8 @@ public void testExternalPemUserPass() throws Exception { ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMKEY_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("auditlog/spock.key.pem") ) - .put( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME, - "admin" - ) - .put( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD, - "admin" - ) + .put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME, "admin") + .put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD, "admin") .build(); setup(additionalSettings); @@ -174,14 +168,8 @@ public void testExternalPemUserPassTp() throws Exception { + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMTRUSTEDCAS_FILEPATH, FileHelper.getAbsoluteFilePathFromClassPath("auditlog/chain-ca.pem") ) - .put( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME, - "admin" - ) - .put( - ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD, - "admin" - ) + .put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_USERNAME, "admin") + .put(ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX + ConfigConstants.SECURITY_AUDIT_CONFIG_PASSWORD, "admin") .build(); setup(additionalSettings); diff --git a/src/test/java/org/opensearch/security/auditlog/sink/WebhookAuditLogTest.java b/src/test/java/org/opensearch/security/auditlog/sink/WebhookAuditLogTest.java index 1959ceb2c8..5109769674 100644 --- a/src/test/java/org/opensearch/security/auditlog/sink/WebhookAuditLogTest.java +++ b/src/test/java/org/opensearch/security/auditlog/sink/WebhookAuditLogTest.java @@ -774,6 +774,162 @@ private SSLContext createSSLContext() throws Exception { return sslContext; } + @Test + public void basicAuthPostTest() throws Exception { + TestHttpHandler handler = new TestHttpHandler(); + + int port = findFreePort(); + server = ServerBootstrap.bootstrap() + .setListenerPort(port) + .setHttpProcessor(HttpProcessors.server("Test/1.1")) + .setRequestRouter((request, context) -> handler) + .create(); + + server.start(); + + String url = "http://localhost:" + port + "/endpoint"; + String username = "test_user"; + String password = "test_password"; + + // Test with basic auth credentials - POST JSON + Settings settings = Settings.builder() + .put("plugins.security.audit.config.webhook.url", url) + .put("plugins.security.audit.config.webhook.format", "json") + .put("plugins.security.audit.config.username", username) + .put("plugins.security.audit.config.password", password) + .put("path.home", ".") + .put( + SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, + FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks") + ) + .build(); + + LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null); + WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback); + AuditMessage msg = MockAuditMessageFactory.validAuditMessage(); + auditlog.store(msg); + + // Verify request was made + assertThat(handler.method, is("POST")); + Assert.assertNotNull(handler.body); + Assert.assertTrue(handler.body.contains("{")); + assertStringContainsAllKeysAndValues(handler.body); + + // Verify Authorization header is present and correct + Assert.assertNotNull(handler.headers); + Assert.assertTrue(handler.headers.containsKey("Authorization")); + String authHeader = handler.headers.get("Authorization"); + Assert.assertTrue(authHeader.startsWith("Basic ")); + + // Decode and verify credentials + String encodedCredentials = authHeader.substring("Basic ".length()); + String decodedCredentials = new String(java.util.Base64.getDecoder().decode(encodedCredentials), StandardCharsets.UTF_8); + Assert.assertEquals(username + ":" + password, decodedCredentials); + + // no message stored on fallback + assertThat(fallback.messages.size(), is(0)); + auditlog.close(); + server.awaitTermination(TimeValue.ofSeconds(3)); + } + + @Test + public void basicAuthGetTest() throws Exception { + TestHttpHandler handler = new TestHttpHandler(); + + int port = findFreePort(); + server = ServerBootstrap.bootstrap() + .setListenerPort(port) + .setHttpProcessor(HttpProcessors.server("Test/1.1")) + .setRequestRouter((request, context) -> handler) + .create(); + + server.start(); + + String url = "http://localhost:" + port + "/endpoint"; + String username = "test_user"; + String password = "test_password"; + + // Test with basic auth credentials - GET + Settings settings = Settings.builder() + .put("plugins.security.audit.config.webhook.url", url) + .put("plugins.security.audit.config.webhook.format", "URL_PARAMETER_GET") + .put("plugins.security.audit.config.username", username) + .put("plugins.security.audit.config.password", password) + .put("path.home", ".") + .put( + SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, + FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks") + ) + .build(); + + LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null); + WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback); + AuditMessage msg = MockAuditMessageFactory.validAuditMessage(); + auditlog.store(msg); + + // Verify request was made with GET method + assertThat(handler.method, is("GET")); + + // Verify Authorization header is present and correct + Assert.assertNotNull(handler.headers); + Assert.assertTrue(handler.headers.containsKey("Authorization")); + String authHeader = handler.headers.get("Authorization"); + Assert.assertTrue(authHeader.startsWith("Basic ")); + + // Decode and verify credentials + String encodedCredentials = authHeader.substring("Basic ".length()); + String decodedCredentials = new String(java.util.Base64.getDecoder().decode(encodedCredentials), StandardCharsets.UTF_8); + Assert.assertEquals(username + ":" + password, decodedCredentials); + + auditlog.close(); + server.awaitTermination(TimeValue.ofSeconds(3)); + } + + @Test + public void webhookWithoutAuthTest() throws Exception { + TestHttpHandler handler = new TestHttpHandler(); + + int port = findFreePort(); + server = ServerBootstrap.bootstrap() + .setListenerPort(port) + .setHttpProcessor(HttpProcessors.server("Test/1.1")) + .setRequestRouter((request, context) -> handler) + .create(); + + server.start(); + + String url = "http://localhost:" + port + "/endpoint"; + + // Test without credentials - should not have Authorization header + Settings settings = Settings.builder() + .put("plugins.security.audit.config.webhook.url", url) + .put("plugins.security.audit.config.webhook.format", "json") + .put("path.home", ".") + .put( + SSLConfigConstants.SECURITY_SSL_TRANSPORT_TRUSTSTORE_FILEPATH, + FileHelper.getAbsoluteFilePathFromClassPath("auditlog/truststore.jks") + ) + .build(); + + LoggingSink fallback = new LoggingSink("test", Settings.EMPTY, null, null); + WebhookSink auditlog = new WebhookSink("name", settings, ConfigConstants.SECURITY_AUDIT_CONFIG_DEFAULT, null, fallback); + AuditMessage msg = MockAuditMessageFactory.validAuditMessage(); + auditlog.store(msg); + + // Verify request was made + assertThat(handler.method, is("POST")); + Assert.assertNotNull(handler.body); + + // Verify Authorization header is NOT present + Assert.assertFalse(handler.headers.containsKey("Authorization")); + + // no message stored on fallback + assertThat(fallback.messages.size(), is(0)); + + auditlog.close(); + server.awaitTermination(TimeValue.ofSeconds(3)); + } + private void assertStringContainsAllKeysAndValues(String in) { Assert.assertTrue(in, in.contains(AuditMessage.FORMAT_VERSION)); Assert.assertTrue(in, in.contains(AuditMessage.CATEGORY));