diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java new file mode 100644 index 00000000000..b37323eaaf1 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java @@ -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. + * + *

This class is intended for scenarios where an application needs to replace the underlying + * service account credentials for a long running Spanner Client. + * + *

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 scopes; + + public MutableCredentials(ServiceAccountCredentials credentials, List 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. + * + *

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> 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(); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java new file mode 100644 index 00000000000..e4bf0820a24 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java @@ -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 scopes = Arrays.asList("scope-a", "scope-b"); + Map> initialMetadata = + Collections.singletonMap("Authorization", Collections.singletonList("v1")); + Map> 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); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java new file mode 100644 index 00000000000..8ab0e283ea4 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java @@ -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 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 + } + } + } +} diff --git a/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json b/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json new file mode 100644 index 00000000000..348e769f6fb --- /dev/null +++ b/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json @@ -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" +}