diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index bc81b42903a..1d0c702cdfc 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -71,9 +71,13 @@ import io.grpc.ExperimentalApi; import io.grpc.ManagedChannelBuilder; import io.grpc.MethodDescriptor; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.Attributes; +import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; @@ -952,6 +956,7 @@ public static class Builder private boolean enableEndToEndTracing = SpannerOptions.environment.isEnableEndToEndTracing(); private boolean enableBuiltInMetrics = SpannerOptions.environment.isEnableBuiltInMetrics(); private String monitoringHost = SpannerOptions.environment.getMonitoringHost(); + private SslContext mTLSContext = null; private static String createCustomClientLibToken(String token) { return token + " " + ServiceOptions.getGoogApiClientLibName(); @@ -1485,6 +1490,27 @@ public Builder setEmulatorHost(String emulatorHost) { return this; } + /** + * Configures mTLS authentication using the provided client certificate and key files. mTLS is + * only supported for external spanner hosts. + * + * @param clientCertificate Path to the client certificate file. + * @param clientCertificateKey Path to the client private key file. + * @throws SpannerException If an error occurs while configuring the mTLS context + */ + @ExperimentalApi("https://github.com/googleapis/java-spanner/pull/3574") + public Builder useClientCert(String clientCertificate, String clientCertificateKey) { + try { + this.mTLSContext = + GrpcSslContexts.forClient() + .keyManager(new File(clientCertificate), new File(clientCertificateKey)) + .build(); + } catch (Exception e) { + throw SpannerExceptionFactory.asSpannerException(e); + } + return this; + } + /** * Sets OpenTelemetry object to be used for Spanner Metrics and Traces. GlobalOpenTelemetry will * be used as fallback if this options is not set. @@ -1594,6 +1620,15 @@ public SpannerOptions build() { // As we are using plain text, we should never send any credentials. this.setCredentials(NoCredentials.getInstance()); } + if (mTLSContext != null) { + this.setChannelConfigurator( + builder -> { + if (builder instanceof NettyChannelBuilder) { + ((NettyChannelBuilder) builder).sslContext(mTLSContext); + } + return builder; + }); + } if (this.numChannels == null) { this.numChannels = this.grpcGcpExtensionEnabled ? GRPC_GCP_ENABLED_DEFAULT_CHANNELS : DEFAULT_CHANNELS; diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java index 6e991816ab6..d205699bb9e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java @@ -20,6 +20,8 @@ import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_CONFIG_EMULATOR; import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_PARTITION_MODE; import static com.google.cloud.spanner.connection.ConnectionProperties.CHANNEL_PROVIDER; +import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_CERTIFICATE; +import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_KEY; import static com.google.cloud.spanner.connection.ConnectionProperties.CREDENTIALS_PROVIDER; import static com.google.cloud.spanner.connection.ConnectionProperties.CREDENTIALS_URL; import static com.google.cloud.spanner.connection.ConnectionProperties.DATABASE_ROLE; @@ -225,6 +227,8 @@ public String[] getValidValues() { static final boolean DEFAULT_USE_VIRTUAL_THREADS = false; static final boolean DEFAULT_USE_VIRTUAL_GRPC_TRANSPORT_THREADS = false; static final String DEFAULT_CREDENTIALS = null; + static final String DEFAULT_CLIENT_CERTIFICATE = null; + static final String DEFAULT_CLIENT_KEY = null; static final String DEFAULT_OAUTH_TOKEN = null; static final Integer DEFAULT_MIN_SESSIONS = null; static final Integer DEFAULT_MAX_SESSIONS = null; @@ -263,6 +267,10 @@ public String[] getValidValues() { private static final String DEFAULT_EMULATOR_HOST = "http://localhost:9010"; /** Use plain text is only for local testing purposes. */ static final String USE_PLAIN_TEXT_PROPERTY_NAME = "usePlainText"; + /** Client certificate path to establish mTLS */ + static final String CLIENT_CERTIFICATE_PROPERTY_NAME = "clientCertificate"; + /** Client key path to establish mTLS */ + static final String CLIENT_KEY_PROPERTY_NAME = "clientKey"; /** Name of the 'autocommit' connection property. */ public static final String AUTOCOMMIT_PROPERTY_NAME = "autocommit"; /** Name of the 'readonly' connection property. */ @@ -434,6 +442,12 @@ static boolean isEnableTransactionalConnectionStateForPostgreSQL() { USE_PLAIN_TEXT_PROPERTY_NAME, "Use a plain text communication channel (i.e. non-TLS) for communicating with the server (true/false). Set this value to true for communication with the Cloud Spanner emulator.", DEFAULT_USE_PLAIN_TEXT), + ConnectionProperty.createStringProperty( + CLIENT_CERTIFICATE_PROPERTY_NAME, + "Specifies the file path to the client certificate required for establishing an mTLS connection."), + ConnectionProperty.createStringProperty( + CLIENT_KEY_PROPERTY_NAME, + "Specifies the file path to the client private key required for establishing an mTLS connection."), ConnectionProperty.createStringProperty( USER_AGENT_PROPERTY_NAME, "The custom user-agent property name to use when communicating with Cloud Spanner. This property is intended for internal library usage, and should not be set by applications."), @@ -1291,6 +1305,14 @@ boolean isUsePlainText() { || getInitialConnectionPropertyValue(USE_PLAIN_TEXT); } + String getClientCertificate() { + return getInitialConnectionPropertyValue(CLIENT_CERTIFICATE); + } + + String getClientCertificateKey() { + return getInitialConnectionPropertyValue(CLIENT_KEY); + } + /** * The (custom) user agent string to use for this connection. If null, then the * default JDBC user agent string will be used. diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java index fd40efa8f4a..6f2628e5a04 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java @@ -22,6 +22,8 @@ import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_PARTITION_MODE_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CHANNEL_PROVIDER_PROPERTY_NAME; +import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_CERTIFICATE_PROPERTY_NAME; +import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_KEY_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CREDENTIALS_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.CREDENTIALS_PROVIDER_PROPERTY_NAME; import static com.google.cloud.spanner.connection.ConnectionOptions.DATABASE_ROLE_PROPERTY_NAME; @@ -33,6 +35,8 @@ import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_PARTITION_MODE; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CHANNEL_PROVIDER; +import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_CERTIFICATE; +import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_KEY; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CREDENTIALS; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_DATABASE_ROLE; import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_DATA_BOOST_ENABLED; @@ -192,6 +196,20 @@ public class ConnectionProperties { BooleanConverter.INSTANCE, Context.STARTUP); + static final ConnectionProperty CLIENT_CERTIFICATE = + create( + CLIENT_CERTIFICATE_PROPERTY_NAME, + "Specifies the file path to the client certificate required for establishing an mTLS connection.", + DEFAULT_CLIENT_CERTIFICATE, + StringValueConverter.INSTANCE, + Context.STARTUP); + static final ConnectionProperty CLIENT_KEY = + create( + CLIENT_KEY_PROPERTY_NAME, + "Specifies the file path to the client private key required for establishing an mTLS connection.", + DEFAULT_CLIENT_KEY, + StringValueConverter.INSTANCE, + Context.STARTUP); static final ConnectionProperty CREDENTIALS_URL = create( CREDENTIALS_PROPERTY_NAME, diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java index 81246e41938..9558947156c 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java @@ -161,6 +161,8 @@ static class SpannerPoolKey { private final Boolean enableExtendedTracing; private final Boolean enableApiTracing; private final boolean enableEndToEndTracing; + private final String clientCertificate; + private final String clientCertificateKey; @VisibleForTesting static SpannerPoolKey of(ConnectionOptions options) { @@ -192,6 +194,8 @@ private SpannerPoolKey(ConnectionOptions options) throws IOException { this.enableExtendedTracing = options.isEnableExtendedTracing(); this.enableApiTracing = options.isEnableApiTracing(); this.enableEndToEndTracing = options.isEndToEndTracingEnabled(); + this.clientCertificate = options.getClientCertificate(); + this.clientCertificateKey = options.getClientCertificateKey(); } @Override @@ -214,7 +218,9 @@ public boolean equals(Object o) { && Objects.equals(this.openTelemetry, other.openTelemetry) && Objects.equals(this.enableExtendedTracing, other.enableExtendedTracing) && Objects.equals(this.enableApiTracing, other.enableApiTracing) - && Objects.equals(this.enableEndToEndTracing, other.enableEndToEndTracing); + && Objects.equals(this.enableEndToEndTracing, other.enableEndToEndTracing) + && Objects.equals(this.clientCertificate, other.clientCertificate) + && Objects.equals(this.clientCertificateKey, other.clientCertificateKey); } @Override @@ -233,7 +239,9 @@ public int hashCode() { this.openTelemetry, this.enableExtendedTracing, this.enableApiTracing, - this.enableEndToEndTracing); + this.enableEndToEndTracing, + this.clientCertificate, + this.clientCertificateKey); } } @@ -393,6 +401,9 @@ Spanner createSpanner(SpannerPoolKey key, ConnectionOptions options) { // Set a custom channel configurator to allow http instead of https. builder.setChannelConfigurator(ManagedChannelBuilder::usePlaintext); } + if (key.clientCertificate != null && key.clientCertificateKey != null) { + builder.useClientCert(key.clientCertificate, key.clientCertificateKey); + } if (options.getConfigurator() != null) { options.getConfigurator().configure(builder); }