Skip to content
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

feat: added regional secret support for secret-manager #3365

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions docs/src/main/asciidoc/secretmanager.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This can be overridden using the authentication properties.
| `spring.cloud.gcp.secretmanager.credentials.location` | OAuth2 credentials for authenticating to the Google Cloud Secret Manager API. | No | By default, infers credentials from https://cloud.google.com/docs/authentication/production[Application Default Credentials].
| `spring.cloud.gcp.secretmanager.credentials.encoded-key` | Base64-encoded contents of OAuth2 account private key for authenticating to the Google Cloud Secret Manager API. | No | By default, infers credentials from https://cloud.google.com/docs/authentication/production[Application Default Credentials].
| `spring.cloud.gcp.secretmanager.project-id` | The default Google Cloud project used to access Secret Manager API for the template and property source. | No | By default, infers the project from https://cloud.google.com/docs/authentication/production[Application Default Credentials].
| spring.cloud.gcp.secretmanager.location | Defines the region of the Secret Manager where your secrets are stored, specifically when using a regional stack. This option is particularly useful for applications that need to access secrets from a specific geographical location. | No | By default, the global stack will be utilized.
|`spring.cloud.gcp.secretmanager.allow-default-secret`| Define the behavior when accessing a non-existent secret string/bytes. +
If set to `true`, `null` will be returned when accessing a non-existent secret; otherwise throwing an exception. | No | `false`
|===
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public SecretManagerServiceClient secretManagerClient()
@ConditionalOnMissingBean
public SecretManagerTemplate secretManagerTemplate(SecretManagerServiceClient client) {
return new SecretManagerTemplate(client, this.gcpProjectIdProvider)
.setAllowDefaultSecretValue(this.properties.isAllowDefaultSecret());
.setAllowDefaultSecretValue(this.properties.isAllowDefaultSecret())
.setLocation(this.properties.getLocation());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import com.google.cloud.spring.core.Credentials;
import com.google.cloud.spring.core.CredentialsSupplier;
import com.google.cloud.spring.core.GcpScope;
import java.util.Optional;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;

Expand All @@ -43,6 +44,13 @@ public class GcpSecretManagerProperties implements CredentialsSupplier {
*/
private String projectId;

/**
* Defines the region of the secrets when Regional Stack is used.
*
* <p>When not specified, the secret manager will use the Global Stack.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it syntactically correct if there is no closing </p> tag?

Suggested change
* <p>When not specified, the secret manager will use the Global Stack.
* <p>When not specified, the secret manager will use the Global Stack.</p>

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry. You are right. I wasn't aware that the closing tag is not mandatory.

*/
private Optional<String> location = Optional.empty();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I think your PR (or the feature you're bringing) is totally cool, I've taken a look at the code in its entirety today, not just the delta shown up here in 'Files changed' view.

May I ask questions about the way you've implemented the 'location'?

The first one is, why as Optional? I like Optionals, but in this specific case, I don't see a great deal of added value.

  • Similarly to projectId, the location can be unset. The projectId in the existing code is implemented as a simple String, and the location as Optional. In terms of code style (not in the sense of arrangement), I would tend to follow the direction of the rest of the code (String).
  • There are no mappings (.map(...)) of the Optional type taking place. The only use case is the check for isPresent(). This could also be implemented - similarly to projectId - with an IF condition.

The other is, if the Optional type is still to be used, could the variable at least be renamed accordingly?

  • In the spring-cloud-gcp library, it's common practice (although this is just a gut feeling and not backed up by evidence) to work with Strings or special objects (SecretVersionName, LocationName, ...). So, if an Optional is introduced, the variable name should somehow reflect this. Although I must admit that this is debatable. I know many voices that say technical implementation details shouldn't be included in variable names.
  • One could, of course, argue that every IDE displays the return type of getLocation() as Optional<String>. However, if you look at the code without an IDE (as is the case here on GitHub), the return type is not immediately apparent and expectations are set. Providing a hint through variable names could at least be considered.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of Optional here is to explicitly signal that location may or may not be present, which I believe makes the intent clearer than a plain String. While it's true we're not using .map() or chaining operations now, Optional forces consumers to consider the absence of location explicitly (rather than relying on null checks). This can help avoid future bugs or assumptions about the field always being set.

I understand your suggestion regarding variable naming, but I believe the intent is already clear from the type itself (Optional<String>), and I prefer to keep the variable name focused on the domain rather than the technical implementation detail.

Let me know your thoughts, and I'm happy to make further adjustments if needed!


/**
* Whether the secret manager will allow a default secret value when accessing a non-existing
* secret.
Expand Down Expand Up @@ -71,4 +79,12 @@ public boolean isAllowDefaultSecret() {
public void setAllowDefaultSecret(boolean allowDefaultSecret) {
this.allowDefaultSecret = allowDefaultSecret;
}

public Optional<String> getLocation() {
return location;
}

public void setLocation(String location) {
this.location = Optional.ofNullable(location).filter(loc -> !loc.isEmpty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ public ConfigData load(
GcpProjectIdProvider projectIdProvider = context.getBootstrapContext()
.get(GcpProjectIdProvider.class);

return new ConfigData(Collections.singleton(new SecretManagerPropertySource(
"spring-cloud-gcp-secret-manager", secretManagerTemplate, projectIdProvider)));
GcpSecretManagerProperties properties = context.getBootstrapContext()
.get(GcpSecretManagerProperties.class);

SecretManagerPropertySource secretManagerPropertySource = new SecretManagerPropertySource(
"spring-cloud-gcp-secret-manager", secretManagerTemplate, projectIdProvider);

secretManagerPropertySource.setLocation(properties.getLocation());

return new ConfigData(Collections.singleton(secretManagerPropertySource));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ public class SecretManagerConfigDataLocationResolver implements
* A static client to avoid creating another client after refreshing.
*/
private static SecretManagerServiceClient secretManagerServiceClient;
/**
* A static endpoint format for regional client creation.
*/
private static final String ENDPOINT_FORMAT = "secretmanager.%s.rep.googleapis.com:443";

@Override
public boolean isResolvable(ConfigDataLocationResolverContext context,
Expand Down Expand Up @@ -112,12 +116,15 @@ static synchronized SecretManagerServiceClient createSecretManagerClient(
.get(GcpSecretManagerProperties.class);
DefaultCredentialsProvider credentialsProvider =
new DefaultCredentialsProvider(properties);
SecretManagerServiceSettings settings = SecretManagerServiceSettings.newBuilder()
.setCredentialsProvider(credentialsProvider)
.setHeaderProvider(
new UserAgentHeaderProvider(SecretManagerConfigDataLoader.class))
.build();
secretManagerServiceClient = SecretManagerServiceClient.create(settings);
SecretManagerServiceSettings.Builder settings =
SecretManagerServiceSettings.newBuilder()
.setCredentialsProvider(credentialsProvider)
.setHeaderProvider(new UserAgentHeaderProvider(SecretManagerConfigDataLoader.class));

properties.getLocation().ifPresent(location ->
settings.setEndpoint(String.format(ENDPOINT_FORMAT, properties.getLocation().get())));

secretManagerServiceClient = SecretManagerServiceClient.create(settings.build());

return secretManagerServiceClient;
} catch (IOException e) {
Expand All @@ -136,7 +143,8 @@ private static SecretManagerTemplate createSecretManagerTemplate(
.get(GcpSecretManagerProperties.class);

return new SecretManagerTemplate(client, projectIdProvider)
.setAllowDefaultSecretValue(properties.isAllowDefaultSecret());
.setAllowDefaultSecretValue(properties.isAllowDefaultSecret())
.setLocation(properties.getLocation());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ void testSecretManagerTemplateExists() {
.isNotNull());
}

@Test
void testLocationWithSecretManagerProperties() {
contextRunner
.withPropertyValues("spring.cloud.gcp.secretmanager.location=us-central1")
.run(
ctx -> assertThat(ctx.getBean(SecretManagerTemplate.class)
.getLocation()).isEqualTo("us-central1"));
}

static class TestConfig {

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package com.google.cloud.spring.autoconfigure.secretmanager;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.cloud.spring.core.GcpProjectIdProvider;
import com.google.cloud.spring.secretmanager.SecretManagerPropertySource;
import com.google.cloud.spring.secretmanager.SecretManagerTemplate;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.boot.ConfigurableBootstrapContext;
import org.springframework.boot.context.config.ConfigData;
import org.springframework.boot.context.config.ConfigDataLoaderContext;
import org.springframework.boot.context.config.ConfigDataLocation;

Expand All @@ -21,18 +26,30 @@ class SecretManagerConfigDataLoaderUnitTests {
private final ConfigDataLoaderContext loaderContext = mock(ConfigDataLoaderContext.class);
private final GcpProjectIdProvider idProvider = mock(GcpProjectIdProvider.class);
private final SecretManagerTemplate template = mock(SecretManagerTemplate.class);
private final GcpSecretManagerProperties properties = mock(GcpSecretManagerProperties.class);
private final ConfigurableBootstrapContext bootstrapContext = mock(
ConfigurableBootstrapContext.class);
private final SecretManagerConfigDataLoader loader = new SecretManagerConfigDataLoader();

@Test
void loadIncorrectResourceThrowsException() {
@ParameterizedTest
@CsvSource({
"regional-fake, us-central1",
"fake, "
})
void loadIncorrectResourceThrowsException(String resourceName, String location) {
when(loaderContext.getBootstrapContext()).thenReturn(bootstrapContext);
when(bootstrapContext.get(GcpProjectIdProvider.class)).thenReturn(idProvider);
when(bootstrapContext.get(SecretManagerTemplate.class)).thenReturn(template);
when(bootstrapContext.get(GcpSecretManagerProperties.class)).thenReturn(properties);
when(template.secretExists(anyString(), anyString())).thenReturn(false);
when(properties.getLocation()).thenReturn(Optional.ofNullable(location));
SecretManagerConfigDataResource resource = new SecretManagerConfigDataResource(
ConfigDataLocation.of("fake"));
assertThatCode(() -> loader.load(loaderContext, resource)).doesNotThrowAnyException();
ConfigDataLocation.of(resourceName));
assertThatCode(() -> {
ConfigData configData = loader.load(loaderContext, resource);
SecretManagerPropertySource propertySource =
(SecretManagerPropertySource) configData.getPropertySources().get(0);
assertThat(Optional.ofNullable(location)).isEqualTo(propertySource.getLocation());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, I'm not from Google, so my opinion is truly not more than my opinion. Google may see things different 😉

Now, in assertions you normally test assumtions/expectations on an object. Having this on mind the assertions should be more in this way:

assertThat(objectToTest)
  .verificationOne(...)
  .verificationTwo(...);

The result is the same, but now it is in a more logical order:

assertThat(propertySource)
  .returns(Optional.ofNullable(location), SecretManagerPropertySource::getLocation);

You could add more verifications if suitable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the existing convention used throughout the codebase, where assertions are written in the same order as the current pattern. However, I understand your perspective, and if the team prefers the alternative style you suggested, I’m happy to make the adjustment.

}).doesNotThrowAnyException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.google.cloud.spring.autoconfigure.secretmanager;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.api.gax.rpc.NotFoundException;
import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse;
import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
import com.google.cloud.secretmanager.v1.SecretPayload;
import com.google.cloud.secretmanager.v1.SecretVersionName;
import com.google.protobuf.ByteString;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.BootstrapRegistry.InstanceSupplier;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;


/**
* Unit tests to check compatibility of Secret Manager for regional endpoints.
*/
class SecretManagerRegionalCompatibilityTests {

private static final String PROJECT_NAME = "regional-secret-manager-project";
private static final String LOCATION = "us-central1";
private SpringApplicationBuilder application;
private SecretManagerServiceClient client;

@BeforeEach
void init() {
application = new SpringApplicationBuilder(SecretManagerRegionalCompatibilityTests.class)
.web(WebApplicationType.NONE)
.properties(
"spring.cloud.gcp.secretmanager.project-id=" + PROJECT_NAME,
"spring.cloud.gcp.sql.enabled=false",
"spring.cloud.gcp.secretmanager.location=" + LOCATION);

client = mock(SecretManagerServiceClient.class);
SecretVersionName secretVersionName =
SecretVersionName.newProjectLocationSecretSecretVersionBuilder()
.setProject(PROJECT_NAME)
.setLocation(LOCATION)
.setSecret("my-reg-secret")
.setSecretVersion("latest")
.build();
when(client.accessSecretVersion(secretVersionName))
.thenReturn(
AccessSecretVersionResponse.newBuilder()
.setPayload(
SecretPayload.newBuilder().setData(ByteString.copyFromUtf8("newRegSecret")))
.build());
secretVersionName =
SecretVersionName.newProjectLocationSecretSecretVersionBuilder()
.setProject(PROJECT_NAME)
.setLocation(LOCATION)
.setSecret("fake-reg-secret")
.setSecretVersion("latest")
.build();
when(client.accessSecretVersion(secretVersionName))
.thenThrow(NotFoundException.class);
}

/**
* Users with the new configuration (i.e., using `spring.config.import`) should get {@link
* com.google.cloud.spring.secretmanager.SecretManagerTemplate} autoconfiguration and properties
* resolved.
*/
@Test
void testRegionalConfigurationWhenDefaultSecretIsNotAllowed() {
application.properties(
"spring.config.import=sm://")
.addBootstrapRegistryInitializer(
(registry) -> registry.registerIfAbsent(
SecretManagerServiceClient.class,
InstanceSupplier.of(client)
)
);
try (ConfigurableApplicationContext applicationContext = application.run()) {
ConfigurableEnvironment environment = applicationContext.getEnvironment();
assertThat(environment.getProperty("sm://my-reg-secret")).isEqualTo("newRegSecret");
assertThatThrownBy(() -> environment.getProperty("sm://fake-reg-secret"))
.isExactlyInstanceOf(NotFoundException.class);
}
}

@Test
void testRegionalConfigurationWhenDefaultSecretIsAllowed() {
application.properties(
"spring.cloud.gcp.secretmanager.allow-default-secret=true",
"spring.config.import=sm://")
.addBootstrapRegistryInitializer(
(registry) -> registry.registerIfAbsent(
SecretManagerServiceClient.class,
InstanceSupplier.of(client)
)
);
try (ConfigurableApplicationContext applicationContext = application.run()) {
ConfigurableEnvironment environment = applicationContext.getEnvironment();
assertThat(environment.getProperty("sm://my-reg-secret")).isEqualTo("newRegSecret");
assertThat(environment.getProperty("sm://fake-reg-secret")).isNull();
}
}
}
2 changes: 2 additions & 0 deletions spring-cloud-gcp-samples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<module>spring-cloud-gcp-data-firestore-sample</module>
<module>spring-cloud-gcp-bigquery-sample</module>
<module>spring-cloud-gcp-security-firebase-sample</module>
<module>spring-cloud-gcp-secretmanager-regional-sample</module>
<module>spring-cloud-gcp-secretmanager-sample</module>
<module>spring-cloud-gcp-kotlin-samples</module>
<module>spring-cloud-gcp-metrics-sample</module>
Expand All @@ -103,6 +104,7 @@
<module>spring-cloud-gcp-integration-storage-sample</module>
<module>spring-cloud-gcp-trace-sample</module>
<module>spring-cloud-gcp-vision-api-sample</module>
<module>spring-cloud-gcp-secretmanager-regional-sample</module>
<module>spring-cloud-gcp-secretmanager-sample</module>
<module>spring-cloud-gcp-vision-ocr-demo</module>
<module>spring-cloud-gcp-data-spanner-template-sample</module>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
= Spring Framework on Google Cloud Secret Manager Regional Sample Application

This code sample demonstrates how to use the Spring Framework on Google Cloud Secret Manager integration.
The sample demonstrates how one can access Secret Manager regional secrets through a `@ConfigurationProperties` class and also through `@Value` annotations on fields.

== Running the Sample

image:http://gstatic.com/cloudssh/images/open-btn.svg[link=https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FGoogleCloudPlatform%2Fspring-cloud-gcp&cloudshell_open_in_editor=spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/README.adoc]

1. Create a Google Cloud project with https://cloud.google.com/billing/docs/how-to/modify-project#enable-billing[billing enabled], if you don't have one already.

2. Enable the Secret Manager API from the "APIs & Services" menu of the Google Cloud Console.
This can be done using the `gcloud` command line tool:
+
[source]
----
gcloud services enable secretmanager.googleapis.com
----

3. Authenticate in one of two ways:

a. Use the Google Cloud SDK to https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login[authenticate with application default credentials].
This method is convenient but should only be used in local development.
b. https://cloud.google.com/iam/docs/creating-managing-service-accounts[Create a new service account], download its private key and point the `spring.cloud.gcp.secretmanager.credentials.location` property to it.
+
Such as: `spring.cloud.gcp.secretmanager.credentials.location=file:/path/to/creds.json`

4. Using the https://console.cloud.google.com/security/secret-manager;regionalSecret[Secret Manager UI in Cloud Console], create a new regional secret named `application-secret` at us-central1 location and set it to any value.
Instructions for using the Secret Manager UI can be found in the https://cloud.google.com/secret-manager/regional-secrets/create-regional-secret[Secret Manager documentation].

5. Make sure that the `spring.cloud.gcp.secretmanager.location` property points to the desired location for the regional secret. For this sample, we have kept the location as us-central1.
+
Such as: `spring.cloud.gcp.secretmanager.location=us-central1`

6. Run `$ mvn clean install` from the root directory of the project.

7. Run `$ mvn spring-boot:run` command from the same directory as this sample's `pom.xml` file.

8. Go to http://localhost:8080 in your browser or use the `Web Preview` button in Cloud Shell to preview the app
on port 8080. Your secret value is injected into your application through the `WebController` and you will see it
displayed.
+
[source]
----
applicationSecret: Hello regional world.
----
+
You will also see some web forms that allow you to create, read, and update regional secrets in Secret Manager.
This is done by using the `SecretManagerTemplate`.
+
Finally, you can view all of your regional secrets using the https://console.cloud.google.com/security/secret-manager;regionalSecret[Secret Manager Cloud Console UI], which is the source of truth for all of your secrets in Secret Manager.

9. Refresh the secrets without restarting the application:

a. After running the application, change your secrets using https://console.cloud.google.com/security/secret-manager;regionalSecret[Secret Manager Cloud Console UI].

b. To refresh the secret, send the following command to your server from which hosting the application:
+
[source]
----
curl -X POST http://localhost:8080/actuator/refresh
----
Note that only `@ConfigurationProperties` annotated with `@RefreshScope` got the updated value.
Loading