Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
89d4789
provide default scopes constructor
ldetmer Mar 5, 2026
bb56c65
chore: generate libraries at Thu Mar 5 15:41:33 UTC 2026
cloud-java-bot Mar 5, 2026
52b761b
testing default credential accesss
ldetmer Mar 5, 2026
06103fd
chore: generate libraries at Thu Mar 5 15:59:43 UTC 2026
cloud-java-bot Mar 5, 2026
d5fb2f8
testing default credential accesss
ldetmer Mar 5, 2026
e5a4c93
chore: generate libraries at Thu Mar 5 16:16:58 UTC 2026
cloud-java-bot Mar 5, 2026
3e71bef
try to disable the nocredentials process
ldetmer Mar 5, 2026
bbfb3e7
chore: generate libraries at Thu Mar 5 18:31:08 UTC 2026
cloud-java-bot Mar 5, 2026
2778372
hopefully fixed IT tests
ldetmer Mar 5, 2026
4c5ec92
chore: generate libraries at Thu Mar 5 19:17:19 UTC 2026
cloud-java-bot Mar 5, 2026
eb0164e
cleaned up tasks + added sample code
ldetmer Mar 5, 2026
bf52fa2
chore: generate libraries at Thu Mar 5 20:28:36 UTC 2026
cloud-java-bot Mar 5, 2026
a50eee8
bump sample dependency so code samples compile
ldetmer Mar 5, 2026
8d640ef
chore: generate libraries at Thu Mar 5 21:10:30 UTC 2026
cloud-java-bot Mar 5, 2026
38ddfb4
bump sample dependency so code samples compile
ldetmer Mar 5, 2026
4ed2f0b
chore: generate libraries at Thu Mar 5 21:49:47 UTC 2026
cloud-java-bot Mar 5, 2026
09c1e63
updates from PR review
ldetmer Mar 6, 2026
8805dd7
chore: generate libraries at Fri Mar 6 14:38:15 UTC 2026
cloud-java-bot Mar 6, 2026
90c6b7b
moved IT test to correct package
ldetmer Mar 6, 2026
ef64649
chore: generate libraries at Fri Mar 6 14:42:23 UTC 2026
cloud-java-bot Mar 6, 2026
4119282
remove spanner version in samples
ldetmer Mar 6, 2026
325db5d
chore: generate libraries at Fri Mar 6 15:13:14 UTC 2026
cloud-java-bot Mar 6, 2026
960e41e
remove MutableCredentials to separate PR
ldetmer Mar 6, 2026
de735ad
chore: generate libraries at Fri Mar 6 15:23:50 UTC 2026
cloud-java-bot Mar 6, 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,112 @@
/*
* 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;

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.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import javax.annotation.Nonnull;

/**
* 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olavloite @sakthivelmanii

I recall you both mentioned something about the built in metrics using creds. Is there a way we can test this behavior to confirm this doesn't break?

private volatile ServiceAccountCredentials delegate;
private final Set<String> scopes;

/** Creates a MutableCredentials instance with default spanner scopes. */
public MutableCredentials(ServiceAccountCredentials credentials) {
this(credentials, SpannerOptions.SCOPES);
}

public MutableCredentials(ServiceAccountCredentials credentials, @Nonnull Set<String> scopes) {
if (scopes.isEmpty()) {
throw new IllegalArgumentException("Scopes must not be empty");
}
this.scopes = new java.util.HashSet<>(scopes);
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.
Comment on lines +61 to +67
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It may may (?) be worth adding a bit of warning here regarding updating creds. I think depending on when the getRequestMetadata call occurs, any in-flight RPC may be using the old creds (which I think should be fine as long as the old cred is still valid for a bit).

*/
public void updateCredentials(ServiceAccountCredentials credentials) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should we check to ensure that a non-null SA creds is passed in?

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
Expand Up @@ -128,7 +128,7 @@ public class SpannerOptions extends ServiceOptions<Spanner, SpannerOptions> {
private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com";
private static final String EXPERIMENTAL_HOST_PROJECT_ID = "default";

private static final ImmutableSet<String> SCOPES =
static final ImmutableSet<String> SCOPES =
ImmutableSet.of(
"https://www.googleapis.com/auth/spanner.admin",
"https://www.googleapis.com/auth/spanner.data");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* 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;

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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
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);
Set<String> scopes = new HashSet<>(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 testCreateMutableCredentialsWithDefaultScopes() throws IOException {
Set<String> defaultScopes = SpannerOptions.SCOPES;
when(initialCredentials.createScoped(defaultScopes)).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);

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

validateInitialDelegatedCredentialsAreSet(credentials, testUri);
verify(initialCredentials).createScoped(defaultScopes);
}

@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(expected = IllegalArgumentException.class)
public void testCreateMutableCredentialsEmptyScopesThrowsError() {
new MutableCredentials(initialCredentials, Collections.emptySet());
}

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,102 @@
/*
* 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.it;

import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.spanner.MutableCredentials;
import com.google.cloud.spanner.SerialIntegrationTest;
import com.google.cloud.spanner.Spanner;
import com.google.cloud.spanner.SpannerOptions;
import com.google.cloud.spanner.admin.instance.v1.InstanceAdminClient;
import com.google.spanner.admin.instance.v1.ProjectName;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
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 INVALID_CERT_PATH =
"/com/google/cloud/spanner/connection/test-key.json";

@Test
public void testMutableCredentialsUpdateAuthorizationForRunningClient() throws IOException {
GoogleCredentials validCredentials = null;

// accept cert path overridden by environment variable for local testing
if (System.getenv("GOOGLE_ACCOUNT_CREDENTIALS") != null) {
try (InputStream stream =
Files.newInputStream(Paths.get(System.getenv("GOOGLE_ACCOUNT_CREDENTIALS")))) {
validCredentials = GoogleCredentials.fromStream(stream);
}
} else {
try {
validCredentials = GoogleCredentials.getApplicationDefault();
} catch (IOException e) {
}
}

// credentials must be ServiceAccountCredentials
assumeTrue(validCredentials instanceof ServiceAccountCredentials);

ServiceAccountCredentials invalidCredentials;
try (InputStream stream =
ITMutableCredentialsTest.class.getResourceAsStream(INVALID_CERT_PATH)) {
invalidCredentials = ServiceAccountCredentials.fromStream(stream);
}

// create MutableCredentials first with valid credentials
MutableCredentials mutableCredentials =
new MutableCredentials((ServiceAccountCredentials) validCredentials);

SpannerOptions options =
SpannerOptions.newBuilder()
// this setting is required in the scenario SPANNER_EMULATOR_HOST is set otherwise
// SpannerOptions overrides credentials to NoCredentials
.setEmulatorHost(null)
.setCredentials(mutableCredentials)
.build();

ProjectName projectName = ProjectName.of(options.getProjectId());
try (Spanner spanner = options.getService();
InstanceAdminClient instanceAdminClient = spanner.createInstanceAdminClient()) {
instanceAdminClient.listInstances(projectName);

// update mutableCredentials now to use an invalid credentials
mutableCredentials.updateCredentials(invalidCredentials);

try {
// this call should now fail with new invalid credentials
instanceAdminClient.listInstances(projectName);
fail("Expected UNAUTHENTICATED after switching to invalid credentials");
} catch (Exception e) {
assertTrue(e.getMessage().contains("UNAUTHENTICATED"));
}
Comment on lines +93 to +99
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use assertThrows(..)

}
}
}
Loading