Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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,94 @@
/*
* 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 static org.junit.Assume.assumeTrue;

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.ITAbstractSpannerTest;
import com.google.cloud.spanner.connection.MutableCredentials;
import com.google.spanner.admin.database.v1.Database;
import com.google.spanner.admin.database.v1.DatabaseName;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
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 extends ITAbstractSpannerTest {
private static final String VALID_KEY_RESOURCE =
"/com/google/cloud/spanner/connection/test-key-cloud-storage.json";

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

@Test
public void testMutableCredentialsUpdateAuthorizationForRunningClient() throws IOException {

GoogleCredentials credentialsFromFile;
try (InputStream stream = Files.newInputStream(Paths.get(VALID_KEY_RESOURCE))) {
credentialsFromFile = GoogleCredentials.fromStream(stream);
}
assumeTrue(
"This test requires service account credentials",
credentialsFromFile instanceof ServiceAccountCredentials);

ServiceAccountCredentials validCredentials = (ServiceAccountCredentials) credentialsFromFile;
ServiceAccountCredentials invalidCredentials;
try (InputStream stream = Files.newInputStream(Paths.get(INVALID_KEY_RESOURCE))) {
assertNotNull("Missing test resource: " + INVALID_KEY_RESOURCE, stream);
invalidCredentials = ServiceAccountCredentials.fromStream(stream);
}

List<String> scopes = new ArrayList<>(getTestEnv().getTestHelper().getOptions().getScopes());
MutableCredentials mutableCredentials = new MutableCredentials(validCredentials, scopes);

SpannerOptions options = SpannerOptions.newBuilder().setCredentials(mutableCredentials).build();

try (Spanner spanner = options.getService();
DatabaseAdminClient databaseAdminClient = spanner.createDatabaseAdminClient()) {
String dbName =
DatabaseName.of(
GceTestEnvConfig.GCE_PROJECT_ID,
getTestEnv().getTestHelper().getInstanceId().getInstance(),
"TEST")
.toString();
Database database = databaseAdminClient.getDatabase(dbName);
assertNotNull(database);
try {
mutableCredentials.updateCredentials(invalidCredentials);
databaseAdminClient.getDatabase(dbName);
fail("Expected UNAUTHENTICATED after switching to invalid credentials");
} catch (SpannerException e) {
assertEquals(ErrorCode.UNAUTHENTICATED, e.getErrorCode());
}
} finally {
closeSpanner();
}
}
}
Loading