Skip to content

Commit eae15aa

Browse files
committed
feat(spanner): mTLS setup for spanner external host clients
1 parent 8d295c4 commit eae15aa

File tree

4 files changed

+98
-3
lines changed

4 files changed

+98
-3
lines changed

google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
import com.google.api.gax.core.GaxProperties;
2828
import com.google.api.gax.grpc.GrpcCallContext;
2929
import com.google.api.gax.grpc.GrpcInterceptorProvider;
30+
import com.google.api.gax.grpc.GrpcTransportChannel;
3031
import com.google.api.gax.longrunning.OperationTimedPollAlgorithm;
3132
import com.google.api.gax.retrying.RetrySettings;
3233
import com.google.api.gax.rpc.ApiCallContext;
34+
import com.google.api.gax.rpc.FixedTransportChannelProvider;
3335
import com.google.api.gax.rpc.TransportChannelProvider;
3436
import com.google.api.gax.tracing.ApiTracerFactory;
3537
import com.google.api.gax.tracing.BaseApiTracerFactory;
@@ -69,13 +71,19 @@
6971
import io.grpc.CompressorRegistry;
7072
import io.grpc.Context;
7173
import io.grpc.ExperimentalApi;
74+
import io.grpc.ManagedChannel;
7275
import io.grpc.ManagedChannelBuilder;
7376
import io.grpc.MethodDescriptor;
77+
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
78+
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
7479
import io.opentelemetry.api.GlobalOpenTelemetry;
7580
import io.opentelemetry.api.OpenTelemetry;
7681
import io.opentelemetry.api.common.Attributes;
82+
import java.io.File;
7783
import java.io.IOException;
7884
import java.net.MalformedURLException;
85+
import java.net.URI;
86+
import java.net.URISyntaxException;
7987
import java.net.URL;
8088
import java.time.Duration;
8189
import java.util.ArrayList;
@@ -90,6 +98,8 @@
9098
import java.util.concurrent.ThreadFactory;
9199
import java.util.concurrent.TimeUnit;
92100
import java.util.concurrent.atomic.AtomicInteger;
101+
import java.util.logging.Level;
102+
import java.util.logging.Logger;
93103
import javax.annotation.Nonnull;
94104
import javax.annotation.Nullable;
95105
import javax.annotation.concurrent.GuardedBy;
@@ -942,6 +952,7 @@ public static class Builder
942952
private CloseableExecutorProvider asyncExecutorProvider;
943953
private String compressorName;
944954
private String emulatorHost = System.getenv("SPANNER_EMULATOR_HOST");
955+
private ManagedChannel managedChannel;
945956
private boolean leaderAwareRoutingEnabled = true;
946957
private boolean attemptDirectPath = true;
947958
private DirectedReadOptions directedReadOptions;
@@ -1485,6 +1496,28 @@ public Builder setEmulatorHost(String emulatorHost) {
14851496
return this;
14861497
}
14871498

1499+
public Builder useClientCert(String host, String clientCertificate, String clientKey) {
1500+
try {
1501+
URI uri = new URI(host);
1502+
managedChannel =
1503+
NettyChannelBuilder.forAddress(uri.getHost(), uri.getPort())
1504+
.sslContext(
1505+
GrpcSslContexts.forClient()
1506+
.keyManager(new File(clientCertificate), new File(clientKey))
1507+
.build())
1508+
.build();
1509+
1510+
setChannelProvider(
1511+
FixedTransportChannelProvider.create(GrpcTransportChannel.create(managedChannel)));
1512+
} catch (URISyntaxException e) {
1513+
throw new IllegalArgumentException(
1514+
"Invalid host format. Expected format: 'protocol://host[:port]'.", e);
1515+
} catch (Exception e) {
1516+
throw new RuntimeException("Unexpected error during mTLS setup.", e);
1517+
}
1518+
return this;
1519+
}
1520+
14881521
/**
14891522
* Sets OpenTelemetry object to be used for Spanner Metrics and Traces. GlobalOpenTelemetry will
14901523
* be used as fallback if this options is not set.
@@ -1593,6 +1626,23 @@ public SpannerOptions build() {
15931626
this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext);
15941627
// As we are using plain text, we should never send any credentials.
15951628
this.setCredentials(NoCredentials.getInstance());
1629+
} else if (managedChannel != null) {
1630+
Runtime.getRuntime()
1631+
.addShutdownHook(
1632+
new Thread(
1633+
() -> {
1634+
final Logger logger = Logger.getLogger(SpannerOptions.class.getName());
1635+
try {
1636+
managedChannel.shutdown();
1637+
logger.log(
1638+
Level.INFO, "[SpannerOptions] ManagedChannel shut down successfully.");
1639+
} catch (Exception e) {
1640+
logger.log(
1641+
Level.WARNING,
1642+
"[SpannerOptions] Failed to shut down ManagedChannel.",
1643+
e);
1644+
}
1645+
}));
15961646
}
15971647
if (this.numChannels == null) {
15981648
this.numChannels =

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_CONFIG_EMULATOR;
2121
import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_PARTITION_MODE;
2222
import static com.google.cloud.spanner.connection.ConnectionProperties.CHANNEL_PROVIDER;
23+
import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_CERTIFICATE;
24+
import static com.google.cloud.spanner.connection.ConnectionProperties.CLIENT_KEY;
2325
import static com.google.cloud.spanner.connection.ConnectionProperties.CREDENTIALS_PROVIDER;
2426
import static com.google.cloud.spanner.connection.ConnectionProperties.CREDENTIALS_URL;
2527
import static com.google.cloud.spanner.connection.ConnectionProperties.DATABASE_ROLE;
@@ -225,6 +227,8 @@ public String[] getValidValues() {
225227
static final boolean DEFAULT_USE_VIRTUAL_THREADS = false;
226228
static final boolean DEFAULT_USE_VIRTUAL_GRPC_TRANSPORT_THREADS = false;
227229
static final String DEFAULT_CREDENTIALS = null;
230+
static final String DEFAULT_CLIENT_CERTIFICATE = null;
231+
static final String DEFAULT_CLIENT_KEY = null;
228232
static final String DEFAULT_OAUTH_TOKEN = null;
229233
static final Integer DEFAULT_MIN_SESSIONS = null;
230234
static final Integer DEFAULT_MAX_SESSIONS = null;
@@ -263,6 +267,10 @@ public String[] getValidValues() {
263267
private static final String DEFAULT_EMULATOR_HOST = "http://localhost:9010";
264268
/** Use plain text is only for local testing purposes. */
265269
static final String USE_PLAIN_TEXT_PROPERTY_NAME = "usePlainText";
270+
/** Client certificate path to establish mTLS */
271+
static final String CLIENT_CERTIFICATE_PROPERTY_NAME = "clientCertificate";
272+
/** Client key path to establish mTLS */
273+
static final String CLIENT_KEY_PROPERTY_NAME = "clientKey";
266274
/** Name of the 'autocommit' connection property. */
267275
public static final String AUTOCOMMIT_PROPERTY_NAME = "autocommit";
268276
/** Name of the 'readonly' connection property. */
@@ -434,6 +442,12 @@ static boolean isEnableTransactionalConnectionStateForPostgreSQL() {
434442
USE_PLAIN_TEXT_PROPERTY_NAME,
435443
"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.",
436444
DEFAULT_USE_PLAIN_TEXT),
445+
ConnectionProperty.createStringProperty(
446+
CLIENT_CERTIFICATE_PROPERTY_NAME,
447+
"Specifies the file path to the client certificate required for establishing an mTLS connection."),
448+
ConnectionProperty.createStringProperty(
449+
CLIENT_KEY_PROPERTY_NAME,
450+
"Specifies the file path to the client private key required for establishing an mTLS connection."),
437451
ConnectionProperty.createStringProperty(
438452
USER_AGENT_PROPERTY_NAME,
439453
"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."),
@@ -828,6 +842,7 @@ public static Builder newBuilder() {
828842
private final Credentials fixedCredentials;
829843

830844
private final String host;
845+
private boolean isExternalHost;
831846
private final String projectId;
832847
private final String instanceId;
833848
private final String databaseName;
@@ -841,10 +856,10 @@ public static Builder newBuilder() {
841856

842857
private ConnectionOptions(Builder builder) {
843858
Matcher matcher;
844-
boolean isExternalHost = false;
859+
this.isExternalHost = false;
845860
if (builder.isValidExternalHostUri(builder.uri)) {
846861
matcher = Builder.EXTERNAL_HOST_PATTERN.matcher(builder.uri);
847-
isExternalHost = true;
862+
this.isExternalHost = true;
848863
} else {
849864
matcher = Builder.SPANNER_URI_PATTERN.matcher(builder.uri);
850865
}
@@ -967,7 +982,7 @@ && getInitialConnectionPropertyValue(OAUTH_TOKEN) == null
967982

968983
String projectId = "default";
969984
String instanceId = matcher.group(Builder.INSTANCE_GROUP);
970-
if (!isExternalHost) {
985+
if (!this.isExternalHost) {
971986
projectId = matcher.group(Builder.PROJECT_GROUP);
972987
} else if (instanceId == null) {
973988
instanceId = "default";
@@ -1291,6 +1306,14 @@ boolean isUsePlainText() {
12911306
|| getInitialConnectionPropertyValue(USE_PLAIN_TEXT);
12921307
}
12931308

1309+
String getClientCertificate() {
1310+
return getInitialConnectionPropertyValue(CLIENT_CERTIFICATE);
1311+
}
1312+
1313+
String getClientCertificateKey() {
1314+
return getInitialConnectionPropertyValue(CLIENT_KEY);
1315+
}
1316+
12941317
/**
12951318
* The (custom) user agent string to use for this connection. If <code>null</code>, then the
12961319
* default JDBC user agent string will be used.

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION_PROPERTY_NAME;
2323
import static com.google.cloud.spanner.connection.ConnectionOptions.AUTO_PARTITION_MODE_PROPERTY_NAME;
2424
import static com.google.cloud.spanner.connection.ConnectionOptions.CHANNEL_PROVIDER_PROPERTY_NAME;
25+
import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_CERTIFICATE_PROPERTY_NAME;
26+
import static com.google.cloud.spanner.connection.ConnectionOptions.CLIENT_KEY_PROPERTY_NAME;
2527
import static com.google.cloud.spanner.connection.ConnectionOptions.CREDENTIALS_PROPERTY_NAME;
2628
import static com.google.cloud.spanner.connection.ConnectionOptions.CREDENTIALS_PROVIDER_PROPERTY_NAME;
2729
import static com.google.cloud.spanner.connection.ConnectionOptions.DATABASE_ROLE_PROPERTY_NAME;
@@ -33,6 +35,8 @@
3335
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_BATCH_DML_UPDATE_COUNT_VERIFICATION;
3436
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_AUTO_PARTITION_MODE;
3537
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CHANNEL_PROVIDER;
38+
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_CERTIFICATE;
39+
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CLIENT_KEY;
3640
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_CREDENTIALS;
3741
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_DATABASE_ROLE;
3842
import static com.google.cloud.spanner.connection.ConnectionOptions.DEFAULT_DATA_BOOST_ENABLED;
@@ -192,6 +196,20 @@ public class ConnectionProperties {
192196
BooleanConverter.INSTANCE,
193197
Context.STARTUP);
194198

199+
static final ConnectionProperty<String> CLIENT_CERTIFICATE =
200+
create(
201+
CLIENT_CERTIFICATE_PROPERTY_NAME,
202+
"Specifies the file path to the client certificate required for establishing an mTLS connection.",
203+
DEFAULT_CLIENT_CERTIFICATE,
204+
StringValueConverter.INSTANCE,
205+
Context.STARTUP);
206+
static final ConnectionProperty<String> CLIENT_KEY =
207+
create(
208+
CLIENT_KEY_PROPERTY_NAME,
209+
"Specifies the file path to the client private key required for establishing an mTLS connection.",
210+
DEFAULT_CLIENT_KEY,
211+
StringValueConverter.INSTANCE,
212+
Context.STARTUP);
195213
static final ConnectionProperty<String> CREDENTIALS_URL =
196214
create(
197215
CREDENTIALS_PROPERTY_NAME,

google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/SpannerPool.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,10 @@ Spanner createSpanner(SpannerPoolKey key, ConnectionOptions options) {
393393
// Set a custom channel configurator to allow http instead of https.
394394
builder.setChannelConfigurator(ManagedChannelBuilder::usePlaintext);
395395
}
396+
if (options.getClientCertificate() != null && options.getClientCertificateKey() != null) {
397+
builder.useClientCert(
398+
options.getHost(), options.getClientCertificate(), options.getClientCertificateKey());
399+
}
396400
if (options.getConfigurator() != null) {
397401
options.getConfigurator().configure(builder);
398402
}

0 commit comments

Comments
 (0)