Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3556d72
feat: ability to update credentials on long running client
ldetmer Mar 2, 2026
02a00ac
chore: generate libraries at Mon Mar 2 15:00:10 UTC 2026
cloud-java-bot Mar 2, 2026
bd89a33
Update google-cloud-spanner/src/main/java/com/google/cloud/spanner/co…
ldetmer Mar 2, 2026
e871f0f
fixed comp issue
ldetmer Mar 2, 2026
b802f0a
suggestions from gemini review + lint fixes
ldetmer Mar 2, 2026
40abd6d
chore: generate libraries at Mon Mar 2 15:20:24 UTC 2026
cloud-java-bot Mar 2, 2026
e5654af
test IT test
ldetmer Mar 2, 2026
28bb07a
remove commented out code
ldetmer Mar 2, 2026
1a302e8
chore: generate libraries at Mon Mar 2 17:08:19 UTC 2026
cloud-java-bot Mar 2, 2026
74995fe
added missing override methods
ldetmer Mar 3, 2026
61468f0
attempt to fix IT tests
ldetmer Mar 3, 2026
fc8c682
chore: generate libraries at Tue Mar 3 21:06:51 UTC 2026
cloud-java-bot Mar 3, 2026
2cf5b89
try to use default key file
ldetmer Mar 3, 2026
1751af2
chore: generate libraries at Tue Mar 3 21:25:30 UTC 2026
cloud-java-bot Mar 3, 2026
22a9ba7
try to use hardcoded service account file
ldetmer Mar 3, 2026
80b67bf
chore: generate libraries at Tue Mar 3 22:13:15 UTC 2026
cloud-java-bot Mar 3, 2026
b0c1514
change to use resource as stream
ldetmer Mar 4, 2026
e920709
Merge branch 'main' into mutable-credentials
ldetmer Mar 4, 2026
910eb82
chore: generate libraries at Wed Mar 4 17:58:06 UTC 2026
cloud-java-bot Mar 4, 2026
816dcfc
change to use correct project Id
ldetmer Mar 4, 2026
d3dab34
change to use new api for test
ldetmer Mar 4, 2026
9739af2
chore: generate libraries at Wed Mar 4 18:27:30 UTC 2026
cloud-java-bot Mar 4, 2026
8c690d2
fix instance name
ldetmer Mar 4, 2026
2d70ec5
add invalid test key for IT tests
ldetmer Mar 4, 2026
917cb2d
change test key to be invalid
ldetmer Mar 4, 2026
fa7ea61
chore: generate libraries at Wed Mar 4 19:01:08 UTC 2026
cloud-java-bot Mar 4, 2026
1d41b10
working IT test
ldetmer Mar 4, 2026
0378706
need to check error message on kokoro as its different then local
ldetmer Mar 4, 2026
9c4b12b
need to check error message on kokoro as its different then local
ldetmer Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.cloud.spanner.connection;

import com.google.auth.CredentialTypeForMetrics;
import com.google.auth.Credentials;
import com.google.auth.RequestMetadataCallback;
import com.google.auth.oauth2.ServiceAccountCredentials;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;

/**
* A mutable {@link Credentials} implementation that delegates authentication behavior to a scoped
* {@link ServiceAccountCredentials} instance.
*
* <p>This class is intended for scenarios where an application needs to replace the underlying
* service account credentials for a long running Spanner Client.
*
* <p>All operations inherited from {@link Credentials} are forwarded to the current delegate,
* including request metadata retrieval and token refresh. Calling {@link
* #updateCredentials(ServiceAccountCredentials)} replaces the delegate with a newly scoped
* credentials instance created from the same scopes that were provided when this object was
* constructed.
*/
public class MutableCredentials extends Credentials {
private volatile ServiceAccountCredentials delegate;
private final List<String> scopes;

public MutableCredentials(ServiceAccountCredentials credentials, List<String> scopes) {
if (scopes != null) {
this.scopes = new java.util.ArrayList<>(scopes);
} else {
this.scopes = Collections.emptyList();
}
delegate = (ServiceAccountCredentials) credentials.createScoped(this.scopes);
}

/**
* Replaces the current delegate with a newly scoped credentials instance.
*
* <p>The provided {@link ServiceAccountCredentials} is scoped using the same scopes that were
* supplied when this {@link MutableCredentials} instance was created.
*
* @param credentials the new base service account credentials to scope and use for client
* authorization.
*/
public void updateCredentials(ServiceAccountCredentials credentials) {
delegate = (ServiceAccountCredentials) credentials.createScoped(scopes);
}

@Override
public String getAuthenticationType() {
return delegate.getAuthenticationType();
}

@Override
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
return delegate.getRequestMetadata(uri);
}

@Override
public boolean hasRequestMetadata() {
return delegate.hasRequestMetadata();
}

@Override
public boolean hasRequestMetadataOnly() {
return delegate.hasRequestMetadataOnly();
}

@Override
public void refresh() throws IOException {
delegate.refresh();
}

@Override
public void getRequestMetadata(URI uri, Executor executor, RequestMetadataCallback callback) {
delegate.getRequestMetadata(uri, executor, callback);
}

@Override
public String getUniverseDomain() throws IOException {
return delegate.getUniverseDomain();
}

@Override
public CredentialTypeForMetrics getMetricsCredentialType() {
return delegate.getMetricsCredentialType();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.spanner.connection;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.google.auth.CredentialTypeForMetrics;
import com.google.auth.RequestMetadataCallback;
import com.google.auth.oauth2.ServiceAccountCredentials;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@RunWith(JUnit4.class)
public class MutableCredentialsTest {
ServiceAccountCredentials initialCredentials = mock(ServiceAccountCredentials.class);
ServiceAccountCredentials initialScopedCredentials = mock(ServiceAccountCredentials.class);
ServiceAccountCredentials updatedCredentials = mock(ServiceAccountCredentials.class);
ServiceAccountCredentials updatedScopedCredentials = mock(ServiceAccountCredentials.class);
List<String> scopes = Arrays.asList("scope-a", "scope-b");
Map<String, List<String>> initialMetadata =
Collections.singletonMap("Authorization", Collections.singletonList("v1"));
Map<String, List<String>> updatedMetadata =
Collections.singletonMap("Authorization", Collections.singletonList("v2"));
String initialAuthType = "auth-1";
String updatedAuthType = "auth-2";
String initialUniverseDomain = "googleapis.com";
String updatedUniverseDomain = "abc.goog";
CredentialTypeForMetrics initialMetricsCredentialType =
CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_JWT;
CredentialTypeForMetrics updatedMetricsCredentialType =
CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_AT;

@Test
public void testCreateMutableCredentials() throws IOException {
setupInitialCredentials();

MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
URI testUri = URI.create("https://spanner.googleapis.com");
Executor executor = mock(Executor.class);
RequestMetadataCallback callback = mock(RequestMetadataCallback.class);

validateInitialDelegatedCredentialsAreSet(credentials, testUri);

credentials.getRequestMetadata(testUri, executor, callback);

credentials.refresh();

verify(initialScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
verify(initialScopedCredentials, times(1)).refresh();
}

@Test
public void testUpdateMutableCredentials() throws IOException {
setupInitialCredentials();
setupUpdatedCredentials();

MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
URI testUri = URI.create("https://example.com");
Executor executor = mock(Executor.class);
RequestMetadataCallback callback = mock(RequestMetadataCallback.class);

validateInitialDelegatedCredentialsAreSet(credentials, testUri);

credentials.updateCredentials(updatedCredentials);

assertEquals(updatedAuthType, credentials.getAuthenticationType());
assertFalse(credentials.hasRequestMetadata());
assertFalse(credentials.hasRequestMetadataOnly());
assertSame(updatedMetadata, credentials.getRequestMetadata(testUri));
assertEquals(updatedUniverseDomain, credentials.getUniverseDomain());
assertEquals(updatedMetricsCredentialType, credentials.getMetricsCredentialType());

credentials.getRequestMetadata(testUri, executor, callback);

credentials.refresh();

verify(updatedScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
verify(updatedScopedCredentials, times(1)).refresh();
}

@Test
public void testCreateMutableCredentialsNullScopes() throws IOException {
setupInitialCredentials();

MutableCredentials credentials = new MutableCredentials(initialCredentials, null);
URI testUri = URI.create("https://spanner.googleapis.com");

validateInitialDelegatedCredentialsAreSet(credentials, testUri);
}

private void validateInitialDelegatedCredentialsAreSet(
MutableCredentials credentials, URI testUri) throws IOException {
assertEquals(initialAuthType, credentials.getAuthenticationType());
assertTrue(credentials.hasRequestMetadata());
assertTrue(credentials.hasRequestMetadataOnly());
assertEquals(initialMetadata, credentials.getRequestMetadata(testUri));
assertEquals(initialUniverseDomain, credentials.getUniverseDomain());
assertEquals(initialMetricsCredentialType, credentials.getMetricsCredentialType());
}

private void setupInitialCredentials() throws IOException {
when(initialCredentials.createScoped(scopes)).thenReturn(initialScopedCredentials);
when(initialCredentials.createScoped(Collections.emptyList()))
.thenReturn(initialScopedCredentials);
when(initialScopedCredentials.getAuthenticationType()).thenReturn(initialAuthType);
when(initialScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(initialMetadata);
when(initialScopedCredentials.getUniverseDomain()).thenReturn(initialUniverseDomain);
when(initialScopedCredentials.getMetricsCredentialType())
.thenReturn(initialMetricsCredentialType);
when(initialScopedCredentials.hasRequestMetadata()).thenReturn(true);
when(initialScopedCredentials.hasRequestMetadataOnly()).thenReturn(true);
}

private void setupUpdatedCredentials() throws IOException {
when(updatedCredentials.createScoped(scopes)).thenReturn(updatedScopedCredentials);
when(updatedScopedCredentials.getAuthenticationType()).thenReturn(updatedAuthType);
when(updatedScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(updatedMetadata);
when(updatedScopedCredentials.getUniverseDomain()).thenReturn(updatedUniverseDomain);
when(updatedScopedCredentials.getMetricsCredentialType())
.thenReturn(updatedMetricsCredentialType);
when(updatedScopedCredentials.hasRequestMetadata()).thenReturn(false);
when(updatedScopedCredentials.hasRequestMetadataOnly()).thenReturn(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.spanner.connection.it;

import static org.junit.Assert.*;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.spanner.*;
import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient;
import com.google.cloud.spanner.connection.MutableCredentials;
import com.google.spanner.admin.database.v1.Database;
import com.google.spanner.admin.database.v1.InstanceName;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@Category(SerialIntegrationTest.class)
@RunWith(JUnit4.class)
public class ITMutableCredentialsTest {
private static final String MISSING_PERM_KEY =
"/com/google/cloud/spanner/connection/test-key-missing-permissions.json";

private static final String INVALID_KEY = "/com/google/cloud/spanner/connection/test-key.json";

@Test
public void testMutableCredentialsUpdateAuthorizationForRunningClient() throws IOException {

GoogleCredentials missingPermissionCredentials;
try (InputStream stream =
ITMutableCredentialsTest.class.getResourceAsStream(MISSING_PERM_KEY)) {
missingPermissionCredentials = GoogleCredentials.fromStream(stream);
}
ServiceAccountCredentials invalidCredentials;
try (InputStream stream = ITMutableCredentialsTest.class.getResourceAsStream(INVALID_KEY)) {
invalidCredentials = ServiceAccountCredentials.fromStream(stream);
}
List<String> scopes =
Collections.singletonList("https://www.googleapis.com/auth/cloud-platform");
// create MutableCredentials first with missing permissions
MutableCredentials mutableCredentials =
new MutableCredentials((ServiceAccountCredentials) missingPermissionCredentials, scopes);

SpannerOptions options = SpannerOptions.newBuilder().setCredentials(mutableCredentials).build();
try (Spanner spanner = options.getService();
DatabaseAdminClient databaseAdminClient = spanner.createDatabaseAdminClient()) {
String project = "gcloud-devel";
String instance = "java-client-integration-tests";
try {
listDatabases(databaseAdminClient, project, instance);
fail("Expected PERMISSION_DENIED");
} catch (Exception e) {
// specifically validate the permission denied error message
System.out.println("exception " + e.getMessage());
assertTrue(e.getMessage().contains("PERMISSION_DENIED"));
assertFalse(e.getMessage().contains("UNAUTHENTICATED"));
}

// update mutableCredentials now to use an invalid credential
mutableCredentials.updateCredentials(invalidCredentials);
try {
listDatabases(databaseAdminClient, project, instance);
fail("Expected UNAUTHENTICATED after switching to invalid credentials");
} catch (Exception e) {
assertTrue(e.getMessage().contains("UNAUTHENTICATED"));
assertFalse(e.getMessage().contains("PERMISSION_DENIED"));
}
}
}

private static void listDatabases(
DatabaseAdminClient databaseAdminClient, String projectId, String instanceId) {
DatabaseAdminClient.ListDatabasesPagedResponse response =
databaseAdminClient.listDatabases(InstanceName.of(projectId, instanceId));

for (DatabaseAdminClient.ListDatabasesPage page : response.iteratePages()) {
for (Database database : page.iterateAll()) {
// no-op
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "ldetmer-sanbox",
"private_key_id": "1f9be0fd206d51e759ab8577c32301333dda9103",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDgwaPePW0yK6Wg\nm0n0PgrmJbwbf0HgFQ9E5I5e+rZ+hl79pCxCkXcTVH6HsIgh+Lp1tyCTExjsE0cN\nDVRy9YD/BR4wKsxOhz5UKwmRhOg127EGvqMAwomEGOIyOuS+AMmdy710B/iBjc01\nLYTzNe1ldZ5SK69AJer9g1+mPn2H7AbZGQ8/ThKvzEAErmgd7PMATRwg6sAc5n6O\nIvcfQp4XApcYuCNH4KVmnXD3K9f0ySV5WKA14eHRIIxwjHagFvHaiMvU4/HXqCao\nClVoNmmoraUBfaDGj4JGMvj1JjTyz4VB0KGXxYTJJrViYhkKZs4zSo2k2qatPczn\nNHH6PkjLAgMBAAECggEAD+QWYPfjiOuQb1WWGgBqUYa+JmI4rHjwtm9D0wVTnTMv\nniwFu8MrMcuvPTVxupfVHyOOgwzo8zATEveBTrYlo7efQHVA3WsvMKZGVpVDZyNx\nqxy+y6agMHMjSGfk6mYwhclKS4eQviA3hRit0MBcWOhtruOgesmeNBnIy9PumOB+\nWVRp7uz8D/xaq55lFAShaH2DAEt316qetZW+LtNq473pPq9GlnFYsj+OPyWT84X2\nmeoWLxVwOpkx1RmmlAEOQCCK24H7GbZwsADiyHQ37cwQ/MfTs9qsib56TByVHSSb\nYD+lTMPT+5N/YY51AVq4op4kGPuVTHLE4D3uZduSEQKBgQDwmuSCjfvHr1LZxo/r\nVPR6+KiQC+o8qBzK0413D3rn+0pAWCcrkb9//PGwXxtRzjEodwbX9B+g8UGzkbfD\nH5scogl3Nd+3zUTsSp5D5IZUJsVv1lN2klv4y48zidey2qELOC8n6hPnrbOHfZqZ\nR22/o2/TeWxnWbmMUN2kx9r++QKBgQDvIyWePiJgFvlRLSLQQpZOiDV4z61Ixows\nDBrTeQyfAYG8gROA0LUS3zS4njA2Yr6xFj6M8rhUD9bLQ1+mGIJWi7ZI2cD+TDtH\ntdxTS7jBU8s26H2nisD8kvKpq61RxI1A2H7u+9gPzDweM0boBlERNqjyPUZhNbdD\n0+7AwmJC4wKBgB91kTVEzUvxr5qL7NtvUzwU8S1McYcW0BTxDkkn/AEDCVVacVyw\nBOL+NrfB57eNhz3sOjfYUp5fjSCmh+l6Y3Sd9zDgGW1V6JIgu4rTAYFVRHF4C5ew\nUVg5fXLWrh5TmcT2xquoXovnWVb45FLwVPg+rWtwL+1ffPRMyn42J3s5AoGAf6CR\ndigRLpl0THe7aczv7U/SwfyMrheRPfzj4FNtgftK43E8GHbK/Rx1RcbfUldXEKof\njhgIeozNhUQa60mPXmNIUQ8uakoDJV2RDj+OhleTUGW6kk2CfAptSlKeuNIe1Sn2\nbNOqV5wXxcJ2KGUepQI4HrjHNCB4A9I7TVMxICMCgYAPXO4/xTZJ/0Nmjd085yRo\nhDFBUTwWPHUTbUA1bBMd908F4RD0WnnLPzSC1hSxhhCGm119JGgusZfwL2Ey1nYh\n9B3b/EwArE/vC+Fl/tyILQR2G/D/f70dISuDut139cKEM8qBLJ2JRuYbKlEBPhGW\nw0x8SmTkNYepAG0SSaBu7g==\n-----END PRIVATE KEY-----\n",
"client_email": "test-mutable-credentials@ldetmer-sanbox.iam.gserviceaccount.com",
"client_id": "110488447517330409458",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-mutable-credentials%40ldetmer-sanbox.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
Loading