diff --git a/datahub-frontend/app/auth/AuthModule.java b/datahub-frontend/app/auth/AuthModule.java index ba3a1a7af6..0e5771a77a 100644 --- a/datahub-frontend/app/auth/AuthModule.java +++ b/datahub-frontend/app/auth/AuthModule.java @@ -40,6 +40,7 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.util.HierarchicalNameMapper; import io.micrometer.jmx.JmxConfig; import io.micrometer.jmx.JmxMeterRegistry; +import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; import java.util.Collections; import javax.annotation.Nonnull; @@ -60,6 +61,8 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext import play.Environment; import play.cache.SyncCacheApi; import utils.ConfigUtil; +import utils.CustomHttpClientFactory; +import utils.TruststoreConfig; /** Responsible for configuring, validating, and providing authentication related components. */ @Slf4j @@ -312,8 +315,34 @@ public class AuthModule extends AbstractModule { @Provides @Singleton - protected CloseableHttpClient provideHttpClient() { - return HttpClients.createDefault(); + protected CloseableHttpClient provideCloseableHttpClient(com.typesafe.config.Config config) { + TruststoreConfig tsConfig = TruststoreConfig.fromConfig(config); + try { + if (tsConfig.isValid()) { + return CustomHttpClientFactory.getApacheHttpClient( + tsConfig.path, tsConfig.password, tsConfig.type); + } else { + return HttpClients.createDefault(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to initialize CloseableHttpClient", e); + } + } + + @Provides + @Singleton + protected HttpClient provideHttpClient(com.typesafe.config.Config config) { + TruststoreConfig tsConfig = TruststoreConfig.fromConfig(config); + try { + if (tsConfig.isValid()) { + return CustomHttpClientFactory.getJavaHttpClient( + tsConfig.path, tsConfig.password, tsConfig.type); + } else { + return HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to initialize HttpClient", e); + } } private com.linkedin.restli.client.Client buildRestliClient() { diff --git a/datahub-frontend/app/controllers/Application.java b/datahub-frontend/app/controllers/Application.java index 893d661f62..0131b65030 100644 --- a/datahub-frontend/app/controllers/Application.java +++ b/datahub-frontend/app/controllers/Application.java @@ -41,14 +41,14 @@ public class Application extends Controller { private static final Logger logger = LoggerFactory.getLogger(Application.class.getName()); private static final Set RESTRICTED_HEADERS = Set.of("connection", "host", "content-length", "expect", "upgrade", "transfer-encoding"); - private final HttpClient httpClient = - HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + private final HttpClient httpClient; private final Config config; private final Environment environment; @Inject - public Application(Environment environment, @Nonnull Config config) { + public Application(HttpClient httpClient, Environment environment, @Nonnull Config config) { + this.httpClient = httpClient; this.config = config; this.environment = environment; } diff --git a/datahub-frontend/app/utils/ConfigUtil.java b/datahub-frontend/app/utils/ConfigUtil.java index 5c80389c96..93ac09c295 100644 --- a/datahub-frontend/app/utils/ConfigUtil.java +++ b/datahub-frontend/app/utils/ConfigUtil.java @@ -13,6 +13,12 @@ public class ConfigUtil { public static final String METADATA_SERVICE_USE_SSL_CONFIG_PATH = "metadataService.useSsl"; public static final String METADATA_SERVICE_SSL_PROTOCOL_CONFIG_PATH = "metadataService.sslProtocol"; + public static final String METADATA_SERVICE_SSL_TRUST_STORE_PATH = + "metadataService.truststore.path"; + public static final String METADATA_SERVICE_SSL_TRUST_STORE_PASSWORD = + "metadataService.truststore.password"; + public static final String METADATA_SERVICE_SSL_TRUST_STORE_TYPE = + "metadataService.truststore.type"; // Legacy env-var based config values, for backwards compatibility: public static final String GMS_HOST_ENV_VAR = "DATAHUB_GMS_HOST"; diff --git a/datahub-frontend/app/utils/CustomHttpClientFactory.java b/datahub-frontend/app/utils/CustomHttpClientFactory.java new file mode 100644 index 0000000000..d63ae4bc9f --- /dev/null +++ b/datahub-frontend/app/utils/CustomHttpClientFactory.java @@ -0,0 +1,73 @@ +package utils; + +import java.io.FileInputStream; +import java.net.http.HttpClient; +import java.security.KeyStore; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CustomHttpClientFactory { + private static final Logger log = + LoggerFactory.getLogger(CustomHttpClientFactory.class.getName()); + + public static SSLContext getSslContext(String path, String pass, String type) throws Exception { + return createSslContext(path, pass, type); + } + + public static HttpClient getJavaHttpClient(String path, String pass, String type) { + try { + log.info( + "Initializing Java HttpClient with custom truststore at '{}' and type '{}'", path, type); + return HttpClient.newBuilder().sslContext(getSslContext(path, pass, type)).build(); + } catch (Exception e) { + log.warn( + "Failed to initialize Java HttpClient with custom truststore at '{}'. Falling back to default HttpClient. Reason: {}", + path, + e.getMessage(), + e); + return HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + } + } + + public static CloseableHttpClient getApacheHttpClient(String path, String pass, String type) { + try { + log.info( + "Initializing Apache CloseableHttpClient with custom truststore at '{}' and type '{}'", + path, + type); + return HttpClients.custom() + .setSSLSocketFactory(new SSLConnectionSocketFactory(getSslContext(path, pass, type))) + .build(); + } catch (Exception e) { + log.warn( + "Failed to initialize Apache HttpClient with custom truststore at '{}'. Falling back to default HttpClient. Reason: {}", + path, + e.getMessage(), + e); + return HttpClients.createDefault(); + } + } + + public static SSLContext createSslContext( + String truststorePath, String truststorePassword, String truststoreType) throws Exception { + log.info( + "Creating SSLContext with truststore at '{}' and type '{}'", + truststorePath, + truststoreType); + KeyStore trustStore = KeyStore.getInstance(truststoreType); + try (FileInputStream fis = new FileInputStream(truststorePath)) { + trustStore.load(fis, truststorePassword.toCharArray()); + } + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } +} diff --git a/datahub-frontend/app/utils/TruststoreConfig.java b/datahub-frontend/app/utils/TruststoreConfig.java new file mode 100644 index 0000000000..1ae09ecb76 --- /dev/null +++ b/datahub-frontend/app/utils/TruststoreConfig.java @@ -0,0 +1,33 @@ +package utils; + +import com.typesafe.config.Config; + +public class TruststoreConfig { + public final String path; + public final String password; + public final String type; + public final boolean metadataServiceUseSsl; + + public TruststoreConfig( + String path, String password, String type, boolean metadataServiceUseSsl) { + this.path = path; + this.password = password; + this.type = type; + this.metadataServiceUseSsl = metadataServiceUseSsl; + } + + public boolean isValid() { + return metadataServiceUseSsl && path != null && password != null; + } + + public static TruststoreConfig fromConfig(Config config) { + return new TruststoreConfig( + ConfigUtil.getString(config, ConfigUtil.METADATA_SERVICE_SSL_TRUST_STORE_PATH, null), + ConfigUtil.getString(config, ConfigUtil.METADATA_SERVICE_SSL_TRUST_STORE_PASSWORD, null), + ConfigUtil.getString(config, ConfigUtil.METADATA_SERVICE_SSL_TRUST_STORE_TYPE, "PKCS12"), + ConfigUtil.getBoolean( + config, + ConfigUtil.METADATA_SERVICE_USE_SSL_CONFIG_PATH, + ConfigUtil.DEFAULT_METADATA_SERVICE_USE_SSL)); + } +} diff --git a/datahub-frontend/conf/application.conf b/datahub-frontend/conf/application.conf index 2d2dd7e26f..7772a95c05 100644 --- a/datahub-frontend/conf/application.conf +++ b/datahub-frontend/conf/application.conf @@ -232,6 +232,9 @@ play.http.session.maxAge = ${?MAX_SESSION_TOKEN_AGE} metadataService.host=${?DATAHUB_GMS_HOST} metadataService.port=${?DATAHUB_GMS_PORT} metadataService.useSsl=${?DATAHUB_GMS_USE_SSL} # Internal SSL is not fully supported yet. +metadataService.truststore.path=${?DATAHUB_GMS_SSL_TRUSTSTORE_PATH} +metadataService.truststore.password=${?DATAHUB_GMS_SSL_TRUSTSTORE_PASSWORD} +metadataService.truststore.type=${?DATAHUB_GMS_SSL_TRUSTSTORE_TYPE} # Set to "true" to enable Metadata Service Authentication. False BY DEFAULT. metadataService.auth.enabled=${?METADATA_SERVICE_AUTH_ENABLED} @@ -257,4 +260,4 @@ entityClient.restli.get.batchConcurrency = ${?ENTITY_CLIENT_RESTLI_GET_BATCH_CON graphql.verbose.logging = false graphql.verbose.logging = ${?GRAPHQL_VERBOSE_LOGGING} graphql.verbose.slowQueryMillis = 2500 -graphql.verbose.slowQueryMillis = ${?GRAPHQL_VERBOSE_LONG_QUERY_MILLIS} \ No newline at end of file +graphql.verbose.slowQueryMillis = ${?GRAPHQL_VERBOSE_LONG_QUERY_MILLIS} diff --git a/datahub-frontend/test/utils/CustomHttpClientFactoryTest.java b/datahub-frontend/test/utils/CustomHttpClientFactoryTest.java new file mode 100644 index 0000000000..02a9970417 --- /dev/null +++ b/datahub-frontend/test/utils/CustomHttpClientFactoryTest.java @@ -0,0 +1,114 @@ +package utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.net.http.HttpClient; +import java.nio.file.Path; +import java.util.List; +import javax.net.ssl.SSLContext; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CustomHttpClientFactoryTest { + + @TempDir Path tempDir; + + private static final String TRUSTSTORE_PASSWORD = "testpassword"; + private static final String TRUSTSTORE_TYPE = "PKCS12"; + + // Helper: Generate a temp PKCS12 truststore using keytool + Path generateTempTruststore() throws IOException, InterruptedException { + Path truststorePath = tempDir.resolve("test-truststore-" + System.nanoTime() + ".p12"); + String keytoolPath = System.getenv("KEYTOOL_PATH"); + if (keytoolPath == null) { + keytoolPath = System.getProperty("java.home") + "/bin/keytool"; + } + File keytoolFile = new File(keytoolPath); + if (!keytoolFile.exists()) { + keytoolPath = "keytool"; // fallback to system path + } + List command = + List.of( + keytoolPath, + "-genkeypair", + "-alias", + "testcert", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-storetype", + "PKCS12", + "-keystore", + truststorePath.toString(), + "-validity", + "3650", + "-storepass", + TRUSTSTORE_PASSWORD, + "-dname", + "CN=Test, OU=Dev, O=Example, L=Test, S=Test, C=US"); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + if (exitCode != 0) + throw new RuntimeException( + "Could not generate truststore, exit code: " + exitCode + ", output: " + output); + return truststorePath; + } + + @Test + void testCreateSslContextWithValidTruststore() throws Exception { + Path truststorePath = generateTempTruststore(); + SSLContext context = + CustomHttpClientFactory.createSslContext( + truststorePath.toString(), TRUSTSTORE_PASSWORD, TRUSTSTORE_TYPE); + assertNotNull(context); + assertEquals("TLS", context.getProtocol()); + } + + @Test + void testCreateSslContextWithInvalidTruststoreThrows() { + assertThrows( + Exception.class, + () -> + CustomHttpClientFactory.createSslContext( + "doesnotexist.p12", "wrongpassword", TRUSTSTORE_TYPE)); + } + + @Test + void testGetJavaHttpClientWithValidTruststore() throws Exception { + Path truststorePath = generateTempTruststore(); + HttpClient client = + CustomHttpClientFactory.getJavaHttpClient( + truststorePath.toString(), TRUSTSTORE_PASSWORD, TRUSTSTORE_TYPE); + assertNotNull(client); + } + + @Test + void testGetJavaHttpClientWithInvalidTruststoreFallsBack() { + HttpClient client = + CustomHttpClientFactory.getJavaHttpClient( + "doesnotexist.p12", "wrongpassword", TRUSTSTORE_TYPE); + assertNotNull(client); + } + + @Test + void testGetApacheHttpClientWithValidTruststore() throws Exception { + Path truststorePath = generateTempTruststore(); + CloseableHttpClient client = + CustomHttpClientFactory.getApacheHttpClient( + truststorePath.toString(), TRUSTSTORE_PASSWORD, TRUSTSTORE_TYPE); + assertNotNull(client); + } + + @Test + void testGetApacheHttpClientWithInvalidTruststoreFallsBack() { + CloseableHttpClient client = + CustomHttpClientFactory.getApacheHttpClient( + "doesnotexist.p12", "wrongpassword", TRUSTSTORE_TYPE); + assertNotNull(client); + } +} diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml index 858fe8ef50..39488a4a2d 100644 --- a/metadata-service/configuration/src/main/resources/application.yaml +++ b/metadata-service/configuration/src/main/resources/application.yaml @@ -89,6 +89,10 @@ datahub: host: ${DATAHUB_GMS_HOST:localhost} port: ${DATAHUB_GMS_PORT:8080} useSSL: ${DATAHUB_GMS_USE_SSL:${GMS_USE_SSL:false}} + truststore: + path: ${DATAHUB_GMS_SSL_TRUSTSTORE_PATH:#{null}} # Required if useSSL is true + password: ${DATAHUB_GMS_SSL_TRUSTSTORE_PASSWORD:#{null}} # Required if useSSL is true + type: ${DATAHUB_GMS_SSL_TRUSTSTORE_TYPE:PKCS12} async: request-timeout-ms: ${DATAHUB_GMS_ASYNC_REQUEST_TIMEOUT_MS:55000}