-
Notifications
You must be signed in to change notification settings - Fork 137
feat: ability to update credentials on long running client #4371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3556d72
02a00ac
bd89a33
e871f0f
b802f0a
40abd6d
e5654af
28bb07a
1a302e8
74995fe
61468f0
fc8c682
2cf5b89
1751af2
22a9ba7
80b67bf
b0c1514
e920709
910eb82
816dcfc
d3dab34
9739af2
8c690d2
2d70ec5
917cb2d
fa7ea61
1d41b10
0378706
9c4b12b
89d4789
bb56c65
52b761b
06103fd
d5fb2f8
e5a4c93
3e71bef
bbfb3e7
2778372
4c5ec92
eb0164e
bf52fa2
a50eee8
8d640ef
38ddfb4
4ed2f0b
09c1e63
8805dd7
90c6b7b
ef64649
4119282
325db5d
960e41e
de735ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| private volatile ServiceAccountCredentials delegate; | ||
| private final Set<String> scopes; | ||
|
|
||
| /** Creates a MutableCredentials instance with default spanner scopes. */ | ||
| public MutableCredentials(ServiceAccountCredentials credentials) { | ||
ldetmer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| */ | ||
| public void updateCredentials(ServiceAccountCredentials credentials) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
ldetmer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @Override | ||
| public String getAuthenticationType() { | ||
| return delegate.getAuthenticationType(); | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException { | ||
| return delegate.getRequestMetadata(uri); | ||
| } | ||
ldetmer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @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,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); | ||
| } | ||
ldetmer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: use |
||
| } | ||
| } | ||
| } | ||
ldetmer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
There was a problem hiding this comment.
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?