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

Commit 3556d72

Browse files
committed
feat: ability to update credentials on long running client
1 parent 9263972 commit 3556d72

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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.connection;
17+
18+
import com.google.auth.Credentials;
19+
import com.google.auth.oauth2.ServiceAccountCredentials;
20+
21+
import java.io.IOException;
22+
import java.net.URI;
23+
import java.util.List;
24+
import java.util.Map;
25+
26+
/**
27+
* A mutable {@link Credentials} implementation that delegates authentication behavior to a scoped
28+
* {@link ServiceAccountCredentials} instance.
29+
*
30+
* <p>This class is intended for scenarios where an application needs to rotate or replace the
31+
* underlying service account credentials for a running Spanner Client.
32+
*
33+
* <p>All operations inherited from {@link Credentials} are forwarded to the current delegate,
34+
* including request metadata retrieval and token refresh. Calling
35+
* {@link #updateCredentials(ServiceAccountCredentials)} replaces the delegate with a newly scoped
36+
* credentials instance created from the same scopes that were provided when this object was
37+
* constructed.
38+
*/
39+
public class MutableCredentials extends Credentials {
40+
ServiceAccountCredentials delegate;
41+
List<String> scopes;
42+
43+
public MutableCredentials(ServiceAccountCredentials credentials, List<String> scopes) {
44+
this. scopes = scopes;
45+
delegate = (ServiceAccountCredentials) credentials.createScoped(scopes);
46+
}
47+
48+
/**
49+
* Replaces the current delegate with a newly scoped credentials instance.
50+
*
51+
* <p>The provided {@link ServiceAccountCredentials} is scoped using the same scopes that were
52+
* supplied when this {@link MutableCredentials} instance was created.
53+
*
54+
* @param credentials the new base service account credentials to scope and use for client
55+
* authorization.
56+
*/
57+
public void updateCredentials(ServiceAccountCredentials credentials) {
58+
delegate =(ServiceAccountCredentials) credentials.createScoped(scopes);
59+
}
60+
61+
@Override
62+
public String getAuthenticationType() {
63+
return delegate.getAuthenticationType();
64+
}
65+
66+
@Override
67+
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
68+
return delegate.getRequestMetadata(uri);
69+
}
70+
71+
@Override
72+
public boolean hasRequestMetadata() {
73+
return delegate.hasRequestMetadata();
74+
}
75+
76+
@Override
77+
public boolean hasRequestMetadataOnly() {
78+
return delegate.hasRequestMetadataOnly();
79+
}
80+
81+
@Override
82+
public void refresh() throws IOException {
83+
delegate.refresh();
84+
}
85+
}
86+
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.connection;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertTrue;
21+
import static org.junit.Assert.fail;
22+
import static org.junit.Assume.assumeTrue;
23+
24+
import com.google.auth.oauth2.GoogleCredentials;
25+
import com.google.auth.oauth2.ServiceAccountCredentials;
26+
import com.google.cloud.spanner.ErrorCode;
27+
import com.google.cloud.spanner.ResultSet;
28+
import com.google.cloud.spanner.SerialIntegrationTest;
29+
import com.google.cloud.spanner.SpannerException;
30+
import com.google.cloud.spanner.Statement;
31+
import java.io.IOException;
32+
import java.io.InputStream;
33+
import java.nio.file.Files;
34+
import java.nio.file.Paths;
35+
import java.util.ArrayList;
36+
import java.util.List;
37+
import org.junit.Test;
38+
import org.junit.experimental.categories.Category;
39+
import org.junit.runner.RunWith;
40+
import org.junit.runners.JUnit4;
41+
42+
@Category(SerialIntegrationTest.class)
43+
@RunWith(JUnit4.class)
44+
public class ITMutableCredentialsTest extends ITAbstractSpannerTest {
45+
private static final String INVALID_KEY_FILE =
46+
ITMutableCredentialsTest.class.getResource("test-key.json").getPath();
47+
48+
@Test
49+
public void testMutableCredentialsUpdateAuthorizationForRunningClient() throws IOException {
50+
assumeTrue("This test requires a service account key file", hasValidKeyFile());
51+
52+
GoogleCredentials credentialsFromFile;
53+
try (InputStream stream = Files.newInputStream(Paths.get(getKeyFile()))) {
54+
credentialsFromFile = GoogleCredentials.fromStream(stream);
55+
}
56+
assumeTrue(
57+
"This test requires service account credentials",
58+
credentialsFromFile instanceof ServiceAccountCredentials);
59+
60+
ServiceAccountCredentials validCredentials = (ServiceAccountCredentials) credentialsFromFile;
61+
ServiceAccountCredentials invalidCredentials;
62+
try (InputStream stream = Files.newInputStream(Paths.get(INVALID_KEY_FILE))) {
63+
invalidCredentials = ServiceAccountCredentials.fromStream(stream);
64+
}
65+
66+
List<String> scopes = new ArrayList<>(getTestEnv().getTestHelper().getOptions().getScopes());
67+
MutableCredentials mutableCredentials = new MutableCredentials(validCredentials, scopes);
68+
69+
StringBuilder uri =
70+
extractConnectionUrl(getTestEnv().getTestHelper().getOptions(), getDatabase());
71+
ConnectionOptions options =
72+
ConnectionOptions.newBuilder()
73+
.setUri(uri.toString())
74+
.setCredentials(mutableCredentials)
75+
.build();
76+
77+
try (Connection connection = options.getConnection()) {
78+
try (ResultSet rs = connection.executeQuery(Statement.of("SELECT 1"))) {
79+
assertTrue(rs.next());
80+
}
81+
82+
mutableCredentials.updateCredentials(invalidCredentials);
83+
84+
try (ResultSet rs = connection.executeQuery(Statement.of("SELECT 2"))) {
85+
rs.next();
86+
fail("Expected UNAUTHENTICATED after switching to invalid credentials");
87+
} catch (SpannerException e) {
88+
assertEquals(ErrorCode.UNAUTHENTICATED, e.getErrorCode());
89+
}
90+
} finally {
91+
closeSpanner();
92+
}
93+
}
94+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.connection;
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.assertTrue;
23+
import static org.mockito.ArgumentMatchers.any;
24+
import static org.mockito.Mockito.mock;
25+
import static org.mockito.Mockito.times;
26+
import static org.mockito.Mockito.verify;
27+
import static org.mockito.Mockito.when;
28+
29+
import com.google.auth.oauth2.ServiceAccountCredentials;
30+
import java.io.IOException;
31+
import java.net.URI;
32+
import java.util.Arrays;
33+
import java.util.Collections;
34+
import java.util.List;
35+
import java.util.Map;
36+
import org.junit.Test;
37+
import org.junit.runner.RunWith;
38+
import org.junit.runners.JUnit4;
39+
40+
@RunWith(JUnit4.class)
41+
public class MutableCredentialsTest {
42+
ServiceAccountCredentials initialCredentials = mock(ServiceAccountCredentials.class);
43+
ServiceAccountCredentials initialScopedCredentials = mock(ServiceAccountCredentials.class);
44+
ServiceAccountCredentials updatedCredentials = mock(ServiceAccountCredentials.class);
45+
ServiceAccountCredentials updatedScopedCredentials = mock(ServiceAccountCredentials.class);
46+
List<String> scopes = Arrays.asList("scope-a", "scope-b");
47+
Map<String, List<String>> initialMetadata =
48+
Collections.singletonMap("Authorization", Collections.singletonList("v1"));
49+
Map<String, List<String>> updatedMetadata =
50+
Collections.singletonMap("Authorization", Collections.singletonList("v2"));
51+
String initialAuthType = "auth-1";
52+
String updatedAuthType = "auth-2";
53+
54+
@Test
55+
public void testCreateMutableCredentialsAndUpdate() throws IOException {
56+
setupInitialCredentials();
57+
setupUpdatedCredentials();
58+
59+
MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
60+
61+
assertEquals(initialAuthType, credentials.getAuthenticationType());
62+
assertTrue(credentials.hasRequestMetadata());
63+
assertTrue(credentials.hasRequestMetadataOnly());
64+
assertEquals(initialMetadata, credentials.getRequestMetadata(URI.create("https://spanner.googleapis.com")));
65+
66+
credentials.refresh();
67+
68+
verify(initialScopedCredentials, times(1)).refresh();
69+
70+
credentials.updateCredentials(updatedCredentials);
71+
72+
assertEquals(updatedAuthType, credentials.getAuthenticationType());
73+
assertFalse(credentials.hasRequestMetadata());
74+
assertFalse(credentials.hasRequestMetadataOnly());
75+
assertSame(updatedMetadata, credentials.getRequestMetadata(URI.create("https://example.com")));
76+
77+
credentials.refresh();
78+
79+
verify(updatedScopedCredentials, times(1)).refresh();
80+
}
81+
82+
private void setupInitialCredentials() throws IOException {
83+
when(initialCredentials.createScoped(scopes)).thenReturn(initialScopedCredentials);
84+
when(initialScopedCredentials.getAuthenticationType()).thenReturn(initialAuthType);
85+
when(initialScopedCredentials.getRequestMetadata(any(URI.class)))
86+
.thenReturn(initialMetadata);
87+
when(initialScopedCredentials.hasRequestMetadata()).thenReturn(true);
88+
when(initialScopedCredentials.hasRequestMetadataOnly()).thenReturn(true);
89+
}
90+
91+
private void setupUpdatedCredentials() throws IOException {
92+
when(updatedCredentials.createScoped(scopes)).thenReturn(updatedScopedCredentials);
93+
when(updatedScopedCredentials.getAuthenticationType()).thenReturn(updatedAuthType);
94+
when(updatedScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(updatedMetadata);
95+
when(updatedScopedCredentials.hasRequestMetadata()).thenReturn(false);
96+
when(updatedScopedCredentials.hasRequestMetadataOnly()).thenReturn(false);
97+
}
98+
}

0 commit comments

Comments
 (0)