Skip to content
This repository was archived by the owner on Apr 7, 2026. It is now read-only.

Commit e238990

Browse files
ldetmercloud-java-botgemini-code-assist[bot]
authored
feat: ability to update credentials on long running client (#4371)
* feat: ability to update credentials on long running client * chore: generate libraries at Mon Mar 2 15:00:10 UTC 2026 * Update google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fixed comp issue * suggestions from gemini review + lint fixes * chore: generate libraries at Mon Mar 2 15:20:24 UTC 2026 * test IT test * remove commented out code * chore: generate libraries at Mon Mar 2 17:08:19 UTC 2026 * added missing override methods * attempt to fix IT tests * chore: generate libraries at Tue Mar 3 21:06:51 UTC 2026 * try to use default key file * chore: generate libraries at Tue Mar 3 21:25:30 UTC 2026 * try to use hardcoded service account file * chore: generate libraries at Tue Mar 3 22:13:15 UTC 2026 * change to use resource as stream # Conflicts: # google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java * chore: generate libraries at Wed Mar 4 17:58:06 UTC 2026 * change to use correct project Id * change to use new api for test * chore: generate libraries at Wed Mar 4 18:27:30 UTC 2026 * fix instance name * add invalid test key for IT tests * change test key to be invalid * chore: generate libraries at Wed Mar 4 19:01:08 UTC 2026 * working IT test * need to check error message on kokoro as its different then local * need to check error message on kokoro as its different then local * provide default scopes constructor * chore: generate libraries at Thu Mar 5 15:41:33 UTC 2026 * testing default credential accesss * chore: generate libraries at Thu Mar 5 15:59:43 UTC 2026 * testing default credential accesss * chore: generate libraries at Thu Mar 5 16:16:58 UTC 2026 * try to disable the nocredentials process * chore: generate libraries at Thu Mar 5 18:31:08 UTC 2026 * hopefully fixed IT tests # Conflicts: # google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java * chore: generate libraries at Thu Mar 5 19:17:19 UTC 2026 * cleaned up tasks + added sample code * chore: generate libraries at Thu Mar 5 20:28:36 UTC 2026 * bump sample dependency so code samples compile * chore: generate libraries at Thu Mar 5 21:10:30 UTC 2026 * bump sample dependency so code samples compile * chore: generate libraries at Thu Mar 5 21:49:47 UTC 2026 * updates from PR review * chore: generate libraries at Fri Mar 6 14:38:15 UTC 2026 * moved IT test to correct package * chore: generate libraries at Fri Mar 6 14:42:23 UTC 2026 * remove spanner version in samples * chore: generate libraries at Fri Mar 6 15:13:14 UTC 2026 * remove MutableCredentials to separate PR * chore: generate libraries at Fri Mar 6 15:23:50 UTC 2026 * added null checks for arguments in MutableCredentials * chore: generate libraries at Mon Mar 9 13:13:15 UTC 2026 --------- Co-authored-by: cloud-java-bot <cloud-java-bot@google.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent f707be4 commit e238990

4 files changed

Lines changed: 418 additions & 1 deletion

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.cloud.spanner;
17+
18+
import com.google.auth.CredentialTypeForMetrics;
19+
import com.google.auth.Credentials;
20+
import com.google.auth.RequestMetadataCallback;
21+
import com.google.auth.oauth2.ServiceAccountCredentials;
22+
import java.io.IOException;
23+
import java.net.URI;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.Objects;
27+
import java.util.Set;
28+
import java.util.concurrent.Executor;
29+
import javax.annotation.Nonnull;
30+
31+
/**
32+
* A mutable {@link Credentials} implementation that delegates authentication behavior to a scoped
33+
* {@link ServiceAccountCredentials} instance.
34+
*
35+
* <p>This class is intended for scenarios where an application needs to replace the underlying
36+
* service account credentials for a long-running Spanner Client.
37+
*
38+
* <p>All operations inherited from {@link Credentials} are forwarded to the current delegate,
39+
* including request metadata retrieval and token refresh. Calling {@link
40+
* #updateCredentials(ServiceAccountCredentials)} replaces the delegate with a newly scoped
41+
* credentials instance created from the same scopes that were provided when this object was
42+
* constructed.
43+
*/
44+
public class MutableCredentials extends Credentials {
45+
private volatile ServiceAccountCredentials delegate;
46+
private final Set<String> scopes;
47+
48+
/** Creates a MutableCredentials instance with default spanner scopes. */
49+
public MutableCredentials(ServiceAccountCredentials credentials) {
50+
this(credentials, SpannerOptions.SCOPES);
51+
}
52+
53+
public MutableCredentials(
54+
@Nonnull ServiceAccountCredentials credentials, @Nonnull Set<String> scopes) {
55+
Objects.requireNonNull(credentials, "credentials must not be null");
56+
Objects.requireNonNull(scopes, "scopes must not be null");
57+
if (scopes.isEmpty()) {
58+
throw new IllegalArgumentException("Scopes must not be empty");
59+
}
60+
this.scopes = new java.util.HashSet<>(scopes);
61+
delegate = (ServiceAccountCredentials) credentials.createScoped(this.scopes);
62+
}
63+
64+
/**
65+
* Replaces the current delegate with a newly scoped credentials instance.
66+
*
67+
* <p>Note any in-flight RPC may continue to use the old credentials.
68+
*
69+
* <p>The provided {@link ServiceAccountCredentials} is scoped using the same scopes that were
70+
* supplied when this {@link MutableCredentials} instance was created.
71+
*
72+
* @param credentials the new base service account credentials to scope and use for client
73+
* authorization.
74+
*/
75+
public void updateCredentials(@Nonnull ServiceAccountCredentials credentials) {
76+
Objects.requireNonNull(credentials, "credentials must not be null");
77+
delegate = (ServiceAccountCredentials) credentials.createScoped(scopes);
78+
}
79+
80+
@Override
81+
public String getAuthenticationType() {
82+
return delegate.getAuthenticationType();
83+
}
84+
85+
@Override
86+
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
87+
return delegate.getRequestMetadata(uri);
88+
}
89+
90+
@Override
91+
public boolean hasRequestMetadata() {
92+
return delegate.hasRequestMetadata();
93+
}
94+
95+
@Override
96+
public boolean hasRequestMetadataOnly() {
97+
return delegate.hasRequestMetadataOnly();
98+
}
99+
100+
@Override
101+
public void refresh() throws IOException {
102+
delegate.refresh();
103+
}
104+
105+
@Override
106+
public void getRequestMetadata(URI uri, Executor executor, RequestMetadataCallback callback) {
107+
delegate.getRequestMetadata(uri, executor, callback);
108+
}
109+
110+
@Override
111+
public String getUniverseDomain() throws IOException {
112+
return delegate.getUniverseDomain();
113+
}
114+
115+
@Override
116+
public CredentialTypeForMetrics getMetricsCredentialType() {
117+
return delegate.getMetricsCredentialType();
118+
}
119+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ public class SpannerOptions extends ServiceOptions<Spanner, SpannerOptions> {
128128
private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com";
129129
private static final String EXPERIMENTAL_HOST_PROJECT_ID = "default";
130130

131-
private static final ImmutableSet<String> SCOPES =
131+
static final ImmutableSet<String> SCOPES =
132132
ImmutableSet.of(
133133
"https://www.googleapis.com/auth/spanner.admin",
134134
"https://www.googleapis.com/auth/spanner.data");
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertFalse;
21+
import static org.junit.Assert.assertSame;
22+
import static org.junit.Assert.assertThrows;
23+
import static org.junit.Assert.assertTrue;
24+
import static org.mockito.ArgumentMatchers.any;
25+
import static org.mockito.Mockito.mock;
26+
import static org.mockito.Mockito.times;
27+
import static org.mockito.Mockito.verify;
28+
import static org.mockito.Mockito.when;
29+
30+
import com.google.auth.CredentialTypeForMetrics;
31+
import com.google.auth.RequestMetadataCallback;
32+
import com.google.auth.oauth2.ServiceAccountCredentials;
33+
import java.io.IOException;
34+
import java.net.URI;
35+
import java.util.Arrays;
36+
import java.util.Collections;
37+
import java.util.HashSet;
38+
import java.util.List;
39+
import java.util.Map;
40+
import java.util.Set;
41+
import java.util.concurrent.Executor;
42+
import org.junit.Test;
43+
import org.junit.runner.RunWith;
44+
import org.junit.runners.JUnit4;
45+
46+
@RunWith(JUnit4.class)
47+
public class MutableCredentialsTest {
48+
ServiceAccountCredentials initialCredentials = mock(ServiceAccountCredentials.class);
49+
ServiceAccountCredentials initialScopedCredentials = mock(ServiceAccountCredentials.class);
50+
ServiceAccountCredentials updatedCredentials = mock(ServiceAccountCredentials.class);
51+
ServiceAccountCredentials updatedScopedCredentials = mock(ServiceAccountCredentials.class);
52+
Set<String> scopes = new HashSet<>(Arrays.asList("scope-a", "scope-b"));
53+
Map<String, List<String>> initialMetadata =
54+
Collections.singletonMap("Authorization", Collections.singletonList("v1"));
55+
Map<String, List<String>> updatedMetadata =
56+
Collections.singletonMap("Authorization", Collections.singletonList("v2"));
57+
String initialAuthType = "auth-1";
58+
String updatedAuthType = "auth-2";
59+
String initialUniverseDomain = "googleapis.com";
60+
String updatedUniverseDomain = "abc.goog";
61+
CredentialTypeForMetrics initialMetricsCredentialType =
62+
CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_JWT;
63+
CredentialTypeForMetrics updatedMetricsCredentialType =
64+
CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_AT;
65+
66+
@Test
67+
public void testCreateMutableCredentials() throws IOException {
68+
setupInitialCredentials();
69+
70+
MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
71+
URI testUri = URI.create("https://spanner.googleapis.com");
72+
Executor executor = mock(Executor.class);
73+
RequestMetadataCallback callback = mock(RequestMetadataCallback.class);
74+
75+
validateInitialDelegatedCredentialsAreSet(credentials, testUri);
76+
77+
credentials.getRequestMetadata(testUri, executor, callback);
78+
79+
credentials.refresh();
80+
81+
verify(initialScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
82+
verify(initialScopedCredentials, times(1)).refresh();
83+
}
84+
85+
@Test
86+
public void testCreateMutableCredentialsWithDefaultScopes() throws IOException {
87+
Set<String> defaultScopes = SpannerOptions.SCOPES;
88+
when(initialCredentials.createScoped(defaultScopes)).thenReturn(initialScopedCredentials);
89+
when(initialScopedCredentials.getAuthenticationType()).thenReturn(initialAuthType);
90+
when(initialScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(initialMetadata);
91+
when(initialScopedCredentials.getUniverseDomain()).thenReturn(initialUniverseDomain);
92+
when(initialScopedCredentials.getMetricsCredentialType())
93+
.thenReturn(initialMetricsCredentialType);
94+
when(initialScopedCredentials.hasRequestMetadata()).thenReturn(true);
95+
when(initialScopedCredentials.hasRequestMetadataOnly()).thenReturn(true);
96+
97+
MutableCredentials credentials = new MutableCredentials(initialCredentials);
98+
URI testUri = URI.create("https://spanner.googleapis.com");
99+
100+
validateInitialDelegatedCredentialsAreSet(credentials, testUri);
101+
verify(initialCredentials).createScoped(defaultScopes);
102+
}
103+
104+
@Test
105+
public void testUpdateMutableCredentials() throws IOException {
106+
setupInitialCredentials();
107+
setupUpdatedCredentials();
108+
109+
MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
110+
URI testUri = URI.create("https://example.com");
111+
Executor executor = mock(Executor.class);
112+
RequestMetadataCallback callback = mock(RequestMetadataCallback.class);
113+
114+
validateInitialDelegatedCredentialsAreSet(credentials, testUri);
115+
116+
credentials.updateCredentials(updatedCredentials);
117+
118+
assertEquals(updatedAuthType, credentials.getAuthenticationType());
119+
assertFalse(credentials.hasRequestMetadata());
120+
assertFalse(credentials.hasRequestMetadataOnly());
121+
assertSame(updatedMetadata, credentials.getRequestMetadata(testUri));
122+
assertEquals(updatedUniverseDomain, credentials.getUniverseDomain());
123+
assertEquals(updatedMetricsCredentialType, credentials.getMetricsCredentialType());
124+
125+
credentials.getRequestMetadata(testUri, executor, callback);
126+
127+
credentials.refresh();
128+
129+
verify(updatedScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
130+
verify(updatedScopedCredentials, times(1)).refresh();
131+
}
132+
133+
@Test(expected = IllegalArgumentException.class)
134+
public void testCreateMutableCredentialsEmptyScopesThrowsError() {
135+
new MutableCredentials(initialCredentials, Collections.emptySet());
136+
}
137+
138+
@Test
139+
public void testCreateMutableCredentialsNullCredentialsThrowsError() {
140+
NullPointerException exception =
141+
assertThrows(NullPointerException.class, () -> new MutableCredentials(null, scopes));
142+
assertEquals("credentials must not be null", exception.getMessage());
143+
}
144+
145+
@Test
146+
public void testCreateMutableCredentialsNullScopesThrowsError() {
147+
NullPointerException exception =
148+
assertThrows(
149+
NullPointerException.class, () -> new MutableCredentials(initialCredentials, null));
150+
assertEquals("scopes must not be null", exception.getMessage());
151+
}
152+
153+
@Test
154+
public void testUpdateMutableCredentialsNullCredentialsThrowsError() throws IOException {
155+
setupInitialCredentials();
156+
MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
157+
158+
NullPointerException exception =
159+
assertThrows(NullPointerException.class, () -> credentials.updateCredentials(null));
160+
assertEquals("credentials must not be null", exception.getMessage());
161+
}
162+
163+
private void validateInitialDelegatedCredentialsAreSet(
164+
MutableCredentials credentials, URI testUri) throws IOException {
165+
assertEquals(initialAuthType, credentials.getAuthenticationType());
166+
assertTrue(credentials.hasRequestMetadata());
167+
assertTrue(credentials.hasRequestMetadataOnly());
168+
assertEquals(initialMetadata, credentials.getRequestMetadata(testUri));
169+
assertEquals(initialUniverseDomain, credentials.getUniverseDomain());
170+
assertEquals(initialMetricsCredentialType, credentials.getMetricsCredentialType());
171+
}
172+
173+
private void setupInitialCredentials() throws IOException {
174+
when(initialCredentials.createScoped(scopes)).thenReturn(initialScopedCredentials);
175+
when(initialCredentials.createScoped(Collections.emptyList()))
176+
.thenReturn(initialScopedCredentials);
177+
when(initialScopedCredentials.getAuthenticationType()).thenReturn(initialAuthType);
178+
when(initialScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(initialMetadata);
179+
when(initialScopedCredentials.getUniverseDomain()).thenReturn(initialUniverseDomain);
180+
when(initialScopedCredentials.getMetricsCredentialType())
181+
.thenReturn(initialMetricsCredentialType);
182+
when(initialScopedCredentials.hasRequestMetadata()).thenReturn(true);
183+
when(initialScopedCredentials.hasRequestMetadataOnly()).thenReturn(true);
184+
}
185+
186+
private void setupUpdatedCredentials() throws IOException {
187+
when(updatedCredentials.createScoped(scopes)).thenReturn(updatedScopedCredentials);
188+
when(updatedScopedCredentials.getAuthenticationType()).thenReturn(updatedAuthType);
189+
when(updatedScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(updatedMetadata);
190+
when(updatedScopedCredentials.getUniverseDomain()).thenReturn(updatedUniverseDomain);
191+
when(updatedScopedCredentials.getMetricsCredentialType())
192+
.thenReturn(updatedMetricsCredentialType);
193+
when(updatedScopedCredentials.hasRequestMetadata()).thenReturn(false);
194+
when(updatedScopedCredentials.hasRequestMetadataOnly()).thenReturn(false);
195+
}
196+
}

0 commit comments

Comments
 (0)