From b45b0fc81272962f4b0be91e59f3ef076083e6cc Mon Sep 17 00:00:00 2001 From: abheda-crest Date: Wed, 30 Oct 2024 15:57:15 +0530 Subject: [PATCH 1/3] secret-manager: added regional secret support --- docs/src/main/asciidoc/secretmanager.adoc | 1 + .../GcpSecretManagerAutoConfiguration.java | 3 +- .../GcpSecretManagerProperties.java | 16 + .../SecretManagerConfigDataLoader.java | 11 +- ...cretManagerConfigDataLocationResolver.java | 22 +- ...cretManagerAutoConfigurationUnitTests.java | 9 + ...ecretManagerConfigDataLoaderUnitTests.java | 27 +- ...cretManagerRegionalCompatibilityTests.java | 107 ++++++ spring-cloud-gcp-samples/pom.xml | 1 + .../README.adoc | 63 ++++ .../pom.xml | 79 +++++ .../example/RegionalSecretConfiguration.java | 35 ++ .../SecretManagerRegionalApplication.java | 29 ++ .../SecretManagerRegionalWebController.java | 126 +++++++ .../src/main/resources/application.properties | 18 + .../src/main/resources/templates/index.html | 135 ++++++++ ...onalSampleLoadSecretsIntegrationTests.java | 60 ++++ ...egionalSampleTemplateIntegrationTests.java | 98 ++++++ .../SecretManagerPropertySource.java | 39 ++- .../SecretManagerPropertyUtils.java | 20 +- .../secretmanager/SecretManagerTemplate.java | 86 ++--- .../SecretManagerPropertyUtilsTests.java | 105 ++++++ .../SecretManagerRegionalTemplateTests.java | 313 ++++++++++++++++++ ...nagerRegionalTemplateIntegrationTests.java | 100 ++++++ ...ecretManagerRegionalTestConfiguration.java | 97 ++++++ ...SecretManagerTemplateIntegrationTests.java | 4 +- 26 files changed, 1540 insertions(+), 64 deletions(-) create mode 100644 spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerRegionalCompatibilityTests.java create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/README.adoc create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/pom.xml create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/RegionalSecretConfiguration.java create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalApplication.java create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/templates/index.html create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleLoadSecretsIntegrationTests.java create mode 100644 spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleTemplateIntegrationTests.java create mode 100644 spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerRegionalTemplateTests.java create mode 100644 spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTemplateIntegrationTests.java create mode 100644 spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTestConfiguration.java diff --git a/docs/src/main/asciidoc/secretmanager.adoc b/docs/src/main/asciidoc/secretmanager.adoc index a769c31a5a..c73d7d060a 100644 --- a/docs/src/main/asciidoc/secretmanager.adoc +++ b/docs/src/main/asciidoc/secretmanager.adoc @@ -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` |=== diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfiguration.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfiguration.java index 365ef6739f..83a68a204b 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfiguration.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfiguration.java @@ -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()); } } diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerProperties.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerProperties.java index 852533d36b..363adc7976 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerProperties.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerProperties.java @@ -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; @@ -43,6 +44,13 @@ public class GcpSecretManagerProperties implements CredentialsSupplier { */ private String projectId; + /** + * Defines the region of the secrets when Regional Stack is used. + * + *

When not specified, the secret manager will use the Global Stack. + */ + private Optional location = Optional.empty(); + /** * Whether the secret manager will allow a default secret value when accessing a non-existing * secret. @@ -71,4 +79,12 @@ public boolean isAllowDefaultSecret() { public void setAllowDefaultSecret(boolean allowDefaultSecret) { this.allowDefaultSecret = allowDefaultSecret; } + + public Optional getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = Optional.ofNullable(location).filter(loc -> !loc.isEmpty()); + } } diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoader.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoader.java index e49bdea27b..098701b01b 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoader.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoader.java @@ -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)); } } diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLocationResolver.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLocationResolver.java index f75c9a1d1f..efe2970c2e 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLocationResolver.java +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLocationResolver.java @@ -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, @@ -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) { @@ -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()); } /** diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfigurationUnitTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfigurationUnitTests.java index f90ab28286..2357b4e3c3 100644 --- a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfigurationUnitTests.java +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfigurationUnitTests.java @@ -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 diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java index 38e054af11..61aae17292 100644 --- a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java @@ -1,14 +1,19 @@ package com.google.cloud.spring.autoconfigure.secretmanager; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertEquals; 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; @@ -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); + assertEquals(Optional.ofNullable(location), propertySource.getLocation()); + }).doesNotThrowAnyException(); } } diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerRegionalCompatibilityTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerRegionalCompatibilityTests.java new file mode 100644 index 0000000000..02187f53bd --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerRegionalCompatibilityTests.java @@ -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(); + } + } +} diff --git a/spring-cloud-gcp-samples/pom.xml b/spring-cloud-gcp-samples/pom.xml index 44cf2e2fdc..48f1a7a77e 100644 --- a/spring-cloud-gcp-samples/pom.xml +++ b/spring-cloud-gcp-samples/pom.xml @@ -80,6 +80,7 @@ spring-cloud-gcp-data-firestore-sample spring-cloud-gcp-bigquery-sample spring-cloud-gcp-security-firebase-sample + spring-cloud-gcp-secretmanager-regional-sample spring-cloud-gcp-secretmanager-sample spring-cloud-gcp-kotlin-samples spring-cloud-gcp-metrics-sample diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/README.adoc b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/README.adoc new file mode 100644 index 0000000000..4d3fbede2b --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/README.adoc @@ -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. diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/pom.xml b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/pom.xml new file mode 100644 index 0000000000..5ab856c894 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/pom.xml @@ -0,0 +1,79 @@ + + + + + + spring-cloud-gcp-samples + com.google.cloud + 5.8.1-SNAPSHOT + + + 4.0.0 + spring-cloud-gcp-secretmanager-regional-sample + Spring Framework on Google Cloud Code Sample - Secret Manager Regional Secrets + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + com.google.cloud + spring-cloud-gcp-starter-secretmanager + + + + org.apache.commons + commons-lang3 + + + + + junit + junit + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + com.google.cloud + spring-cloud-gcp-dependencies + ${project.version} + pom + import + + + + + + + + maven-deploy-plugin + + true + + + + + diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/RegionalSecretConfiguration.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/RegionalSecretConfiguration.java new file mode 100644 index 0000000000..78412b86ca --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/RegionalSecretConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.example; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; + +@ConfigurationProperties("application") +@RefreshScope +public class RegionalSecretConfiguration { + + private String secret; + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getSecret() { + return secret; + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalApplication.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalApplication.java new file mode 100644 index 0000000000..dca5a16373 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(RegionalSecretConfiguration.class) +public class SecretManagerRegionalApplication { + public static void main(String[] args) { + SpringApplication.run(SecretManagerRegionalApplication.class, args); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java new file mode 100644 index 0000000000..e9194d5a5f --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java @@ -0,0 +1,126 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.example; + +import com.google.cloud.spring.secretmanager.SecretManagerTemplate; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.util.HtmlUtils; + +@Controller +public class SecretManagerRegionalWebController { + + private static final String INDEX_PAGE = "index.html"; + private static final String APPLICATION_SECRET_FROM_VALUE = "applicationSecretFromValue"; + + private final SecretManagerTemplate secretManagerTemplate; + // Application secrets can be accessed using configuration properties class, + // secret can be refreshed when decorated with @RefreshScope on the class. + private final RegionalSecretConfiguration configuration; + + // For the default value takes place, there should be no property called `application-fake` + // in property files. + @Value("${${sm://application-fake}:DEFAULT}") + private String defaultSecret; + // Application secrets can be accessed using @Value syntax. + @Value("${sm://application-regional-secret}") + private String appSecretFromValue; + + public SecretManagerRegionalWebController(SecretManagerTemplate secretManagerTemplate, + RegionalSecretConfiguration configuration + ) { + this.secretManagerTemplate = secretManagerTemplate; + this.configuration = configuration; + } + + @GetMapping("/") + public ModelAndView renderIndex(ModelMap map) { + map.put("applicationDefaultSecret", defaultSecret); + map.put(APPLICATION_SECRET_FROM_VALUE, appSecretFromValue); + map.put("applicationSecretFromConfigurationProperties", configuration.getSecret()); + return new ModelAndView(INDEX_PAGE, map); + } + + @GetMapping("/getSecret") + @ResponseBody + public String getSecret( + @RequestParam String secretId, + @RequestParam(required = false) String version, + @RequestParam(required = false) String projectId, + ModelMap map) { + + if (StringUtils.isEmpty(version)) { + version = SecretManagerTemplate.LATEST_VERSION; + } + + String secretPayload; + if (StringUtils.isEmpty(projectId)) { + secretPayload = + this.secretManagerTemplate.getSecretString("sm://" + secretId + "/" + version); + } else { + secretPayload = + this.secretManagerTemplate.getSecretString( + "sm://" + projectId + "/" + secretId + "/" + version); + } + + return "Secret ID: " + + HtmlUtils.htmlEscape(secretId) + + " | Value: " + + secretPayload + + "

Go back"; + } + + @PostMapping("/createSecret") + public ModelAndView createSecret( + @RequestParam String secretId, + @RequestParam String secretPayload, + @RequestParam(required = false) String projectId, + ModelMap map) { + + if (StringUtils.isEmpty(projectId)) { + this.secretManagerTemplate.createSecret(secretId, secretPayload); + } else { + this.secretManagerTemplate.createSecret(secretId, secretPayload.getBytes(), projectId); + } + + map.put(APPLICATION_SECRET_FROM_VALUE, this.appSecretFromValue); + map.put("message", "Secret created!"); + return new ModelAndView(INDEX_PAGE, map); + } + + @PostMapping("/deleteSecret") + public ModelAndView deleteSecret( + @RequestParam String secretId, + @RequestParam(required = false) String projectId, + ModelMap map) { + if (StringUtils.isEmpty(projectId)) { + this.secretManagerTemplate.deleteSecret(secretId); + } else { + this.secretManagerTemplate.deleteSecret(secretId, projectId); + } + map.put(APPLICATION_SECRET_FROM_VALUE, this.appSecretFromValue); + map.put("message", "Secret deleted!"); + return new ModelAndView(INDEX_PAGE, map); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties new file mode 100644 index 0000000000..276d9f1094 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties @@ -0,0 +1,18 @@ +# You can directly load the secret into a variable, as in this example +# This demonstrates multiple ways of loading the same application secret using template syntax. +# +# Please refer to the Spring Cloud GCP Secret Manager reference documentation for the full protocol syntax. + +# You can also specify a secret from another project. +# example.property=${sm://MY_PROJECT/MY_SECRET_ID/MY_VERSION} + +# Using SpEL, you can reference an environment variable and fallback to a secret if it is missing. +# example.secret=${MY_ENV_VARIABLE:${sm://application-secret/latest}} + +management.endpoints.web.exposure.include=refresh +# enable external resource from GCP Secret Manager. +spring.config.import=sm:// +application.secret=${sm://application-regional-secret} +# enable default secret value when accessing non-exited secret. +spring.cloud.gcp.secretmanager.allow-default-secret=true +spring.cloud.gcp.secretmanager.location=us-central1 diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/templates/index.html b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/templates/index.html new file mode 100644 index 0000000000..520fec5875 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/templates/index.html @@ -0,0 +1,135 @@ + + + + + + Google Cloud Secret Manager Regional Secrets Demo + + + + + +

Secret Manager Regional Secrets Demo with Spring Cloud GCP

+ +
+

Secret Manager Property Source

+ At the bootstrap phase, we loaded the following regional secret from us-central1 location into the application context: +
+
+ Default Application secret if not found: [[${applicationDefaultSecret}]]
+ Application secret from @Value: [[${applicationSecretFromValue}]]
+ Application secret from @ConfigurationProperties: [[${applicationSecretFromConfigurationProperties}]] +
+ +
+

Create, Read, Update and Delete Secrets

+ +

+ Using the form below, you can create/update regional secrets in Secret Manager, read and delete them. + In the controller code, the SecretManagerTemplate is being used to do these operations. +

+ +

+ Here, We have set the location for the regional secrets as us-central1 in the application properties. + If we want to use different location, then we can set that particular location in the application properties. +

+ +

+ NOTE: In practice, you never want to allow your secrets to be visible as plaintext. + This is just a demonstration! +

+ +
+

Get Regional Secret by Secret ID

+

+ Get a regional secret by secret ID from Secret Manager. You will receive an error if you + try to get a secret ID that does not already exist. +

+
+
    +
  1. Secret ID:
  2. +
  3. Version (optional):
  4. +
  5. Project ID (optional):
  6. +
  7. +
+
+
+ +
+

Create/Update Regional Secret

+

+ This will create a regional secret if the provided secret ID does not exist. + Otherwise, it will create version under the provided secret ID. +

+
+
    +
  1. Secret ID:
  2. +
  3. Secret Payload:
  4. +
  5. Project ID (optional):
  6. +
  7. +
+
+ +
+ +
+

Delete Regional Secret

+

+ This will delete a regional secret if the provided secret ID exists. +

+
+
    +
  1. Secret ID:
  2. +
  3. Project ID (optional):
  4. +
  5. +
+
+
+ + +
+

+ Secret Manager Operation: [[${message}]] +

+
+
+ +
+

+ You can also view your regional secrets in the + + Secret Manager UI in Google Cloud Console + . +

+
+ + + diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleLoadSecretsIntegrationTests.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleLoadSecretsIntegrationTests.java new file mode 100644 index 0000000000..5b784cb1e8 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleLoadSecretsIntegrationTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.cloud.spring.secretmanager.SecretManagerTemplate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Tests sample application endpoint that loads secrets as properties into the application context. + * Application secret named "application-secret" must exist at location us-central1 and have a value of "Hello regional world.". + */ +@EnabledIfSystemProperty(named = "it.secretmanager", matches = "true") +@ExtendWith(SpringExtension.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = SecretManagerRegionalApplication.class) +class SecretManagerRegionalSampleLoadSecretsIntegrationTests { + + @Autowired private SecretManagerTemplate secretManagerTemplate; + + @Autowired private TestRestTemplate testRestTemplate; + + private static final String SECRET_CONTENT = "Hello regional world."; + + @Test + void testApplicationStartupSecretLoadsCorrectly() { + ResponseEntity response = this.testRestTemplate.getForEntity("/", String.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()) + .contains("Application secret from @Value: " + SECRET_CONTENT + ""); + assertThat(response.getBody()) + .contains( + "Application secret from @ConfigurationProperties: " + + SECRET_CONTENT + + ""); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleTemplateIntegrationTests.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleTemplateIntegrationTests.java new file mode 100644 index 0000000000..c1a29284f9 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/test/java/com/example/SecretManagerRegionalSampleTemplateIntegrationTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.cloud.spring.secretmanager.SecretManagerTemplate; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** Test for sample application web endpoints using SecretManagerTemplate. */ +@EnabledIfSystemProperty(named = "it.secretmanager", matches = "true") +@ExtendWith(SpringExtension.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = SecretManagerRegionalApplication.class) +class SecretManagerRegionalSampleTemplateIntegrationTests { + + @Autowired private SecretManagerTemplate secretManagerTemplate; + + @Autowired private TestRestTemplate testRestTemplate; + + private String secretName; + + @BeforeEach + void createRegionalSecret() { + this.secretName = String.format("secret-manager-sample-regional-secret-%s", UUID.randomUUID()); + secretManagerTemplate.createSecret(this.secretName, "54321"); + } + + @AfterEach + void deleteRegionalSecret() { + if (secretManagerTemplate.secretExists(this.secretName)) { + secretManagerTemplate.deleteSecret(this.secretName); + } + } + + @Test + void testCreateRegionalSecret() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("secretId", this.secretName); + params.add("projectId", ""); + params.add("secretPayload", "54321"); + HttpEntity> request = new HttpEntity<>(params, new HttpHeaders()); + + ResponseEntity response = + this.testRestTemplate.postForEntity("/createSecret", request, String.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } + + @Test + void testReadRegionalSecret() { + String getSecretUrl = String.format("/getSecret?secretId=%s", this.secretName); + ResponseEntity response = + this.testRestTemplate.getForEntity(getSecretUrl, String.class); + assertThat(response.getBody()) + .contains(String.format("Secret ID: %s | Value: 54321", this.secretName)); + } + + @Test + void testDeleteRegionalSecret() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("secretId", this.secretName); + params.add("projectId", ""); + HttpEntity> request = new HttpEntity<>(params, new HttpHeaders()); + + ResponseEntity response = + this.testRestTemplate.postForEntity("/deleteSecret", request, String.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} diff --git a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertySource.java b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertySource.java index 759b43e3d8..d917f0b750 100644 --- a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertySource.java +++ b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertySource.java @@ -18,6 +18,8 @@ import com.google.cloud.secretmanager.v1.SecretVersionName; import com.google.cloud.spring.core.GcpProjectIdProvider; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import org.springframework.core.env.EnumerablePropertySource; /** @@ -29,6 +31,7 @@ public class SecretManagerPropertySource extends EnumerablePropertySource { private final GcpProjectIdProvider projectIdProvider; + private Optional location = Optional.empty(); public SecretManagerPropertySource( String propertySourceName, @@ -38,13 +41,41 @@ public SecretManagerPropertySource( this.projectIdProvider = projectIdProvider; } + /** + * Set the location to be used when creating a SecretVersionName from a property string. This + * property is used when the property string does not contain enough information to create a + * SecretVersionName. + * + * @param location the location to be used when creating a SecretVersionName from a property + * string. + */ + public void setLocation(Optional location) { + this.location = location; + } + + /** + * Returns the location. + * + * @return the location + */ + public Optional getLocation() { + return location; + } + @Override public Object getProperty(String name) { - SecretVersionName secretIdentifier = - SecretManagerPropertyUtils.getSecretVersionName(name, this.projectIdProvider); + AtomicReference secretIdentifier = new AtomicReference<>(); + getLocation().ifPresentOrElse( + location -> secretIdentifier.set(SecretManagerPropertyUtils.getSecretVersionName( + name, this.projectIdProvider, location + )), + () -> secretIdentifier.set(SecretManagerPropertyUtils.getSecretVersionName( + name, this.projectIdProvider + )) + ); - if (secretIdentifier != null) { - return getSource().getSecretByteString(secretIdentifier); + if (secretIdentifier.get() != null) { + return getSource().getSecretByteString(secretIdentifier.get()); } else { return null; } diff --git a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtils.java b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtils.java index 5d3c2103fa..4b5a301854 100644 --- a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtils.java +++ b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtils.java @@ -29,6 +29,11 @@ private SecretManagerPropertyUtils() {} static SecretVersionName getSecretVersionName( String input, GcpProjectIdProvider projectIdProvider) { + return getSecretVersionName(input, projectIdProvider, null); + } + + static SecretVersionName getSecretVersionName( + String input, GcpProjectIdProvider projectIdProvider, String location) { if (!input.startsWith(GCP_SECRET_PREFIX)) { return null; } @@ -75,10 +80,15 @@ static SecretVersionName getSecretVersionName( Assert.hasText(version, "The GCP Secret Manager secret version must not be empty: " + input); - return SecretVersionName.newBuilder() - .setProject(projectId) - .setSecret(secretId) - .setSecretVersion(version) - .build(); + return getSecretVersionName(projectId, secretId, version, location); + } + + static SecretVersionName getSecretVersionName( + String projectId, String secretId, String version, String location) { + if (location != null) { + return SecretVersionName.newProjectLocationSecretSecretVersionBuilder().setLocation(location).setProject(projectId).setSecret(secretId).setSecretVersion(version).build(); + } else { + return SecretVersionName.newBuilder().setProject(projectId).setSecret(secretId).setSecretVersion(version).build(); + } } } diff --git a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerTemplate.java b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerTemplate.java index b71a4e3495..856708ad5b 100644 --- a/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerTemplate.java +++ b/spring-cloud-gcp-secretmanager/src/main/java/com/google/cloud/spring/secretmanager/SecretManagerTemplate.java @@ -20,6 +20,7 @@ import com.google.cloud.secretmanager.v1.AddSecretVersionRequest; import com.google.cloud.secretmanager.v1.CreateSecretRequest; import com.google.cloud.secretmanager.v1.DeleteSecretRequest; +import com.google.cloud.secretmanager.v1.LocationName; import com.google.cloud.secretmanager.v1.ProjectName; import com.google.cloud.secretmanager.v1.Replication; import com.google.cloud.secretmanager.v1.Secret; @@ -29,6 +30,7 @@ import com.google.cloud.secretmanager.v1.SecretVersionName; import com.google.cloud.spring.core.GcpProjectIdProvider; import com.google.protobuf.ByteString; +import java.util.Optional; import javax.annotation.Nullable; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -54,6 +56,8 @@ public class SecretManagerTemplate implements SecretManagerOperations { */ private boolean allowDefaultSecretValue; + private Optional location = Optional.empty(); + public SecretManagerTemplate( SecretManagerServiceClient secretManagerServiceClient, GcpProjectIdProvider projectIdProvider) { @@ -68,6 +72,16 @@ public SecretManagerTemplate setAllowDefaultSecretValue(boolean allowDefaultSecr return this; } + public SecretManagerTemplate setLocation(Optional location) { + this.location = location; + + return this; + } + + public String getLocation() { + return location.orElse(null); + } + public String getProjectId() { return projectIdProvider.getProjectId(); } @@ -110,7 +124,7 @@ public boolean secretExists(String secretId) { @Override public boolean secretExists(String secretId, String projectId) { - SecretName secretName = SecretName.of(projectId, secretId); + SecretName secretName = getSecretName(projectId, secretId); try { this.secretManagerServiceClient.getSecret(secretName); } catch (NotFoundException ex) { @@ -128,11 +142,8 @@ public void disableSecretVersion(String secretId, String version) { @Override public void disableSecretVersion(String secretId, String version, String projectId) { SecretVersionName secretVersionName = - SecretVersionName.newBuilder() - .setProject(projectId) - .setSecret(secretId) - .setSecretVersion(version) - .build(); + SecretManagerPropertyUtils.getSecretVersionName( + projectId, secretId, version, getLocation()); this.secretManagerServiceClient.disableSecretVersion(secretVersionName); } @@ -144,11 +155,8 @@ public void enableSecretVersion(String secretId, String version) { @Override public void enableSecretVersion(String secretId, String version, String projectId) { SecretVersionName secretVersionName = - SecretVersionName.newBuilder() - .setProject(projectId) - .setSecret(secretId) - .setSecretVersion(version) - .build(); + SecretManagerPropertyUtils.getSecretVersionName( + projectId, secretId, version, getLocation()); this.secretManagerServiceClient.enableSecretVersion(secretVersionName); } @@ -159,7 +167,7 @@ public void deleteSecret(String secretId) { @Override public void deleteSecret(String secretId, String projectId) { - SecretName name = SecretName.of(projectId, secretId); + SecretName name = getSecretName(projectId, secretId); DeleteSecretRequest request = DeleteSecretRequest.newBuilder().setName(name.toString()).build(); this.secretManagerServiceClient.deleteSecret(request); } @@ -167,17 +175,14 @@ public void deleteSecret(String secretId, String projectId) { @Override public void deleteSecretVersion(String secretId, String version, String projectId) { SecretVersionName secretVersionName = - SecretVersionName.newBuilder() - .setProject(projectId) - .setSecret(secretId) - .setSecretVersion(version) - .build(); + SecretManagerPropertyUtils.getSecretVersionName( + projectId, secretId, version, getLocation()); this.secretManagerServiceClient.destroySecretVersion(secretVersionName); } ByteString getSecretByteString(String secretIdentifier) { - SecretVersionName secretVersionName = - SecretManagerPropertyUtils.getSecretVersionName(secretIdentifier, projectIdProvider); + SecretVersionName secretVersionName = SecretManagerPropertyUtils.getSecretVersionName( + secretIdentifier, this.projectIdProvider, getLocation()); if (secretVersionName == null) { secretVersionName = getDefaultSecretVersionName(secretIdentifier); @@ -189,10 +194,8 @@ ByteString getSecretByteString(String secretIdentifier) { ByteString getSecretByteString(SecretVersionName secretVersionName) { ByteString secretData; try { - secretData = secretManagerServiceClient - .accessSecretVersion(secretVersionName) - .getPayload() - .getData(); + secretData = + secretManagerServiceClient.accessSecretVersion(secretVersionName).getPayload().getData(); } catch (NotFoundException ex) { LOGGER.warn(secretVersionName.toString() + " doesn't exist in Secret Manager."); if (!this.allowDefaultSecretValue) { @@ -217,7 +220,7 @@ private void createNewSecretVersion(String secretId, ByteString payload, String createSecretInternal(secretId, projectId); } - SecretName name = SecretName.of(projectId, secretId); + SecretName name = getSecretName(projectId, secretId); AddSecretVersionRequest payloadRequest = AddSecretVersionRequest.newBuilder() .setParent(name.toString()) @@ -233,27 +236,34 @@ private void createNewSecretVersion(String secretId, ByteString payload, String * versions of the secret which stores the payload of the secret. */ private void createSecretInternal(String secretId, String projectId) { - ProjectName projectName = ProjectName.of(projectId); - - Secret secret = - Secret.newBuilder() - .setReplication( - Replication.newBuilder().setAutomatic(Replication.Automatic.getDefaultInstance())) - .build(); + String parent; + Secret.Builder secret = Secret.newBuilder(); + if (location.isPresent()) { + parent = LocationName.of(projectId, getLocation()).toString(); + } else { + parent = ProjectName.of(projectId).toString(); + secret.setReplication( + Replication.newBuilder().setAutomatic(Replication.Automatic.getDefaultInstance())); + } CreateSecretRequest request = CreateSecretRequest.newBuilder() - .setParent(projectName.toString()) + .setParent(parent) .setSecretId(secretId) - .setSecret(secret) + .setSecret(secret.build()) .build(); this.secretManagerServiceClient.createSecret(request); } private SecretVersionName getDefaultSecretVersionName(String secretId) { - return SecretVersionName.newBuilder() - .setProject(this.projectIdProvider.getProjectId()) - .setSecret(secretId) - .setSecretVersion(LATEST_VERSION) - .build(); + return SecretManagerPropertyUtils.getSecretVersionName( + this.projectIdProvider.getProjectId(), secretId, LATEST_VERSION, getLocation()); + } + + private SecretName getSecretName(String projectId, String secretId) { + if (location.isPresent()) { + return SecretName.ofProjectLocationSecretName(projectId, getLocation(), secretId); + } else { + return SecretName.of(projectId, secretId); + } } } diff --git a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtilsTests.java b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtilsTests.java index 436a20542b..bc61d741c9 100644 --- a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtilsTests.java +++ b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerPropertyUtilsTests.java @@ -102,4 +102,109 @@ void testLongProperty_projectSecretVersion() { assertThat(secretIdentifier.getSecret()).isEqualTo("the-secret"); assertThat(secretIdentifier.getSecretVersion()).isEqualTo("3"); } + + @Test + void testNonRegionalSecret() { + String property = "spring.cloud.datasource"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier).isNull(); + } + + @Test + void testInvalidSecretFormat_missingRegionalSecretId() { + String property = "sm://"; + + assertThatThrownBy( + () -> + SecretManagerPropertyUtils.getSecretVersionName( + property, DEFAULT_PROJECT_ID_PROVIDER, "us-central1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("The GCP Secret Manager secret id must not be empty"); + } + + @Test + void testShortProperty_regionalSecretId() { + String property = "sm://the-reg-secret"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier.getProject()).isEqualTo("defaultProject"); + assertThat(secretIdentifier.getLocation()).isEqualTo("us-central1"); + assertThat(secretIdentifier.getSecret()).isEqualTo("the-reg-secret"); + assertThat(secretIdentifier.getSecretVersion()).isEqualTo("latest"); + } + + @Test + void testShortProperty_projectRegionalSecretId() { + String property = "sm://the-reg-secret/the-reg-version"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier.getProject()).isEqualTo("defaultProject"); + assertThat(secretIdentifier.getLocation()).isEqualTo("us-central1"); + assertThat(secretIdentifier.getSecret()).isEqualTo("the-reg-secret"); + assertThat(secretIdentifier.getSecretVersion()).isEqualTo("the-reg-version"); + } + + @Test + void testShortProperty_projectRegionalSecretIdVersion() { + String property = "sm://my-project/the-reg-secret/3"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier.getProject()).isEqualTo("my-project"); + assertThat(secretIdentifier.getLocation()).isEqualTo("us-central1"); + assertThat(secretIdentifier.getSecret()).isEqualTo("the-reg-secret"); + assertThat(secretIdentifier.getSecretVersion()).isEqualTo("3"); + } + + @Test + void testLongProperty_projectRegionalSecret() { + String property = "sm://projects/my-project/secrets/the-reg-secret"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier.getProject()).isEqualTo("my-project"); + assertThat(secretIdentifier.getLocation()).isEqualTo("us-central1"); + assertThat(secretIdentifier.getSecret()).isEqualTo("the-reg-secret"); + assertThat(secretIdentifier.getSecretVersion()).isEqualTo("latest"); + } + + @Test + void testLongProperty_projectRegionalSecretVersion() { + String property = "sm://projects/my-project/secrets/the-reg-secret/versions/2"; + SecretVersionName secretIdentifier = + SecretManagerPropertyUtils.getSecretVersionName( + property, + DEFAULT_PROJECT_ID_PROVIDER, + "us-central1" + ); + + assertThat(secretIdentifier.getProject()).isEqualTo("my-project"); + assertThat(secretIdentifier.getLocation()).isEqualTo("us-central1"); + assertThat(secretIdentifier.getSecret()).isEqualTo("the-reg-secret"); + assertThat(secretIdentifier.getSecretVersion()).isEqualTo("2"); + } } diff --git a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerRegionalTemplateTests.java b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerRegionalTemplateTests.java new file mode 100644 index 0000000000..1307cb0802 --- /dev/null +++ b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/SecretManagerRegionalTemplateTests.java @@ -0,0 +1,313 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.spring.secretmanager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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.AddSecretVersionRequest; +import com.google.cloud.secretmanager.v1.CreateSecretRequest; +import com.google.cloud.secretmanager.v1.DeleteSecretRequest; +import com.google.cloud.secretmanager.v1.Secret; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretName; +import com.google.cloud.secretmanager.v1.SecretPayload; +import com.google.cloud.secretmanager.v1.SecretVersionName; +import com.google.protobuf.ByteString; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + + +class SecretManagerRegionalTemplateTests { + + private SecretManagerServiceClient client; + + private SecretManagerTemplate secretManagerTemplate; + + @BeforeEach + void setupMocks() { + this.client = mock(SecretManagerServiceClient.class); + when(this.client.accessSecretVersion(any(SecretVersionName.class))) + .thenReturn( + AccessSecretVersionResponse.newBuilder() + .setPayload( + SecretPayload.newBuilder().setData(ByteString.copyFromUtf8("regional payload."))) + .build()); + + this.secretManagerTemplate = + new SecretManagerTemplate(this.client, () -> "my-reg-project").setLocation(Optional.of("us-central1")); + } + + @Test + void testProjectIdRegional() { + assertThat(this.secretManagerTemplate.getProjectId()).isEqualTo("my-reg-project"); + } + + @Test + void testCreateRegionalSecretIfMissing() { + // This means that no previous regional secrets exist. + when(this.client.getSecret(any(SecretName.class))).thenThrow(NotFoundException.class); + + this.secretManagerTemplate.createSecret("my-reg-secret", "hello regional world!"); + + // Verify the regional secret is created correctly. + verifyCreateRegionalSecretRequest("my-reg-secret", "us-central1", "my-reg-project"); + + // Verifies the regional secret payload is added correctly. + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "my-reg-project"); + } + + @Test + void testCreateRegionalSecretIfMissing_withProject() { + when(this.client.getSecret(any(SecretName.class))).thenThrow(NotFoundException.class); + + this.secretManagerTemplate.createSecret( + "my-reg-secret", "hello regional world!".getBytes(), "custom-reg-project"); + + verifyCreateRegionalSecretRequest("my-reg-secret", "us-central1", "custom-reg-project"); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "custom-reg-project"); + } + + @Test + void testCreateRegionalSecretIfAlreadyPresent() { + // The secret 'my-reg-secret' already exists. + when(this.client.getSecret(SecretName.ofProjectLocationSecretName("my-reg-project", "us-central1", "my-reg-secret"))) + .thenReturn(Secret.getDefaultInstance()); + + // Verify that the secret is not created. + this.secretManagerTemplate.createSecret("my-reg-secret", "hello regional world!"); + verify(this.client).getSecret(SecretName.ofProjectLocationSecretName("my-reg-project", "us-central1", "my-reg-secret")); + verify(this.client, never()).createSecret(any()); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "my-reg-project"); + } + + @Test + void testCreateRegionalSecretIfAlreadyPresent_withProject() { + when(this.client.getSecret(SecretName.ofProjectLocationSecretName("custom-reg-project", "us-central1", "my-reg-secret"))) + .thenReturn(Secret.getDefaultInstance()); + + this.secretManagerTemplate.createSecret( + "my-reg-secret", "hello regional world!".getBytes(), "custom-reg-project"); + verify(this.client).getSecret(SecretName.ofProjectLocationSecretName("custom-reg-project", "us-central1", "my-reg-secret")); + verify(this.client, never()).createSecret(any()); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "custom-reg-project"); + } + + @Test + void testCreateRegionalByteSecretIfMissing() { + // This means that no previous secrets exist. + when(this.client.getSecret(any(SecretName.class))).thenThrow(NotFoundException.class); + + this.secretManagerTemplate.createSecret("my-reg-secret", "hello regional world!".getBytes()); + + verifyCreateRegionalSecretRequest("my-reg-secret", "us-central1", "my-reg-project"); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "my-reg-project"); + } + + @Test + void testCreateRegionalByteSecretIfMissing_withProject() { + // This means that no previous secrets exist. + when(this.client.getSecret(any(SecretName.class))).thenThrow(NotFoundException.class); + + this.secretManagerTemplate.createSecret( + "my-reg-secret", "hello regional world!".getBytes(), "custom-reg-project"); + + verifyCreateRegionalSecretRequest("my-reg-secret", "us-central1", "custom-reg-project"); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "custom-reg-project"); + } + + @Test + void testCreateRegionalByteSecretIfAlreadyPresent() { + // The secret 'my-reg-secret' already exists. + when(this.client.getSecret(SecretName.ofProjectLocationSecretName("my-reg-project", "us-central1", "my-reg-secret"))) + .thenReturn(Secret.getDefaultInstance()); + + // Verify that the secret is not created. + this.secretManagerTemplate.createSecret("my-reg-secret", "hello regional world!".getBytes()); + verify(this.client).getSecret(SecretName.ofProjectLocationSecretName("my-reg-project", "us-central1", "my-reg-secret")); + verify(this.client, never()).createSecret(any()); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "my-reg-project"); + } + + @Test + void testCreateRegionalByteSecretIfAlreadyPresent_withProject() { + // The secret 'my-reg-secret' already exists. + when(this.client.getSecret(SecretName.ofProjectLocationSecretName("custom-reg-project", "us-central1", "my-reg-secret"))) + .thenReturn(Secret.getDefaultInstance()); + + // Verify that the secret is not created. + this.secretManagerTemplate.createSecret( + "my-reg-secret", "hello regional world!".getBytes(), "custom-reg-project"); + verify(this.client).getSecret(SecretName.ofProjectLocationSecretName("custom-reg-project", "us-central1", "my-reg-secret")); + verify(this.client, never()).createSecret(any()); + verifyAddRegionalSecretRequest("my-reg-secret", "hello regional world!", "us-central1", "custom-reg-project"); + } + + @Test + void testAccessRegionalSecretBytes() { + byte[] result = this.secretManagerTemplate.getSecretBytes("my-reg-secret"); + verify(this.client) + .accessSecretVersion( + SecretVersionName.ofProjectLocationSecretSecretVersionName( + "my-reg-project", + "us-central1", + "my-reg-secret", + "latest" + ) + ); + assertThat(result).isEqualTo("regional payload.".getBytes()); + + result = this.secretManagerTemplate.getSecretBytes("sm://my-reg-secret/1"); + verify(this.client).accessSecretVersion(SecretVersionName.ofProjectLocationSecretSecretVersionName("my-reg-project", "us-central1", "my-reg-secret", "1")); + assertThat(result).isEqualTo("regional payload.".getBytes()); + } + + @Test + void testAccessRegionalSecretString() { + String result = this.secretManagerTemplate.getSecretString("my-reg-secret"); + verify(this.client) + .accessSecretVersion(SecretVersionName.ofProjectLocationSecretSecretVersionName("my-reg-project", "us-central1", "my-reg-secret", "latest")); + assertThat(result).isEqualTo("regional payload."); + + result = this.secretManagerTemplate.getSecretString("sm://my-reg-secret/1"); + verify(this.client).accessSecretVersion(SecretVersionName.ofProjectLocationSecretSecretVersionName("my-reg-project", "us-central1", "my-reg-secret", "1")); + assertThat(result).isEqualTo("regional payload."); + } + + @Test + void testAccessNonExistentRegionalSecretStringWhenDefaultIsNotAllowed() { + when(this.client.accessSecretVersion(any(SecretVersionName.class))) + .thenThrow(NotFoundException.class); + assertThatThrownBy(() -> this.secretManagerTemplate.getSecretString("sm://fake-secret")) + .isExactlyInstanceOf(NotFoundException.class); + } + + @Test + void testAccessNonExistentRegionalSecretStringWhenDefaultIsAllowed() { + when(this.client.accessSecretVersion(any(SecretVersionName.class))) + .thenThrow(NotFoundException.class); + this.secretManagerTemplate = + new SecretManagerTemplate(this.client, () -> "my-reg-project") + .setAllowDefaultSecretValue(true) + .setLocation(Optional.of("us-central1")); + String result = this.secretManagerTemplate.getSecretString("sm://fake-secret"); + assertThat(result).isNull(); + } + + @Test + void testEnableRegionalSecretVersion() { + this.secretManagerTemplate.enableSecretVersion("my-reg-secret", "1"); + verifyEnableRegionalSecretVersionRequest("my-reg-secret", "1", "us-central1", "my-reg-project"); + + this.secretManagerTemplate.enableSecretVersion("my-reg-secret", "1", "custom-reg-project"); + verifyEnableRegionalSecretVersionRequest("my-reg-secret", "1", "us-central1", "custom-reg-project"); + } + + @Test + void testDeleteRegionalSecret() { + this.secretManagerTemplate.deleteSecret("my-reg-secret"); + verifyDeleteRegionalSecretRequest("my-reg-secret", "us-central1", "my-reg-project"); + + this.secretManagerTemplate.deleteSecret("my-reg-secret", "custom-reg-project"); + verifyDeleteRegionalSecretRequest("my-reg-secret", "us-central1", "custom-reg-project"); + } + + @Test + void testDeleteRegionalSecretVersion() { + this.secretManagerTemplate.deleteSecretVersion("my-reg-secret", "10", "custom-reg-project"); + verifyDeleteRegionalSecretVersionRequest("my-reg-secret", "10", "us-central1", "custom-reg-project"); + } + + @Test + void testDisableRegionalSecretVersion() { + this.secretManagerTemplate.disableSecretVersion("my-reg-secret", "1"); + verifyDisableRegionalSecretVersionRequest("my-reg-secret", "1", "us-central1", "my-reg-project"); + + this.secretManagerTemplate.disableSecretVersion("my-reg-secret", "1", "custom-reg-project"); + verifyDisableRegionalSecretVersionRequest("my-reg-secret", "1", "us-central1", "custom-reg-project"); + } + + private void verifyCreateRegionalSecretRequest(String secretId, String locationId, String projectId) { + Secret secretToAdd = Secret.newBuilder().build(); + + CreateSecretRequest createSecretRequest = + CreateSecretRequest.newBuilder() + .setParent("projects/" + projectId + "/locations/" + locationId) + .setSecretId(secretId) + .setSecret(secretToAdd) + .build(); + + verify(this.client).createSecret(createSecretRequest); + } + + private void verifyAddRegionalSecretRequest(String secretId, String payload, String locationId, String projectId) { + AddSecretVersionRequest addSecretVersionRequest = + AddSecretVersionRequest.newBuilder() + .setParent("projects/" + projectId + "/locations/" + locationId + "/secrets/" + secretId) + .setPayload(SecretPayload.newBuilder().setData(ByteString.copyFromUtf8(payload))) + .build(); + verify(this.client).addSecretVersion(addSecretVersionRequest); + } + + private void verifyEnableRegionalSecretVersionRequest(String secretId, String version, String locationId, String projectId) { + SecretVersionName secretVersionName = + SecretVersionName.newProjectLocationSecretSecretVersionBuilder() + .setProject(projectId) + .setLocation(locationId) + .setSecret(secretId) + .setSecretVersion(version) + .build(); + verify(this.client).enableSecretVersion(secretVersionName); + } + + private void verifyDeleteRegionalSecretRequest(String secretId, String locationId, String projectId) { + SecretName name = SecretName.ofProjectLocationSecretName(projectId, locationId, secretId); + DeleteSecretRequest request = DeleteSecretRequest.newBuilder().setName(name.toString()).build(); + verify(this.client).deleteSecret(request); + } + + private void verifyDeleteRegionalSecretVersionRequest(String secretId, String version, String locationId, String projectId) { + SecretVersionName secretVersionName = + SecretVersionName.newProjectLocationSecretSecretVersionBuilder() + .setProject(projectId) + .setLocation(locationId) + .setSecret(secretId) + .setSecretVersion(version) + .build(); + verify(this.client).destroySecretVersion(secretVersionName); + } + + private void verifyDisableRegionalSecretVersionRequest( + String secretId, String version, String locationId, String projectId) { + SecretVersionName secretVersionName = + SecretVersionName.newProjectLocationSecretSecretVersionBuilder() + .setProject(projectId) + .setLocation(locationId) + .setSecret(secretId) + .setSecretVersion(version) + .build(); + verify(this.client).disableSecretVersion(secretVersionName); + } +} diff --git a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTemplateIntegrationTests.java b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTemplateIntegrationTests.java new file mode 100644 index 0000000000..c5f67959f6 --- /dev/null +++ b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTemplateIntegrationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.spring.secretmanager.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import com.google.cloud.spring.secretmanager.SecretManagerTemplate; +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** Integration tests for {@link SecretManagerTemplate} for regional secrets. */ +@EnabledIfSystemProperty(named = "it.secretmanager", matches = "true") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {SecretManagerRegionalTestConfiguration.class}) +class SecretManagerRegionalTemplateIntegrationTests { + + @Autowired SecretManagerTemplate secretManagerTemplate; + + private String secretName; + + @BeforeEach + void createSecret() { + this.secretName = String.format("test-reg-secret-%s", UUID.randomUUID()); + + secretManagerTemplate.createSecret(secretName, "4321"); + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + String secretString = secretManagerTemplate.getSecretString(secretName); + assertThat(secretString).isEqualTo("4321"); + }); + } + + @AfterEach + void deleteSecret() { + secretManagerTemplate.deleteSecret(this.secretName); + } + + @Test + void testReadWriteSecrets() { + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + String secretString = secretManagerTemplate.getSecretString(this.secretName); + assertThat(secretString).isEqualTo("4321"); + + byte[] secretBytes = secretManagerTemplate.getSecretBytes(this.secretName); + assertThat(secretBytes).isEqualTo("4321".getBytes()); + }); + } + + @Test + void testReadMissingSecret() { + + assertThatThrownBy(() -> secretManagerTemplate.getSecretBytes("test-NON-EXISTING-reg-secret")) + .isInstanceOf(com.google.api.gax.rpc.NotFoundException.class); + } + + @Test + void testUpdateSecrets() { + + secretManagerTemplate.createSecret(this.secretName, "9999"); + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + String secretString = secretManagerTemplate.getSecretString(this.secretName); + assertThat(secretString).isEqualTo("9999"); + + byte[] secretBytes = secretManagerTemplate.getSecretBytes(this.secretName); + assertThat(secretBytes).isEqualTo("9999".getBytes()); + }); + } +} diff --git a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTestConfiguration.java b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTestConfiguration.java new file mode 100644 index 0000000000..6ffd414717 --- /dev/null +++ b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerRegionalTestConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright 2017-2024 the original author or authors. + * + * 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 + * + * https://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.spring.secretmanager.it; + +import com.google.api.gax.core.CredentialsProvider; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings; +import com.google.cloud.spring.core.Credentials; +import com.google.cloud.spring.core.DefaultCredentialsProvider; +import com.google.cloud.spring.core.DefaultGcpEnvironmentProvider; +import com.google.cloud.spring.core.DefaultGcpProjectIdProvider; +import com.google.cloud.spring.core.GcpEnvironmentProvider; +import com.google.cloud.spring.core.GcpProjectIdProvider; +import com.google.cloud.spring.secretmanager.SecretManagerTemplate; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.util.Optional; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.env.ConfigurableEnvironment; + +@Configuration +public class SecretManagerRegionalTestConfiguration { + + private final GcpProjectIdProvider projectIdProvider; + + private final CredentialsProvider credentialsProvider; + + public SecretManagerRegionalTestConfiguration(ConfigurableEnvironment configurableEnvironment) + throws IOException { + + this.projectIdProvider = new DefaultGcpProjectIdProvider(); + this.credentialsProvider = new DefaultCredentialsProvider(Credentials::new); + + // Registers {@link ByteString} type converters to convert to String and byte[]. + configurableEnvironment + .getConversionService() + .addConverter( + new Converter() { + @Override + public String convert(ByteString source) { + return source.toStringUtf8(); + } + }); + + configurableEnvironment + .getConversionService() + .addConverter( + new Converter() { + @Override + public byte[] convert(ByteString source) { + return source.toByteArray(); + } + }); + } + + @Bean + public GcpProjectIdProvider gcpProjectIdProvider() { + return this.projectIdProvider; + } + + @Bean + public static GcpEnvironmentProvider gcpEnvironmentProvider() { + return new DefaultGcpEnvironmentProvider(); + } + + @Bean + public SecretManagerServiceClient secretManagerClient() throws IOException { + SecretManagerServiceSettings settings = + SecretManagerServiceSettings.newBuilder() + .setEndpoint("secretmanager.us-central1.rep.googleapis.com:443") + .setCredentialsProvider(this.credentialsProvider) + .build(); + + return SecretManagerServiceClient.create(settings); + } + + @Bean + public SecretManagerTemplate secretManagerTemplate(SecretManagerServiceClient client) { + return new SecretManagerTemplate(client, this.projectIdProvider).setLocation(Optional.of("us-central1")); + } +} diff --git a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerTemplateIntegrationTests.java b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerTemplateIntegrationTests.java index 1abd94665f..2af4f04558 100644 --- a/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerTemplateIntegrationTests.java +++ b/spring-cloud-gcp-secretmanager/src/test/java/com/google/cloud/spring/secretmanager/it/SecretManagerTemplateIntegrationTests.java @@ -90,10 +90,10 @@ void testUpdateSecrets() { .atMost(Duration.ofSeconds(10)) .untilAsserted( () -> { - String secretString = secretManagerTemplate.getSecretString("test-update-secret"); + String secretString = secretManagerTemplate.getSecretString(this.secretName); assertThat(secretString).isEqualTo("6666"); - byte[] secretBytes = secretManagerTemplate.getSecretBytes("test-update-secret"); + byte[] secretBytes = secretManagerTemplate.getSecretBytes(this.secretName); assertThat(secretBytes).isEqualTo("6666".getBytes()); }); } From 9557d43fb4fc3105d623c8fdfbbe5a4b2e152c93 Mon Sep 17 00:00:00 2001 From: abheda-crest Date: Mon, 4 Nov 2024 11:14:49 +0530 Subject: [PATCH 2/3] secret-manager: updated pom.xml of sample --- spring-cloud-gcp-samples/pom.xml | 1 + .../java/com/example/SecretManagerRegionalWebController.java | 2 +- .../src/main/resources/application.properties | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spring-cloud-gcp-samples/pom.xml b/spring-cloud-gcp-samples/pom.xml index 48f1a7a77e..bb524e0f62 100644 --- a/spring-cloud-gcp-samples/pom.xml +++ b/spring-cloud-gcp-samples/pom.xml @@ -104,6 +104,7 @@ spring-cloud-gcp-integration-storage-sample spring-cloud-gcp-trace-sample spring-cloud-gcp-vision-api-sample + spring-cloud-gcp-secretmanager-regional-sample spring-cloud-gcp-secretmanager-sample spring-cloud-gcp-vision-ocr-demo spring-cloud-gcp-data-spanner-template-sample diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java index e9194d5a5f..fe9c197091 100644 --- a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/java/com/example/SecretManagerRegionalWebController.java @@ -44,7 +44,7 @@ public class SecretManagerRegionalWebController { @Value("${${sm://application-fake}:DEFAULT}") private String defaultSecret; // Application secrets can be accessed using @Value syntax. - @Value("${sm://application-regional-secret}") + @Value("${sm://application-secret}") private String appSecretFromValue; public SecretManagerRegionalWebController(SecretManagerTemplate secretManagerTemplate, diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties index 276d9f1094..8a1d708724 100644 --- a/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-secretmanager-regional-sample/src/main/resources/application.properties @@ -12,7 +12,7 @@ management.endpoints.web.exposure.include=refresh # enable external resource from GCP Secret Manager. spring.config.import=sm:// -application.secret=${sm://application-regional-secret} +application.secret=${sm://application-secret} # enable default secret value when accessing non-exited secret. spring.cloud.gcp.secretmanager.allow-default-secret=true spring.cloud.gcp.secretmanager.location=us-central1 From 9f6bf3899938e195810b5f1cd2c585dd3be8e98d Mon Sep 17 00:00:00 2001 From: abheda-crest Date: Mon, 11 Nov 2024 14:38:31 +0530 Subject: [PATCH 3/3] secret-manager: replaced junit assertion with assertj --- .../secretmanager/SecretManagerConfigDataLoaderUnitTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java index 61aae17292..d1069665b0 100644 --- a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/SecretManagerConfigDataLoaderUnitTests.java @@ -1,7 +1,7 @@ package com.google.cloud.spring.autoconfigure.secretmanager; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertEquals; +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; @@ -49,7 +49,7 @@ void loadIncorrectResourceThrowsException(String resourceName, String location) ConfigData configData = loader.load(loaderContext, resource); SecretManagerPropertySource propertySource = (SecretManagerPropertySource) configData.getPropertySources().get(0); - assertEquals(Optional.ofNullable(location), propertySource.getLocation()); + assertThat(Optional.ofNullable(location)).isEqualTo(propertySource.getLocation()); }).doesNotThrowAnyException(); } }