diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ce4478d829bd..4d0c51fa5a55 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,6 +25,7 @@ /sdk/servicebus/ @yvgopal @nemakam @hemanttanwar @conniey /sdk/storage/ @amishra-dev @rickle-msft @jaschrep-msft @gapra-msft @alzimmermsft @sima-zhu /sdk/textanalytics/ @samvaity @mssfang @sima-zhu +/sdk/spring/ @saragluna @yiliuTo @chenrujun @jialindai # end to end tests /sdk/e2e/ @jianghaolu @g2vinay diff --git a/eng/versioning/external_dependencies.txt b/eng/versioning/external_dependencies.txt index 50b9edde2ab3..6146514f7bd8 100644 --- a/eng/versioning/external_dependencies.txt +++ b/eng/versioning/external_dependencies.txt @@ -81,7 +81,10 @@ pl.pragmatists:JUnitParams;1.1.1 ## Test dependency versions cglib:cglib-nodep;3.2.7 +com.alibaba:fastjson;1.2.61 +com.github.cverges.expect4j:expect4j;1.6 com.github.tomakehurst:wiremock-standalone;2.24.1 +com.jcraft:jsch;0.1.53 com.microsoft.azure:adal4j;1.6.5 com.microsoft.azure:azure;1.24.1 com.microsoft.azure:azure-mgmt-graph-rbac;1.3.0 @@ -98,8 +101,14 @@ io.opentelemetry:opentelemetry-api;0.2.4 io.opentelemetry:opentelemetry-sdk;0.2.4 io.projectreactor:reactor-test;3.3.5.RELEASE junit:junit;4.13-beta-3 +org.apache.maven:maven-compat;3.6.2 +org.apache.maven:maven-embedder;3.6.2 +org.apache.maven.wagon:wagon-http;3.3.4 +org.apache.maven.wagon:wagon-provider-api;3.3.4 org.assertj:assertj-core;3.11.1 org.bouncycastle:bcprov-jdk15on;1.60 +org.eclipse.aether:aether-connector-basic;1.1.0 +org.eclipse.aether:aether-transport-wagon;1.1.0 org.eclipse.jetty:jetty-http;9.4.11.v20180605 org.eclipse.jetty:jetty-server;9.4.11.v20180605 org.hamcrest:hamcrest-all;1.3 @@ -125,6 +134,8 @@ org.junit.platform:junit-platform-testkit;1.6.2 org.junit.vintage:junit-vintage-engine;5.6.2 org.openjdk.jmh:jmh-core;1.22 org.openjdk.jmh:jmh-generator-annprocess;1.22 +org.springframework.boot:spring-boot;2.2.0.RELEASE +org.springframework:spring-context;5.2.0.RELEASE org.spockframework:spock-core;1.3-groovy-2.5 org.testng:testng;6.14.3 uk.org.lidalia:slf4j-test;1.2.0 @@ -238,3 +249,4 @@ storage_com.microsoft.azure:azure-storage;8.4.0 # sdk\spring\azure-spring-boot\pom.xml spring_io.micrometer:micrometer-core;1.3.0 spring_io.micrometer:micrometer-registry-azure-monitor;1.3.0 +spring_com.microsoft.azure:azure;1.34.0 diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt index efdf57ee6fbd..a6237c228702 100644 --- a/eng/versioning/version_client.txt +++ b/eng/versioning/version_client.txt @@ -54,6 +54,9 @@ com.microsoft.azure:azure-data-gremlin-spring-boot-starter;2.2.4;2.2.5-beta.1 com.microsoft.azure:azure-keyvault-secrets-spring-boot-starter;2.2.4;2.2.5-beta.1 com.microsoft.azure:azure-servicebus-jms-spring-boot-starter;2.2.4;2.2.5-beta.1 com.microsoft.azure:azure-spring-boot-metrics-starter;2.2.4;2.2.5-beta.1 +com.microsoft.azure:azure-spring-boot-tests;2.2.4;2.2.5-beta.1 +com.microsoft.azure:azure-spring-boot-test-core;2.2.4;2.2.5-beta.1 + # Unreleased dependencies: Copy the entry from above, prepend "unreleased_" and remove the current # version. Unreleased dependencies are only valid for dependency versions. diff --git a/sdk/spring/azure-spring-boot-starter-keyvault-secrets/README.md b/sdk/spring/azure-spring-boot-starter-keyvault-secrets/README.md index 0017dd2b9f28..5d836ae8f86f 100644 --- a/sdk/spring/azure-spring-boot-starter-keyvault-secrets/README.md +++ b/sdk/spring/azure-spring-boot-starter-keyvault-secrets/README.md @@ -107,6 +107,62 @@ azure.keyvault.allow.telemetry=false When telemetry is enabled, an HTTP request will be sent to URL `https://dc.services.visualstudio.com/v2/track`. So please make sure it's not blocked by your firewall. Find more information about Azure Service Privacy Statement, please check [Microsoft Online Services Privacy Statement](https://www.microsoft.com/privacystatement/OnlineServices/Default.aspx). +## Multiple Key Vault support + +If you want to use multiple key vaults you need to define names for each of the +key vaults you want to use and in which order the key vaults should be consulted. +If a property exists in multiple key vaults the order determine which value you +will get back. + +The example below shows a setup for 2 key vaults, named `keyvault1` and +`keyvault2`. The order specifies that `keyvault1` will be consulted first. + +``` +azure.keyvault.order=keyvault1,keyvault2 +azure.keyvault.keyvault1.uri=put-a-azure-keyvault-uri-here +azure.keyvault.keyvault1.client-id=put-a-azure-client-id-here +azure.keyvault.keyvault1.client-key=put-a-azure-client-key-here +azure.keyvault.keyvault1.tenant-id=put-a-azure-tenant-id-here +azure.keyvault.keyvault2.uri=put-a-azure-keyvault-uri-here +azure.keyvault.keyvault2.client-id=put-a-azure-client-id-here +azure.keyvault.keyvault2.client-key=put-a-azure-client-key-here +azure.keyvault.keyvault2.tenant-id=put-a-azure-tenant-id-here +``` + +Note if you decide to use multiple key vault support and you already have an +existing configuration, please make sure you migrate that configuration to the +multiple key vault variant. Mixing multiple key vaults with an existing single +key vault configuration is a non supported scenario. + +## Case sensitive key mode + +The new case sensitive mode allows you to use case sensitive key vault names. Note +that the key vault secret key still needs to honor the naming limitation as +described in [About keys, secrets, and certificates](https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates). + +To enable case sensitive mode use: + +``` +azure.keyvault.case-sensitive-keys=true +``` + +## Placeholders in properties + +If your Spring property is using a name that does not honor the key vault secret +key limitation use the following technique as described by +[Externalized Configuration](https://docs.spring.io/autorepo/docs/spring-boot/2.2.7.RELEASE/reference/html/spring-boot-features.html#boot-features-external-config-placeholders-in-properties) +in the Spring Boot documentation. + +An example of using a placeholder: + +``` +my.not.compliant.property=${myCompliantKeyVaultSecret} +``` + +The application will take care of getting the value that is backed by the +`myCompliantKeyVaultSecret` key name and assign its value to the non compliant +`my.not.compliant.property`. + ## Troubleshooting ## Next steps ## Contributing diff --git a/sdk/spring/azure-spring-boot-tests/README.md b/sdk/spring/azure-spring-boot-tests/README.md new file mode 100644 index 000000000000..928b7a2efe58 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/README.md @@ -0,0 +1,18 @@ +#Azure Spring Boot Integration tests client library for Java + +## Key concepts +The structure of integration tests are organized as: + +- azure-spring-boot-tests (top folder for integration tests) + - azure-spring-boot-test-core (common code shared by integration tests) + - azure-spring-boot-test-keyvault (integration tests for key vault starter) + - azure-spring-boot-test-aad (integration tests for aad starter) + - azure-spring-boot-test-cosmosdb (integration tests for cosmos starter) + - azure-spring-boot-test-application (an application used by other integration tests) + +## Getting started +## Key concepts +## Examples +## Troubleshooting +## Next steps +## Contributing diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/pom.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/pom.xml new file mode 100644 index 000000000000..fac57c9a0259 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/pom.xml @@ -0,0 +1,61 @@ + + + + azure-spring-boot-tests + com.microsoft.azure + 2.2.5-beta.1 + ../pom.xml + + 4.0.0 + + azure-spring-boot-test-aad + + + + com.microsoft.azure + azure-spring-boot-test-core + 2.2.5-beta.1 + + + com.microsoft.azure + azure-active-directory-spring-boot-starter + 2.2.5-beta.1 + + + org.springframework.boot + spring-boot-starter-test + 2.2.0.RELEASE + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + + + com.microsoft.azure:* + org.springframework.boot:spring-boot-starter-web:[2.2.0.RELEASE] + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.0 + + ${skipSpringITs} + + + + + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/approle/AADAppRoleStatelessAuthenticationFilterIT.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/approle/AADAppRoleStatelessAuthenticationFilterIT.java new file mode 100644 index 000000000000..792f62b587d6 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/approle/AADAppRoleStatelessAuthenticationFilterIT.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.keyvault.approle; + +import com.microsoft.azure.spring.autoconfigure.aad.AADAppRoleStatelessAuthenticationFilter; +import com.microsoft.azure.test.utils.AppRunner; +import com.microsoft.azure.test.oauth.OAuthResponse; +import com.microsoft.azure.test.oauth.OAuthUtils; +import org.junit.Ignore; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; + +import static com.microsoft.azure.test.oauth.OAuthUtils.AAD_CLIENT_ID; +import static com.microsoft.azure.test.oauth.OAuthUtils.AAD_CLIENT_SECRET; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class AADAppRoleStatelessAuthenticationFilterIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(AADAppRoleStatelessAuthenticationFilterIT.class); + private final RestTemplate restTemplate = new RestTemplate(); + + @Test + @Ignore + public void testAADAppRoleStatelessAuthenticationFilter() { + final OAuthResponse authResponse = OAuthUtils.executeOAuth2ROPCFlow(System.getenv(AAD_CLIENT_ID), + System.getenv(AAD_CLIENT_SECRET)); + assertNotNull(authResponse); + + try (AppRunner app = new AppRunner(DumbApp.class)) { + + app.property("azure.activedirectory.client-id", System.getenv(AAD_CLIENT_ID)); + app.property("azure.activedirectory.session-stateless", "true"); + + app.start(); + + final ResponseEntity response = restTemplate.exchange(app.root() + "public", + HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("public endpoint response", response.getBody()); + + try { + restTemplate.exchange(app.root() + "authorized", + HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class, new HashMap<>()); + } catch (Exception e) { + assertEquals(HttpClientErrorException.Forbidden.class, e.getClass()); + } + + final HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", String.format("Bearer %s", authResponse.getIdToken())); + final HttpEntity entity = new HttpEntity<>(headers); + + final ResponseEntity response2 = restTemplate.exchange(app.root() + "authorized", + HttpMethod.GET, entity, String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response2.getStatusCode()); + assertEquals("authorized endpoint response", response2.getBody()); + + final ResponseEntity response3 = restTemplate.exchange(app.root() + "admin/demo", + HttpMethod.GET, entity, String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response3.getStatusCode()); + assertEquals("admin endpoint response", response3.getBody()); + + LOGGER.info("--------------------->test over"); + } + } + + @EnableGlobalMethodSecurity(prePostEnabled = true) + @SpringBootApplication + @RestController + public static class DumbApp extends WebSecurityConfigurerAdapter { + + @Autowired + private AADAppRoleStatelessAuthenticationFilter aadAuthFilter; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.csrf().disable(); + + http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER); + + http.authorizeRequests() + .antMatchers("/admin/**").hasRole("Admin") + .antMatchers("/", "/index.html", "/public").permitAll() + .anyRequest().authenticated(); + + http.addFilterBefore(aadAuthFilter, UsernamePasswordAuthenticationFilter.class); + } + + @GetMapping("/public") + public String publicMethod() { + return "public endpoint response"; + } + + @GetMapping("/authorized") + @PreAuthorize("hasRole('ROLE_User')") + public String onlyAuthorizedUsers() { + return "authorized endpoint response"; + } + + @GetMapping("/admin/demo") + public String onlyForAdmins() { + return "admin endpoint response"; + } + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/group/AADAuthenticationFilterIT.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/group/AADAuthenticationFilterIT.java new file mode 100644 index 000000000000..7e96965a7193 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/keyvault/group/AADAuthenticationFilterIT.java @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.keyvault.group; + +import com.microsoft.azure.spring.autoconfigure.aad.AADAuthenticationFilter; +import com.microsoft.azure.test.utils.AppRunner; +import com.microsoft.azure.test.oauth.OAuthResponse; +import com.microsoft.azure.test.oauth.OAuthUtils; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +import static com.microsoft.azure.test.oauth.OAuthUtils.AAD_CLIENT_ID; +import static com.microsoft.azure.test.oauth.OAuthUtils.AAD_CLIENT_SECRET; +import static com.microsoft.azure.test.oauth.OAuthUtils.SINGLE_TENANT_AAD_CLIENT_ID; +import static com.microsoft.azure.test.oauth.OAuthUtils.SINGLE_TENANT_AAD_CLIENT_SECRET; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.springframework.http.HttpHeaders.COOKIE; +import static org.springframework.http.HttpHeaders.SET_COOKIE; + +public class AADAuthenticationFilterIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(AADAuthenticationFilterIT.class); + private final RestTemplate restTemplate = new RestTemplate(); + + @Test + public void testAADAuthenticationFilterWithSingleTenantApp() { + final String clientId = System.getenv(SINGLE_TENANT_AAD_CLIENT_ID); + final String clientSecret = System.getenv(SINGLE_TENANT_AAD_CLIENT_SECRET); + + final OAuthResponse authResponse = OAuthUtils.executeOAuth2ROPCFlow(clientId, clientSecret); + assertNotNull(authResponse); + + testAADAuthenticationFilter(clientId, clientSecret, authResponse.getIdToken()); + } + + @Test + public void testAADAuthenticationFilterWithMultiTenantApp() { + final String clientId = System.getenv(AAD_CLIENT_ID); + final String clientSecret = System.getenv(AAD_CLIENT_SECRET); + + final OAuthResponse authResponse = OAuthUtils.executeOAuth2ROPCFlow(clientId, clientSecret); + assertNotNull(authResponse); + + testAADAuthenticationFilter(clientId, clientSecret, authResponse.getIdToken()); + } + + + private void testAADAuthenticationFilter(String clientId, String clientSecret, String idToken) { + try (AppRunner app = new AppRunner(DumbApp.class)) { + + app.property("azure.activedirectory.client-id", clientId); + app.property("azure.activedirectory.client-secret", clientSecret); + app.property("azure.activedirectory.ActiveDirectoryGroups", "group1,group2"); + + app.start(); + + final ResponseEntity response = restTemplate.exchange(app.root() + "home", + HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("home", response.getBody()); + + final HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", String.format("Bearer %s", idToken)); + HttpEntity entity = new HttpEntity<>(headers); + + final ResponseEntity response2 = restTemplate.exchange(app.root() + "api/all", + HttpMethod.GET, entity, String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response2.getStatusCode()); + assertEquals("all", response2.getBody()); + + final List cookies = response2.getHeaders().getOrDefault(SET_COOKIE, new ArrayList<>()); + final Optional sessionCookie = cookies.stream().filter(s -> s.startsWith("JSESSIONID=")).findAny(); + + if (sessionCookie.isPresent()) { + headers.add(COOKIE, sessionCookie.get()); + entity = new HttpEntity<>(headers); + } + + final ResponseEntity response3 = restTemplate.exchange(app.root() + "api/group1", + HttpMethod.GET, entity, String.class, new HashMap<>()); + assertEquals(HttpStatus.OK, response3.getStatusCode()); + assertEquals("group1", response3.getBody()); + + try { + restTemplate.exchange(app.root() + "api/group2", + HttpMethod.GET, entity, String.class, new HashMap<>()); + } catch (Exception e) { + assertEquals(HttpClientErrorException.Forbidden.class, e.getClass()); + } + + LOGGER.info("--------------------->test over"); + } + } + + @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) + @SpringBootApplication + @RestController + public static class DumbApp extends WebSecurityConfigurerAdapter { + + @Autowired + private AADAuthenticationFilter aadAuthFilter; + + @Override + protected void configure(HttpSecurity http) throws Exception { + + http.authorizeRequests().antMatchers("/home").permitAll(); + http.authorizeRequests().antMatchers("/api/**").authenticated(); + + http.logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .logoutSuccessUrl("/").deleteCookies("JSESSIONID").invalidateHttpSession(true); + + http.authorizeRequests().anyRequest().permitAll(); + + http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); + + http.addFilterBefore(aadAuthFilter, UsernamePasswordAuthenticationFilter.class); + } + + @GetMapping(value = "/api/all") + public ResponseEntity getAll() { + return new ResponseEntity<>("all", HttpStatus.OK); + } + + @PreAuthorize("hasRole('ROLE_group1')") + @GetMapping(value = "/api/group1") + public ResponseEntity getRoleGroup1() { + return new ResponseEntity<>("group1", HttpStatus.OK); + } + + @PreAuthorize("hasRole('ROLE_group2')") + @GetMapping(value = "/api/group2") + public ResponseEntity getRoleGroup2() { + return new ResponseEntity<>("group2", HttpStatus.OK); + } + + @GetMapping(value = "/home") + public ResponseEntity getHome() { + return new ResponseEntity<>("home", HttpStatus.OK); + } + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthResponse.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthResponse.java new file mode 100644 index 000000000000..b79d052b960a --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthResponse.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.oauth; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) +public class OAuthResponse { + + private String tokenType; + private String scope; + private long expiresIn; + private long extExpiresIn; + private String accessToken; + private String refreshToken; + private String idToken; + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(long expiresIn) { + this.expiresIn = expiresIn; + } + + public long getExtExpiresIn() { + return extExpiresIn; + } + + public void setExtExpiresIn(long extExpiresIn) { + this.extExpiresIn = extExpiresIn; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getIdToken() { + return idToken; + } + + public void setIdToken(String idToken) { + this.idToken = idToken; + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthUtils.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthUtils.java new file mode 100644 index 000000000000..76e0dcd13f09 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-aad/src/test/java/com/microsoft/azure/test/oauth/OAuthUtils.java @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.oauth; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +public class OAuthUtils { + + public static final String AAD_CLIENT_ID = "AAD_CLIENT_ID"; + public static final String AAD_CLIENT_SECRET = "AAD_CLIENT_SECRET"; + public static final String SINGLE_TENANT_AAD_CLIENT_ID = "SINGLE_TENANT_AAD_CLIENT_ID"; + public static final String SINGLE_TENANT_AAD_CLIENT_SECRET = "SINGLE_TENANT_AAD_CLIENT_SECRET"; + private static final String AAD_TENANT_ID = "AAD_TENANT_ID"; + private static final String AAD_USER_NAME = "AAD_USER_NAME"; + private static final String AAD_USER_PASSWORD = "AAD_USER_PASSWORD"; + + private static final RestTemplate CLIENT = new RestTemplate(); + + public static OAuthResponse executeOAuth2ROPCFlow(String aadClientId, String aadClientSecret) { + final String tenantId = System.getenv().get(AAD_TENANT_ID); + final String aadUsername = System.getenv(AAD_USER_NAME); + final String aadUserPassword = System.getenv(AAD_USER_PASSWORD); + + assertNotEmpty(aadClientId, "client id"); + assertNotEmpty(aadClientSecret, "client secret"); + assertNotEmpty(aadUsername, AAD_USER_NAME); + assertNotEmpty(aadUserPassword, AAD_USER_PASSWORD); + + String url = String.format("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(APPLICATION_FORM_URLENCODED); + + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add("scope", "user.read openid profile offline_access"); + requestBody.add("grant_type", "password"); + requestBody.add("client_id", aadClientId); + requestBody.add("client_secret", aadClientSecret); + requestBody.add("username", aadUsername); + requestBody.add("password", aadUserPassword); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + return CLIENT.postForObject(url, requestEntity, OAuthResponse.class); + } + + private static void assertNotEmpty(String text, String key) { + if (text == null || text.isEmpty()) { + throw new IllegalArgumentException(String.format("%s is not set!", key)); + } + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/pom.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/pom.xml new file mode 100644 index 000000000000..7a351bfbfea9 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/pom.xml @@ -0,0 +1,81 @@ + + + + org.springframework.boot + spring-boot-starter-parent + 2.2.0.RELEASE + + + 4.0.0 + + com.microsoft.azure + azure-spring-boot-test-application + 1.0.0 + jar + + + 1.8 + UTF-8 + UTF-8 + 1.2.61 + + + + + org.springframework.boot + spring-boot-starter-web + 2.2.0.RELEASE + + + + org.springframework.boot + spring-boot-starter-test + 2.2.0.RELEASE + test + + + + com.microsoft.azure + azure-keyvault-secrets-spring-boot-starter + 2.2.5-beta.1 + + + + com.alibaba + fastjson + ${fastjson.version} + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-assembly-plugin + 3.2.0 + + + package + + single + + + false + + src/main/resources/assembly/zip.xml + + + + + + + + + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/config/IncreaseStartupTimeHelper.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/config/IncreaseStartupTimeHelper.java new file mode 100644 index 000000000000..0f6e7f566391 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/config/IncreaseStartupTimeHelper.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.config.ConfigFileApplicationListener; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; + +public class IncreaseStartupTimeHelper implements EnvironmentPostProcessor, Ordered { + + public static final int DEFAULT_ORDER = ConfigFileApplicationListener.DEFAULT_ORDER; + private int order = DEFAULT_ORDER; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + try { + Thread.sleep(100_000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + @Override + public int getOrder() { + return order; + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/test/Application.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/test/Application.java new file mode 100644 index 000000000000..ab4b1e8574c5 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/java/com/microsoft/azure/test/Application.java @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test; + +import com.alibaba.fastjson.JSON; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +@SpringBootApplication +@RestController +public class Application implements CommandLineRunner { + + @Autowired + private ConfigurableEnvironment environment; + + + @Value("${spring.cosmos.db.key:local}") + private String cosmosDBkey; + + private static ObjectMapper mapper = new ObjectMapper(); + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + @GetMapping("hello") + public String hello() { + try { + return mapper.writeValueAsString(System.getenv()); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return "Some error happens"; + } + } + + @GetMapping("get") + public String get() { + return cosmosDBkey; + } + + @GetMapping("env/{key}") + public String env(@PathVariable String key) { + final String property = environment.getProperty(key); + return property; + } + + @GetMapping("list") + public String list() { + final List list = new ArrayList(); + final MutablePropertySources propertySources = this.environment.getPropertySources(); + final Iterator> iterator = propertySources.iterator(); + while (iterator.hasNext()) { + final PropertySource next = iterator.next(); + list.add(next.getName()); + } + return JSON.toJSONString(list); + } + + @GetMapping("getSpecificProperty/{ps}/{key}") + public String getSpecificProperty(@PathVariable String ps, @PathVariable String key) { + final MutablePropertySources propertySources = this.environment.getPropertySources(); + final PropertySource propertySource = propertySources.get(ps); + if (propertySource != null) { + final Object property = propertySource.getProperty(key); + return property == null ? null : property.toString(); + } else { + return null; + } + } + + public void run(String... varl) throws Exception { + System.out.println("property your-property-name value is: " + cosmosDBkey); + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/application.properties b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/application.properties new file mode 100644 index 000000000000..1a875edb98cb --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/application.properties @@ -0,0 +1,10 @@ +# Specify if Key Vault should be used to retrieve secrets. +azure.keyvault.enabled=true + +# Specify the URI of your Key Vault (e.g.: https://name.vault.azure.net/). +#azure.keyvault.uri=put-your-key-vault-uri-here + +# Specify the Service Principal Client ID with access to your Key Vault. +#azure.keyvault.client-id=put-your-azure-client-id-here +#azure.keyvault.client-key=put-your-azure-client-key-here +# Specify the Service Principal Client Secret. diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/assembly/zip.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/assembly/zip.xml new file mode 100644 index 000000000000..e2b513cf5c95 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-application/src/main/resources/assembly/zip.xml @@ -0,0 +1,16 @@ + + bin + / + + zip + + false + + + target/app.jar + app.jar + + + \ No newline at end of file diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/pom.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/pom.xml new file mode 100644 index 000000000000..c37124956bb7 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/pom.xml @@ -0,0 +1,126 @@ + + + + azure-spring-boot-tests + com.microsoft.azure + 2.2.5-beta.1 + ../pom.xml + + 4.0.0 + + azure-spring-boot-test-core + jar + + + + org.springframework.boot + spring-boot + 2.2.0.RELEASE + + + org.springframework + spring-context + 5.2.0.RELEASE + + + org.springframework.boot + spring-boot-starter-web + 2.2.0.RELEASE + + + org.apache.maven + maven-embedder + 3.6.2 + + + org.apache.maven + maven-compat + 3.6.2 + + + org.eclipse.aether + aether-connector-basic + 1.1.0 + + + org.eclipse.aether + aether-transport-wagon + 1.1.0 + + + org.apache.maven.wagon + wagon-http + 3.3.4 + + + org.apache.maven.wagon + wagon-provider-api + 3.3.4 + + + com.microsoft.azure + azure + 1.34.0 + provided + + + com.jcraft + jsch + 0.1.53 + + + com.github.cverges.expect4j + expect4j + 1.6 + + + + com.microsoft.azure + azure-spring-boot-starter + 2.2.5-beta.1 + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + + + com.azure:* + com.microsoft.azure:* + com.jcraft:jsch:[0.1.53] + com.github.cverges.expect4j:expect4j:[1.6] + org.apache.maven:maven-embedder:[3.6.2] + org.apache.maven:maven-compat:[3.6.2] + org.apache.maven.wagon:wagon-http:[3.3.4] + org.apache.maven.wagon:wagon-provider-api:[3.3.4] + org.eclipse.aether:aether-connector-basic:[1.1.0] + org.eclipse.aether:aether-transport-wagon:[1.1.0] + org.springframework.boot:spring-boot:[2.2.0.RELEASE] + org.springframework.boot:spring-boot-starter-web:[2.2.0.RELEASE] + org.springframework:spring-context:[5.2.0.RELEASE] + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.0 + + ${skipSpringITs} + + + + + + + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/Access.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/Access.java new file mode 100644 index 000000000000..991f15398068 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/Access.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.management; + +import com.microsoft.azure.credentials.AzureTokenCredentials; + +public interface Access { + + AzureTokenCredentials credentials(); + + String subscription(); + + String tenantId(); + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/ClientSecretAccess.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/ClientSecretAccess.java new file mode 100644 index 000000000000..a23bdfc7c8b2 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/management/ClientSecretAccess.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.management; + +import com.microsoft.azure.AzureEnvironment; +import com.microsoft.azure.credentials.ApplicationTokenCredentials; +import com.microsoft.azure.credentials.AzureTokenCredentials; + +import java.util.Map; + +public class ClientSecretAccess implements Access { + + private static final String SPRING_TENANT_ID = "SPRING_TENANT_ID"; + private static final String SPRING_SUBSCRIPTION_ID = "SPRING_SUBSCRIPTION_ID"; + private static final String SPRING_CLIENT_ID = "SPRING_CLIENT_ID"; + private static final String SPRING_CLIENT_SECRET = "SPRING_CLIENT_SECRET"; + + private String tenant; + private String subscription; + private String clientId; + private String clientSecret; + + public static ClientSecretAccess load() { + return load(System.getenv()); + } + + private static ClientSecretAccess load(Map props) { + final String tenant = props.get(SPRING_TENANT_ID); + final String subscription = props.get(SPRING_SUBSCRIPTION_ID); + final String clientId = props.get(SPRING_CLIENT_ID); + final String clientSecret = props.get(SPRING_CLIENT_SECRET); + + assertNotEmpty(tenant, SPRING_TENANT_ID); + assertNotEmpty(subscription, SPRING_SUBSCRIPTION_ID); + assertNotEmpty(clientId, SPRING_CLIENT_ID); + assertNotEmpty(clientSecret, SPRING_CLIENT_SECRET); + + return new ClientSecretAccess(tenant, subscription, clientId, clientSecret); + } + + private static void assertNotEmpty(String text, String key) { + if (text == null || text.isEmpty()) { + throw new IllegalArgumentException(String.format("%s is not set!", key)); + } + } + + public ClientSecretAccess(String tenantId, String subscriptionId, String clientId, String clientSecret) { + this.tenant = tenantId; + this.subscription = subscriptionId; + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + @Override + public AzureTokenCredentials credentials() { + return new ApplicationTokenCredentials( + clientId, + tenant, + clientSecret, + AzureEnvironment.AZURE); + } + + @Override + public String subscription() { + return subscription; + } + + @Override + public String tenantId() { + return tenant; + } + + public String clientId() { + return clientId; + } + + public String clientSecret() { + return clientSecret; + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/AppRunner.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/AppRunner.java new file mode 100644 index 000000000000..d9ca62fcb389 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/AppRunner.java @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.utils; + +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextException; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.util.SocketUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class AppRunner implements AutoCloseable { + + private Class appClass; + private Map props; + + private ConfigurableApplicationContext app; + + public AppRunner(Class appClass) { + this.appClass = appClass; + props = new LinkedHashMap<>(); + } + + public void property(String key, String value) { + props.put(key, value); + } + + public void start() { + if (app == null) { + final SpringApplicationBuilder builder = new SpringApplicationBuilder(appClass); + builder.properties("spring.jmx.enabled=false"); + builder.properties(String.format("server.port=%d", availableTcpPort())); + builder.properties(props()); + + app = builder.build().run(); + } + } + + public ConfigurableApplicationContext start(String dummy) { + if (app == null) { + final SpringApplicationBuilder builder = new SpringApplicationBuilder(appClass); + builder.properties("spring.jmx.enabled=false"); + builder.properties(String.format("server.port=%d", availableTcpPort())); + builder.properties(props()); + + app = builder.build().run(); + } + return app; + } + + private int availableTcpPort() { + return SocketUtils.findAvailableTcpPort(); + } + + private String[] props() { + final List result = new ArrayList<>(); + + for (final Map.Entry entry: props.entrySet()) { + result.add(String.format("%s=%s", entry.getKey(), entry.getValue())); + } + + return result.toArray(new String[0]); + } + + public void stop() { + if (app != null) { + app.close(); + app = null; + } + } + + public ConfigurableApplicationContext app() { + return app; + } + + public T getBean(Class type) { + return getApp().getBean(type); + } + + public ApplicationContext parent() { + return getApp().getParent(); + } + + public Map getParentBeans(Class type) { + return parent().getBeansOfType(type); + } + + public String getProperty(String key) { + return getApp().getEnvironment().getProperty(key); + } + + public int port() { + return getApp().getEnvironment().getProperty("server.port", Integer.class, -1); + } + + public String root() { + final String protocol = tlsEnabled() ? "https" : "http"; + return String.format("%s://localhost:%d/", protocol, port()); + } + + private boolean tlsEnabled() { + return getApp().getEnvironment().getProperty("server.ssl.enabled", Boolean.class, false); + } + + @Override + public void close() { + stop(); + } + + private ConfigurableApplicationContext getApp() { + if (app == null) { + throw new ApplicationContextException("App is not running."); + } + return app; + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/MavenBasedProject.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/MavenBasedProject.java new file mode 100644 index 000000000000..d6261373a2cb --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/MavenBasedProject.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.utils; + +import org.apache.maven.cli.MavenCli; +import org.apache.maven.model.io.xpp3.MavenXpp3Reader; +import org.codehaus.plexus.util.xml.pull.XmlPullParserException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +public class MavenBasedProject { + + private final String path; + + public MavenBasedProject(String path) { + this.path = path; + } + + public void packageUp() { + executeMaven("-q", "package"); + } + + public void assembly() { + executeMaven("-q", "assembly:single"); + } + + private void executeMaven(String... commands) { + String existing = setMavenSystemProp(path); + try { + MavenCli cli = new MavenCli(); + int ret = cli.doMain(commands, path, System.out, System.err); + + if (ret != 0) { + throw new RuntimeException(); + } + + } finally { + setMavenSystemProp(existing); + } + } + + private String setMavenSystemProp(String value) { + String result = System.getProperty("maven.multiModuleProjectDirectory"); + + if (value != null) { + System.setProperty("maven.multiModuleProjectDirectory", value); + } else { + System.clearProperty("maven.multiModuleProjectDirectory"); + } + + return result; + } + + public String artifact() { + try (FileInputStream input = new FileInputStream(new File(path, "pom.xml"))) { + MavenXpp3Reader reader = new MavenXpp3Reader(); + reader.read(input); + + File result = new File(new File(path, "target"), "app.jar"); + return result.getAbsolutePath(); + + } catch (IOException | XmlPullParserException ex) { + throw new RuntimeException(ex); + } + } + + public String zipFile() { + try (FileInputStream input = new FileInputStream(new File(path, "pom.xml"))) { + MavenXpp3Reader reader = new MavenXpp3Reader(); + reader.read(input); + File result = new File(new File(path, "target"), "app.zip"); + return result.getAbsolutePath(); + + } catch (IOException | XmlPullParserException ex) { + throw new RuntimeException(ex); + } + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/SSHShell.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/SSHShell.java new file mode 100644 index 000000000000..0f5c4457fa08 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-core/src/main/java/com/microsoft/azure/test/utils/SSHShell.java @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.utils; + +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import expect4j.Closure; +import expect4j.Expect4j; +import expect4j.matches.Match; +import expect4j.matches.RegExpMatch; +import org.apache.oro.text.regex.MalformedPatternException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.List; + +/** + * Utility class to run commands on Linux VM via SSH. + */ +public final class SSHShell implements Closeable { + private static final Logger LOGGER = LoggerFactory.getLogger(SSHShell.class); + private final Session session; + private final ChannelShell channel; + private final Expect4j expect; + private final StringBuilder shellBuffer = new StringBuilder(); + private final List linuxPromptMatches = new ArrayList<>(); + + /** + * Creates SSHShell. + * + * @param host the host name + * @param port the ssh port + * @param userName the ssh user name + * @param password the ssh password + * @return the shell + * @throws JSchException + * @throws IOException + */ + private SSHShell(String host, int port, String userName, String password) + throws JSchException, IOException, MalformedPatternException { + final Closure expectClosure = getExpectClosure(); + for (final String linuxPromptPattern : new String[]{"\\>", "#", "~#", "~\\$"}) { + final Match match = new RegExpMatch(linuxPromptPattern, expectClosure); + linuxPromptMatches.add(match); + } + final JSch jsch = new JSch(); + this.session = jsch.getSession(userName, host, port); + session.setPassword(password); + final Hashtable config = new Hashtable<>(); + config.put("StrictHostKeyChecking", "no"); + session.setConfig(config); + session.connect(60000); + this.channel = (ChannelShell) session.openChannel("shell"); + this.expect = new Expect4j(channel.getInputStream(), channel.getOutputStream()); + channel.connect(); + } + + /** + * Opens a SSH shell. + * + * @param host the host name + * @param port the ssh port + * @param userName the ssh user name + * @param password the ssh password + * @return the shell + * @throws JSchException exception thrown + * @throws IOException IO exception thrown + * @throws MalformedPatternException MalformedPatternException thrown + */ + public static SSHShell open(String host, int port, String userName, String password) + throws JSchException, IOException, MalformedPatternException { + return new SSHShell(host, port, userName, password); + } + + + /** + * Creates a new file on the remote host using the input content. + * + * @param from the byte array content to be uploaded + * @param fileName the name of the file for which the content will be saved into + * @param toPath the path of the file for which the content will be saved into + * @param isUserHomeBased true if the path of the file is relative to the user's home directory + * @param filePerm file permissions to be set + * @throws Exception exception thrown + */ + public void upload(InputStream from, String fileName, String toPath, boolean isUserHomeBased, String filePerm) + throws Exception { + final ChannelSftp channel = (ChannelSftp) this.session.openChannel("sftp"); + channel.connect(); + final String absolutePath = isUserHomeBased ? channel.getHome() + "/" + toPath : toPath; + + final StringBuilder path = new StringBuilder(); + for (final String dir : absolutePath.split("/")) { + path.append("/" + dir); + try { + channel.mkdir(path.toString()); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + } + channel.cd(absolutePath); + channel.put(from, fileName); + if (filePerm != null) { + channel.chmod(Integer.parseInt(filePerm), absolutePath + "/" + fileName); + } + + channel.disconnect(); + } + + /** + * Runs a given list of commands in the shell. + * + * @param commands the commands + * @return the result + * @throws Exception exception thrown + */ + public String runCommands(List commands) throws Exception { + final String output; + try { + for (final String command : commands) { + expect.expect(this.linuxPromptMatches); + expect.send(command); + expect.send("\r"); + expect.expect(this.linuxPromptMatches); + } + output = shellBuffer.toString(); + } finally { + shellBuffer.setLength(0); + } + return output; + } + + + /** + * Closes shell. + */ + public void close() { + if (expect != null) { + expect.close(); + } + if (channel != null) { + channel.disconnect(); + } + if (session != null) { + session.disconnect(); + } + } + + private Closure getExpectClosure() { + return expectState -> { + final String outputBuffer = expectState.getBuffer(); + System.out.println(outputBuffer); + shellBuffer.append(outputBuffer); + expectState.exp_continue(); + }; + } + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/pom.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/pom.xml new file mode 100644 index 000000000000..5bedcfce3df2 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/pom.xml @@ -0,0 +1,63 @@ + + + + azure-spring-boot-tests + com.microsoft.azure + 2.2.5-beta.1 + ../pom.xml + + 4.0.0 + + azure-spring-boot-test-cosmosdb + + + + com.microsoft.azure + azure-cosmosdb-spring-boot-starter + 2.2.5-beta.1 + + + com.microsoft.azure + azure-spring-boot-test-core + 2.2.5-beta.1 + + + org.springframework.boot + spring-boot-starter-test + 2.2.0.RELEASE + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + + + com.azure:* + com.microsoft.azure:* + org.springframework.boot:spring-boot-starter-web:[2.2.0.RELEASE] + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.0 + + ${skipSpringITs} + + + + + + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/CosmosDBIT.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/CosmosDBIT.java new file mode 100644 index 000000000000..92c7594719b4 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/CosmosDBIT.java @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.cosmosdb; + +import com.microsoft.azure.spring.autoconfigure.aad.AADAuthenticationFilterAutoConfiguration; +import com.microsoft.azure.test.utils.AppRunner; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +public class CosmosDBIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(CosmosDBIT.class); + private static final String AZURE_COSMOSDB_ENDPOINT = System.getenv("AZURE_COSMOSDB_ENDPOINT"); + private static final String AZURE_COSMOSDB_ACCOUNT_KEY = System.getenv("AZURE_COSMOSDB_ACCOUNT_KEY"); + private static final String AZURE_COSMOSDB_DATABASE_NAME = System.getenv("AZURE_COSMOSDB_DATABASE_NAME"); + + @Test(expected = NoSuchBeanDefinitionException.class) + @Ignore + public void testCosmosStarterIsolating() { + try (AppRunner app = new AppRunner(DummyApp.class)) { + //set properties + app.property("azure.cosmosdb.uri", AZURE_COSMOSDB_ENDPOINT); + app.property("azure.cosmosdb.key", AZURE_COSMOSDB_ACCOUNT_KEY); + app.property("azure.cosmosdb.database", AZURE_COSMOSDB_DATABASE_NAME); + app.property("azure.cosmosdb.populateQueryMetrics", String.valueOf(true)); + + //start app + app.start(); + app.getBean(AADAuthenticationFilterAutoConfiguration.class); + } + } + + @Test + public void testCosmosOperation() { + try (AppRunner app = new AppRunner(DummyApp.class)) { + //set properties + app.property("azure.cosmosdb.uri", AZURE_COSMOSDB_ENDPOINT); + app.property("azure.cosmosdb.key", AZURE_COSMOSDB_ACCOUNT_KEY); + app.property("azure.cosmosdb.database", AZURE_COSMOSDB_DATABASE_NAME); + app.property("azure.cosmosdb.populateQueryMetrics", String.valueOf(true)); + + //start app + app.start(); + final UserRepository repository = app.getBean(UserRepository.class); + final User testUser = new User("testId", + "testFirstName", + "testLastName", + "test address line one"); + + // Save the User class to Azure CosmosDB database. + final Mono saveUserMono = repository.save(testUser); + final Flux firstNameUserFlux = repository.findByFirstName("testFirstName"); + + // Nothing happens until we subscribe to these Monos. + // findById will not return the user as user is not present. + final Mono findByIdMono = repository.findById(testUser.getId()); + final User findByIdUser = findByIdMono.block(); + Assert.assertNull("User must be null", findByIdUser); + + final User savedUser = saveUserMono.block(); + Assert.assertNotNull("Saved user must not be null", savedUser); + Assert.assertEquals("Saved user first name doesn't match", + testUser.getFirstName(), savedUser.getFirstName()); + + firstNameUserFlux.collectList().block(); + final Optional optionalUserResult = repository.findById(testUser.getId()).blockOptional(); + Assert.assertTrue("Cannot find user.", optionalUserResult.isPresent()); + + final User result = optionalUserResult.get(); + Assert.assertEquals("query result firstName doesn't match!", + testUser.getFirstName(), result.getFirstName()); + Assert.assertEquals("query result lastName doesn't match!", testUser.getLastName(), result.getLastName()); + + LOGGER.info("findOne in User collection get result: {}", result.toString()); + } + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/DummyApp.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/DummyApp.java new file mode 100644 index 000000000000..a2fed22011b3 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/DummyApp.java @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.cosmosdb; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DummyApp { +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/User.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/User.java new file mode 100644 index 000000000000..1e5eb73e2cc3 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/User.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.cosmosdb; + +import com.microsoft.azure.spring.data.cosmosdb.core.mapping.Document; +import com.microsoft.azure.spring.data.cosmosdb.core.mapping.PartitionKey; +import org.springframework.data.annotation.Id; + +@Document(collection = "mycollection") +public class User { + + public User() { + + } + + public User(String id, String firstName, String lastName, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.address = address; + } + + @Id + private String id; + + private String firstName; + + @PartitionKey + private String lastName; + + private String address; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public String toString() { + return String.format("%s %s, %s", firstName, lastName, address); + } + +} + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/UserRepository.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/UserRepository.java new file mode 100644 index 000000000000..2a3f3aa224dd --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/src/test/java/com/microsoft/azure/test/cosmosdb/UserRepository.java @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.cosmosdb; + +import com.microsoft.azure.spring.data.cosmosdb.repository.ReactiveCosmosRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface UserRepository extends ReactiveCosmosRepository { + + Flux findByFirstName(String firstName); + +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/test-resources.json b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/test-resources.json new file mode 100644 index 000000000000..245981c3908e --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-cosmosdb/test-resources.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "baseName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "The base resource name." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "The location of the resource. By default, this is the same as the resource group." + } + } + }, + "variables": { + "apiVersion": "2020-03-01", + "accountName": "[toLower(parameters('baseName'))]", + "databaseName": "TestDB", + "resourceId": "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('accountName'))]" + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "apiVersion": "[variables('apiVersion')]", + "name": "[variables('accountName')]", + "location": "[parameters('location')]", + "properties": { + "databaseAccountOfferType": "Standard", + "consistencyPolicy": { + "defaultConsistencyLevel": "Session" + } + } + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", + "name": "[concat(variables('accountName'), '/', variables('databaseName'))]", + "apiVersion": "[variables('apiVersion')]", + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('accountName'))]" + ], + "properties": { + "resource": { + "id": "[variables('databaseName')]" + }, + "options": { + "throughput": "400" + } + } + } + ], + "outputs": { + "AZURE_COSMOSDB_ENDPOINT": { + "type": "string", + "value": "[reference(variables('resourceId'), variables('apiVersion')).documentEndpoint]" + }, + "AZURE_COSMOSDB_ACCOUNT_KEY": { + "type": "string", + "value": "[listKeys(variables('resourceId'), variables('apiVersion')).primaryMasterKey]" + }, + "AZURE_COSMOSDB_DATABASE_NAME": { + "type": "string", + "value": "[variables('databaseName')]" + } + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/pom.xml b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/pom.xml new file mode 100644 index 000000000000..44c532a5731d --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/pom.xml @@ -0,0 +1,76 @@ + + + + azure-spring-boot-tests + com.microsoft.azure + 2.2.5-beta.1 + ../pom.xml + + 4.0.0 + + azure-spring-boot-test-keyvault + + + + org.springframework.boot + spring-boot-starter-test + 2.2.0.RELEASE + test + + + com.microsoft.azure + azure-keyvault-secrets-spring-boot-starter + 2.2.5-beta.1 + + + com.microsoft.azure + azure-spring-boot-test-core + 2.2.5-beta.1 + + + com.microsoft.azure + azure + 1.34.0 + + + com.fasterxml.jackson.core + jackson-core + + + com.google.code.gson + gson + + + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + + + com.microsoft.azure:* + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.0 + + ${skipSpringITs} + + + + + diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/KeyVaultIT.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/KeyVaultIT.java new file mode 100755 index 000000000000..b51f01b3d29b --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/KeyVaultIT.java @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.keyvault; + +import com.microsoft.azure.management.Azure; +import com.microsoft.azure.management.appservice.WebApp; +import com.microsoft.azure.management.compute.RunCommandInput; +import com.microsoft.azure.management.compute.VirtualMachine; +import com.microsoft.azure.management.resources.fluentcore.utils.SdkContext; +import com.microsoft.azure.test.management.ClientSecretAccess; +import com.microsoft.azure.test.utils.AppRunner; +import com.microsoft.azure.test.utils.MavenBasedProject; +import com.microsoft.azure.test.utils.SSHShell; +import org.junit.Ignore; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; + +public class KeyVaultIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(KeyVaultIT.class); + private static final String AZURE_KEYVAULT_ENDPOINT = System.getenv("AZURE_KEYVAULT_ENDPOINT"); + private static final String KEY_VAULT_SECRET_VALUE = System.getenv("KEY_VAULT_SECRET_VALUE"); + private static final String KEY_VAULT_SECRET_NAME = System.getenv("KEY_VAULT_SECRET_NAME"); + private static final String SPRING_RESOURCE_GROUP = System.getenv("SPRING_RESOURCE_GROUP"); + private static final String APP_SERVICE_NAME = System.getenv("APP_SERVICE_NAME"); + private static final String VM_NAME = System.getenv("VM_NAME"); + private static final String VM_USER_USERNAME = System.getenv("VM_USER_USERNAME"); + private static final String VM_USER_PASSWORD = System.getenv("VM_USER_PASSWORD"); + private static final int DEFAULT_MAX_RETRY_TIMES = 3; + private static final Azure AZURE; + private static final ClientSecretAccess CLIENT_SECRET_ACCESS; + private static final RestTemplate REST_TEMPLATE = new RestTemplate(); + + static { + CLIENT_SECRET_ACCESS = ClientSecretAccess.load(); + AZURE = Azure.authenticate(CLIENT_SECRET_ACCESS.credentials()) + .withSubscription(CLIENT_SECRET_ACCESS.subscription()); + } + + @Test + public void keyVaultAsPropertySource() { + try (AppRunner app = new AppRunner(DumbApp.class)) { + app.property("azure.keyvault.enabled", "true"); + app.property("azure.keyvault.uri", AZURE_KEYVAULT_ENDPOINT); + app.property("azure.keyvault.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + + + final ConfigurableApplicationContext dummy = app.start("dummy"); + final ConfigurableEnvironment environment = dummy.getEnvironment(); + final MutablePropertySources propertySources = environment.getPropertySources(); + for (final PropertySource propertySource : propertySources) { + System.out.println("name = " + propertySource.getName() + "\nsource = " + propertySource + .getSource().getClass() + "\n"); + } + + assertEquals(KEY_VAULT_SECRET_VALUE, app.getProperty(KEY_VAULT_SECRET_NAME)); + LOGGER.info("--------------------->test over"); + } + } + + @Test + public void keyVaultAsPropertySourceWithSpecificKeys() { + try (AppRunner app = new AppRunner(DumbApp.class)) { + app.property("azure.keyvault.enabled", "true"); + app.property("azure.keyvault.uri", AZURE_KEYVAULT_ENDPOINT); + app.property("azure.keyvault.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + + app.property("azure.keyvault.secret.keys", KEY_VAULT_SECRET_NAME + " , azure-cosmosdb-key"); + LOGGER.info("====" + KEY_VAULT_SECRET_NAME + " , azure-cosmosdb-key"); + + app.start(); + assertEquals(KEY_VAULT_SECRET_VALUE, app.getProperty(KEY_VAULT_SECRET_NAME)); + LOGGER.info("--------------------->test over"); + } + } + + @Test + public void keyVaultWithAppServiceMSI() { + final WebApp webApp = AZURE + .webApps() + .getByResourceGroup(SPRING_RESOURCE_GROUP, APP_SERVICE_NAME); + + + final MavenBasedProject app = new MavenBasedProject("../azure-spring-boot-test-application"); + app.packageUp(); + + // Deploy zip + // Add retry logic here to avoid Kudu's socket timeout issue. + // More details: https://github.com/Microsoft/azure-maven-plugins/issues/339 + int retryCount = 0; + final File zipFile = new File(app.zipFile()); + while (retryCount < DEFAULT_MAX_RETRY_TIMES) { + retryCount += 1; + try { + webApp.zipDeploy(zipFile); + LOGGER.info(String.format("Deployed the artifact to https://%s", webApp.defaultHostName())); + break; + } catch (Exception e) { + LOGGER.error(String.format("Exception occurred when deploying the zip package: %s, " + + "retrying immediately (%d/%d)", e.getMessage(), retryCount, DEFAULT_MAX_RETRY_TIMES)); + } + } + + // Restart App Service + LOGGER.info("restarting app service..."); + webApp.restart(); + LOGGER.info("restarting app service finished..."); + + final String resourceUrl = "https://" + webApp.name() + ".azurewebsites.net" + "/get"; + // warm up + final ResponseEntity response = curlWithRetry(resourceUrl, 3, 120_000, String.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(KEY_VAULT_SECRET_VALUE, response.getBody()); + LOGGER.info("--------------------->test app service with MSI over"); + } + + @Test + @Ignore + public void keyVaultWithVirtualMachineMSI() throws Exception { + final VirtualMachine vm = AZURE.virtualMachines().getByResourceGroup(SPRING_RESOURCE_GROUP, VM_NAME); + + final String host = vm.getPrimaryPublicIPAddress().ipAddress(); + + // Upload app.jar to virtual machine + final MavenBasedProject app = new MavenBasedProject("../azure-spring-boot-test-application"); + app.packageUp(); + + final File file = new File(app.artifact()); + + if (!file.exists()) { + throw new FileNotFoundException("There's no app.jar file found."); + } + try (SSHShell sshShell = SSHShell.open(host, 22, VM_USER_USERNAME, VM_USER_PASSWORD); + FileInputStream fis = new FileInputStream(file)) { + LOGGER.info("Uploading jar file..."); + sshShell.upload(fis, "app.jar", "", true, "4095"); + } + + // run java application + final List commands = new ArrayList<>(); + commands.add(String.format("cd /home/%s", VM_USER_USERNAME)); + commands.add(String.format("nohup java -jar -Xdebug " + + "-Xrunjdwp:server=y,transport=dt_socket,address=4000,suspend=n " + + "-Dazure.keyvault.uri=%s %s &" + + " >/log.txt 2>&1", + AZURE_KEYVAULT_ENDPOINT, + "app.jar")); + + vm.runCommand(new RunCommandInput().withCommandId("RunShellScript").withScript(commands)); + + final ResponseEntity response = curlWithRetry( + String.format("http://%s:8080/get", host), + 3, + 60_000, + String.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(KEY_VAULT_SECRET_VALUE, response.getBody()); + LOGGER.info("key vault value is: {}", response.getBody()); + LOGGER.info("--------------------->test virtual machine with MSI over"); + } + + private static ResponseEntity curlWithRetry(String resourceUrl, + final int retryTimes, + int sleepMills, + Class clazz) { + HttpStatus httpStatus = HttpStatus.BAD_REQUEST; + ResponseEntity response = ResponseEntity.of(Optional.empty()); + int rt = retryTimes; + + while (rt-- > 0 && httpStatus != HttpStatus.OK) { + SdkContext.sleep(sleepMills); + + LOGGER.info("CURLing " + resourceUrl); + + try { + response = REST_TEMPLATE.getForEntity(resourceUrl, clazz); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + + httpStatus = response.getStatusCode(); + } + return response; + } + + @SpringBootApplication + public static class DumbApp { + + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/MultipleKeyVaultsIT.java b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/MultipleKeyVaultsIT.java new file mode 100644 index 000000000000..19ddbd3e4538 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/src/test/java/com/microsoft/azure/test/keyvault/MultipleKeyVaultsIT.java @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.test.keyvault; + +import com.microsoft.azure.test.management.ClientSecretAccess; +import com.microsoft.azure.test.utils.AppRunner; +import org.junit.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import static org.junit.Assert.assertEquals; + +public class MultipleKeyVaultsIT { + + private static final String KEY_VAULT1_SECRET_VALUE = System.getenv("KEY_VAULT_SECRET_VALUE"); + private static final String KEY_VAULT1_SECRET_NAME = System.getenv("KEY_VAULT_SECRET_NAME"); + private static final String KEY_VAULT2_SECRET_VALUE = System.getenv("KEY_VAULT2_SECRET_VALUE"); + private static final String KEY_VAULT2_SECRET_NAME = System.getenv("KEY_VAULT2_SECRET_NAME"); + private static final String KEY_VAULT_COMMON_SECRET_NAME = System.getenv("KEY_VAULT_COMMON_SECRET_NAME"); + private static final String KEY_VAULT1_COMMON_SECRET_VALUE = System.getenv("KEY_VAULT1_COMMON_SECRET_VALUE"); + private static final String KEY_VAULT2_COMMON_SECRET_VALUE = System.getenv("KEY_VAULT2_COMMON_SECRET_VALUE"); + private static final String AZURE_KEYVAULT1_ENDPOINT = System.getenv("AZURE_KEYVAULT_ENDPOINT"); + private static final String AZURE_KEYVAULT2_ENDPOINT = System.getenv("AZURE_KEYVAULT2_ENDPOINT"); + private static final ClientSecretAccess CLIENT_SECRET_ACCESS = ClientSecretAccess.load(); + + /** + * Test getting value from 'keyvault1'. + */ + @Test + public void testGetValueFromKeyVault1() { + try (AppRunner app = new AppRunner(TestApp.class)) { + app.property("azure.keyvault.order", "keyvault1"); + app.property("azure.keyvault.keyvault1.uri", AZURE_KEYVAULT1_ENDPOINT); + app.property("azure.keyvault.keyvault1.enabled", "true"); + app.property("azure.keyvault.keyvault1.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.keyvault1.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.keyvault1.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.start("dummy"); + assertEquals(KEY_VAULT1_SECRET_VALUE, app.getProperty(KEY_VAULT1_SECRET_NAME)); + } + } + + /** + * Test getting value from 'keyvault2'. + */ + @Test + public void testGetValueFromKeyVault2() { + try (AppRunner app = new AppRunner(TestApp.class)) { + app.property("azure.keyvault.order", "keyvault2"); + app.property("azure.keyvault.keyvault2.uri", AZURE_KEYVAULT2_ENDPOINT); + app.property("azure.keyvault.keyvault2.enabled", "true"); + app.property("azure.keyvault.keyvault2.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.keyvault2.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.keyvault2.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.start("dummy"); + assertEquals(KEY_VAULT2_SECRET_VALUE, app.getProperty(KEY_VAULT2_SECRET_NAME)); + } + } + + /** + * Test getting value for a duplicate key which should resolve to the value + * in 'keyvault1' as that is the first one of the configured key vaults. + */ + @Test + public void testGetValueForDuplicateKey() { + try (AppRunner app = new AppRunner(TestApp.class)) { + app.property("azure.keyvault.order", "keyvault1, keyvault2"); + app.property("azure.keyvault.keyvault1.uri", AZURE_KEYVAULT1_ENDPOINT); + app.property("azure.keyvault.keyvault1.enabled", "true"); + app.property("azure.keyvault.keyvault1.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.keyvault1.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.keyvault1.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.property("azure.keyvault.keyvault2.uri", AZURE_KEYVAULT2_ENDPOINT); + app.property("azure.keyvault.keyvault2.enabled", "true"); + app.property("azure.keyvault.keyvault2.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.keyvault2.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.keyvault2.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.start("dummy"); + assertEquals(KEY_VAULT1_COMMON_SECRET_VALUE, app.getProperty(KEY_VAULT_COMMON_SECRET_NAME)); + } + } + + /** + * Test getting value from a vault configured both with single and the + * multiple vault support. + */ + @Test + public void testGetValueFromSingleVault() { + try (AppRunner app = new AppRunner(TestApp.class)) { + app.property("azure.keyvault.enabled", "true"); + app.property("azure.keyvault.uri", AZURE_KEYVAULT1_ENDPOINT); + app.property("azure.keyvault.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.property("azure.keyvault.order", "keyvault2"); + app.property("azure.keyvault.keyvault2.enabled", "true"); + app.property("azure.keyvault.keyvault2.uri", AZURE_KEYVAULT2_ENDPOINT); + app.property("azure.keyvault.keyvault2.client-id", CLIENT_SECRET_ACCESS.clientId()); + app.property("azure.keyvault.keyvault2.client-key", CLIENT_SECRET_ACCESS.clientSecret()); + app.property("azure.keyvault.keyvault2.tenant-id", CLIENT_SECRET_ACCESS.tenantId()); + app.start("dummy"); + assertEquals(KEY_VAULT1_SECRET_VALUE, app.getProperty(KEY_VAULT1_SECRET_NAME)); + assertEquals(KEY_VAULT2_SECRET_VALUE, app.getProperty(KEY_VAULT2_SECRET_NAME)); + } + } + + /** + * Defines the Spring Boot test application. + */ + @SpringBootApplication + public static class TestApp { + } +} diff --git a/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/test-resources.json b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/test-resources.json new file mode 100644 index 000000000000..d21f354ea5ca --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/azure-spring-boot-test-keyvault/test-resources.json @@ -0,0 +1,445 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "baseName": { + "type": "String" + }, + "tenantId": { + "type": "String" + }, + "testApplicationOid": { + "type": "String" + }, + "endpointSuffix": { + "defaultValue": "vault.azure.net", + "type": "String" + } + }, + "variables": { + "location": "[resourceGroup().location]", + "azureKeyVaultUrl": "[format('https://{0}.{1}/', parameters('baseName'), parameters('endpointSuffix'))]", + "secretName": "spring-cosmos-db-key", + "secretValue": "rock", + "duplicateSecretName": "duplicateKey", + "duplicateSecretValue1": "duplicate1", + "keyVaultName": "[parameters('baseName')]", + "azureKeyVault2Url": "[format('https://{0}.{1}/', variables('keyVaultName2'), parameters('endpointSuffix'))]", + "secretName2": "spring-cosmos-db-key-2", + "secretValue2": "rock-2", + "duplicateSecretValue2": "duplicate2", + "keyVaultName2": "[concat(parameters('baseName'),'-2')]", + "appServicePlanName": "[concat(parameters('baseName'), '-plan')]", + "appServiceName": "[concat(parameters('baseName'), '-app')]", + "vmName": "[concat(parameters('baseName'), '-vm')]", + "adminUsername": "deploy", + "adminPassword": "12NewPAwX0rd!", + "vmSize": "Standard_D2s_v3", + "ubuntuOSVersion": "18.04-LTS", + "networkSecurityGroupName": "[concat(parameters('baseName'), '-nsg')]", + "virtualNetworkName": "[concat(parameters('baseName'), '-vn')]", + "networkInterfaceName": "[concat(parameters('baseName'), '-ni')]", + "subnetName": "default", + "publicIpAddressName": "[concat(parameters('baseName'), '-ip')]", + "nsgId": "[resourceId('Microsoft.Network/networkSecurityGroups', variables('networkSecurityGroupName'))]", + "vnetId": "[resourceId('Microsoft.Network/virtualNetworks', variables('virtualNetworkName'))]", + "ipId": "[resourceId('Microsoft.Network/publicIpAddresses/', variables('publicIpAddressName'))]", + "niId": "[resourceId('Microsoft.Network/networkInterfaces', variables('networkInterfaceName'))]", + "subnetRef": "[concat(variables('vnetId'), '/subnets/', variables('subnetName'))]" + }, + "resources": [ + { + "apiVersion": "2018-11-01", + "name": "[variables('appServicePlanName')]", + "type": "Microsoft.Web/serverfarms", + "location": "[variables('location')]", + "kind": "linux", + "tags": null, + "properties": { + "name": "[variables('appServicePlanName')]", + "workerSize": "0", + "workerSizeId": "0", + "numberOfWorkers": "1", + "reserved": true + }, + "sku": { + "Tier": "Basic", + "Name": "B1" + } + }, + { + "apiVersion": "2018-11-01", + "name": "[variables('appServiceName')]", + "type": "Microsoft.Web/sites", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]" + ], + "properties": { + "name": "[variables('appServiceName')]", + "siteConfig": { + "appSettings": [ + { + "name": "AZURE_KEYVAULT_URI", + "value": "[variables('azureKeyVaultUrl')]" + } + ], + "linuxFxVersion": "JAVA|8-jre8", + "alwaysOn": false + }, + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]", + "clientAffinityEnabled": false + }, + "identity": { + "type": "SystemAssigned" + } + }, + { + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2019-02-01", + "name": "[variables('networkSecurityGroupName')]", + "location": "[variables('location')]", + "properties": { + "securityRules": [ + { + "name": "HTTP", + "properties": { + "priority": 234, + "protocol": "*", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefix": "*", + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "8080" + } + }, + { + "name": "SSH", + "properties": { + "priority": 235, + "protocol": "TCP", + "access": "Allow", + "direction": "Inbound", + "sourceAddressPrefix": "*", + "sourcePortRange": "*", + "destinationAddressPrefix": "*", + "destinationPortRange": "22" + } + } + ] + } + }, + { + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2018-10-01", + "name": "[variables('networkInterfaceName')]", + "location": "[variables('location')]", + "dependsOn": [ + "[variables('nsgId')]", + "[variables('vnetId')]", + "[variables('ipId')]" + ], + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "subnet": { + "id": "[variables('subnetRef')]" + }, + "privateIPAllocationMethod": "Dynamic", + "publicIpAddress": { + "id": "[variables('ipId')]" + } + } + } + ], + "networkSecurityGroup": { + "id": "[variables('nsgId')]" + } + } + }, + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2019-04-01", + "name": "[variables('virtualNetworkName')]", + "location": "[variables('location')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "10.0.0.0/24" + ] + }, + "subnets": [ + { + "name": "[variables('subnetName')]", + "properties": { + "addressPrefix": "10.0.0.0/24" + } + } + ] + } + }, + { + "type": "Microsoft.Network/publicIpAddresses", + "apiVersion": "2019-02-01", + "name": "[variables('publicIpAddressName')]", + "location": "[variables('location')]", + "properties": { + "publicIpAllocationMethod": "Dynamic" + }, + "sku": { + "name": "Basic" + } + }, + { + "name": "[concat(variables('vmName'), '/CustomScriptForLinux')]", + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2019-03-01", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmName'))]" + ], + "properties": { + "publisher": "Microsoft.OSTCExtensions", + "type": "CustomScriptForLinux", + "typeHandlerVersion": "1.4", + "autoUpgradeMinorVersion": true, + "settings": { + "fileUris": [ + "https://raw.githubusercontent.com/Azure/azure-libraries-for-java/master/azure-samples/src/main/resources/install_jva_mvn_git.sh" + ], + "commandToExecute": "bash install_jva_mvn_git.sh" + }, + "protectedSettings": { + "commandToExecute": "" + } + } + }, + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2019-03-01", + "name": "[variables('vmName')]", + "location": "[variables('location')]", + "dependsOn": [ + "[variables('niId')]" + ], + "properties": { + "hardwareProfile": { + "vmSize": "[variables('vmSize')]" + }, + "storageProfile": { + "osDisk": { + "createOption": "fromImage", + "managedDisk": { + "storageAccountType": "Premium_LRS" + } + }, + "imageReference": { + "publisher": "Canonical", + "offer": "UbuntuServer", + "sku": "[variables('ubuntuOSVersion')]", + "version": "latest" + } + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "[variables('niId')]" + } + ] + }, + "osProfile": { + "computerName": "[variables('vmName')]", + "adminUsername": "[variables('adminUsername')]", + "adminPassword": "[variables('adminPassword')]" + } + }, + "identity": { + "type": "SystemAssigned" + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2016-10-01", + "name": "[variables('keyVaultName')]", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('appServiceName'))]", + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmName'))]" + ], + "properties": { + "sku": { + "family": "A", + "name": "standard" + }, + "tenantId": "[parameters('tenantId')]", + "accessPolicies": [ + { + "tenantId": "[parameters('tenantId')]", + "objectId": "[parameters('testApplicationOid')]", + "permissions": { + "secrets": [ + "get", + "list" + ] + } + }, + { + "tenantId": "[parameters('tenantId')]", + "objectId": "[reference(resourceId('Microsoft.Web/sites', variables('appServiceName')), '2019-08-01', 'Full').identity.principalId]", + "permissions": { + "secrets": [ + "get", + "list" + ] + } + }, + { + "tenantId": "[parameters('tenantId')]", + "objectId": "[reference(resourceId('Microsoft.Compute/virtualMachines', variables('vmName')), '2019-03-01', 'Full').identity.principalId]", + "permissions": { + "secrets": [ + "get", + "list" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": false + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/', variables('secretName'))]", + "apiVersion": "2018-02-14", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ], + "properties": { + "value": "[variables('secretValue')]" + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/', variables('duplicateSecretName'))]", + "apiVersion": "2018-02-14", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ], + "properties": { + "value": "[variables('duplicateSecretValue1')]" + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2016-10-01", + "name": "[variables('keyVaultName2')]", + "location": "[variables('location')]", + "properties": { + "sku": { + "family": "A", + "name": "standard" + }, + "tenantId": "[parameters('tenantId')]", + "accessPolicies": [ + { + "tenantId": "[parameters('tenantId')]", + "objectId": "[parameters('testApplicationOid')]", + "permissions": { + "secrets": [ "get", "list" ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": false + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName2'), '/', variables('secretName2'))]", + "apiVersion": "2018-02-14", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName2'))]" + ], + "properties": { + "value": "[variables('secretValue2')]" + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName2'), '/', variables('duplicateSecretName'))]", + "apiVersion": "2018-02-14", + "location": "[variables('location')]", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName2'))]" + ], + "properties": { + "value": "[variables('duplicateSecretValue2')]" + } + } + ], + "outputs": { + "AZURE_KEYVAULT_ENDPOINT": { + "type": "string", + "value": "[variables('azureKeyVaultUrl')]" + }, + "KEY_VAULT_SECRET_NAME": { + "type": "string", + "value": "[variables('secretName')]" + }, + "KEY_VAULT_SECRET_VALUE": { + "type": "string", + "value": "[variables('secretValue')]" + }, + "AZURE_KEYVAULT2_ENDPOINT": { + "type": "string", + "value": "[variables('azureKeyVault2Url')]" + }, + "KEY_VAULT2_SECRET_NAME": { + "type": "string", + "value": "[variables('secretName2')]" + }, + "KEY_VAULT2_SECRET_VALUE": { + "type": "string", + "value": "[variables('secretValue2')]" + }, + "KEY_VAULT_COMMON_SECRET_NAME": { + "type": "string", + "value": "[variables('duplicateSecretName')]" + }, + "KEY_VAULT1_COMMON_SECRET_VALUE": { + "type": "string", + "value": "[variables('duplicateSecretValue1')]" + }, + "KEY_VAULT2_COMMON_SECRET_VALUE": { + "type": "string", + "value": "[variables('duplicateSecretValue2')]" + }, + "APP_SERVICE_NAME": { + "type": "string", + "value": "[variables('appServiceName')]" + }, + "VM_NAME": { + "type": "string", + "value": "[variables('vmName')]" + }, + "VM_USER_USERNAME": { + "type": "string", + "value": "[variables('adminUsername')]" + }, + "VM_USER_PASSWORD": { + "type": "string", + "value": "[variables('adminPassword')]" + } + } +} diff --git a/sdk/spring/azure-spring-boot-tests/pom.xml b/sdk/spring/azure-spring-boot-tests/pom.xml new file mode 100644 index 000000000000..2e12e3085ee1 --- /dev/null +++ b/sdk/spring/azure-spring-boot-tests/pom.xml @@ -0,0 +1,35 @@ + + + + 4.0.0 + + + com.azure + azure-client-sdk-parent + 1.7.0 + ../../parents/azure-client-sdk-parent + + + com.microsoft.azure + azure-spring-boot-tests + 2.2.5-beta.1 + pom + + Azure Spring Boot Tests + Tests for Azure Spring Boot + + + azure-spring-boot-test-core + azure-spring-boot-test-application + azure-spring-boot-test-aad + azure-spring-boot-test-cosmosdb + azure-spring-boot-test-keyvault + + + + true + + + diff --git a/sdk/spring/azure-spring-boot/pom.xml b/sdk/spring/azure-spring-boot/pom.xml index 0f40098d9a12..97092aed3ebb 100644 --- a/sdk/spring/azure-spring-boot/pom.xml +++ b/sdk/spring/azure-spring-boot/pom.xml @@ -41,6 +41,7 @@ org.springframework spring-web 5.2.5.RELEASE + true diff --git a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessor.java b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessor.java index 4924b5141c8e..ad0f69b68193 100644 --- a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessor.java +++ b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessor.java @@ -3,7 +3,10 @@ package com.microsoft.azure.keyvault.spring; -import com.microsoft.azure.utils.Constants; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_ENABLED; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_ORDER; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_PREFIX; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_VAULT_URI; import org.springframework.boot.SpringApplication; import org.springframework.boot.context.config.ConfigFileApplicationListener; import org.springframework.boot.env.EnvironmentPostProcessor; @@ -18,22 +21,83 @@ public class KeyVaultEnvironmentPostProcessor implements EnvironmentPostProcesso public static final int DEFAULT_ORDER = ConfigFileApplicationListener.DEFAULT_ORDER + 1; private int order = DEFAULT_ORDER; + /** + * Post process the environment. + * + *

+ * Here we are going to process any key vault(s) and make them as available + * PropertySource(s). Note this supports both the singular key vault setup, + * as well as the multiple key vault setup. + *

+ * + * @param environment the environment. + * @param application the application. + */ @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { - if (isKeyVaultEnabled(environment)) { - final KeyVaultEnvironmentPostProcessorHelper helper = - new KeyVaultEnvironmentPostProcessorHelper(environment); - helper.addKeyVaultPropertySource(); + final KeyVaultEnvironmentPostProcessorHelper helper + = new KeyVaultEnvironmentPostProcessorHelper(environment); + if (isKeyVaultEnabled(environment, "")) { + helper.addKeyVaultPropertySource(""); + } + if (hasMultipleKeyVaultsEnabled(environment)) { + final String property = environment.getProperty(AZURE_KEYVAULT_PREFIX + AZURE_KEYVAULT_ORDER, ""); + final String[] keyVaultNames = property.split(","); + for (int i = keyVaultNames.length - 1; i >= 0; i--) { + if (isKeyVaultEnabled(environment, keyVaultNames[i].trim() + ".")) { + helper.addKeyVaultPropertySource(keyVaultNames[i].trim() + "."); + } + } } } - private boolean isKeyVaultEnabled(ConfigurableEnvironment environment) { - if (environment.getProperty(Constants.AZURE_KEYVAULT_VAULT_URI) == null) { - // User doesn't want to enable Key Vault property initializer. + /** + * Is the key vault enabled. + * + *

+ * If the (normalizedName+) AZURE_KEYVAULT_URI is not present then the user + * does not want to enable the key vault at all. + *

+ *

+ * If the (normalizedName+) AZURE_KEYVAULT_ENABLED is set to false the user + * wants to disable the key vault, if it is set to true the key vault will + * be enabled. + *

+ *

+ * If the KeyVaultClient implementation is not available then key vault + * support will not be enabled. + *

+ * + * @param environment the environment. + * @param normalizedName the normalized name used to differentiate between + * multiple key vaults. + * @return true if the key vault is enabled, false otherwise. + */ + private boolean isKeyVaultEnabled(ConfigurableEnvironment environment, String normalizedName) { + if (environment.getProperty(AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_VAULT_URI) == null) { return false; } - return environment.getProperty(Constants.AZURE_KEYVAULT_ENABLED, Boolean.class, true) - && isKeyVaultClientAvailable(); + return environment.getProperty(AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_ENABLED, + Boolean.class, true) && isKeyVaultClientAvailable(); + } + + /** + * Determine whether or not multiple key vaults are enabled. + * + *

+ * Look for the AZURE_KEYVAULT_ORDER property to determine if multiple key + * vault support should be enabled. + *

+ * + * @param environment the environment. + * @return true if enabled, false otherwise. + */ + private boolean hasMultipleKeyVaultsEnabled(ConfigurableEnvironment environment) { + boolean result = false; + if (environment.getProperty(AZURE_KEYVAULT_PREFIX + AZURE_KEYVAULT_ORDER) != null) { + result = true; + } + return result; } private boolean isKeyVaultClientAvailable() { diff --git a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorHelper.java b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorHelper.java index ee16e0607ce6..69155a1ed8a5 100644 --- a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorHelper.java +++ b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorHelper.java @@ -10,6 +10,18 @@ import com.azure.identity.ManagedIdentityCredentialBuilder; import com.azure.security.keyvault.secrets.SecretClient; import com.azure.security.keyvault.secrets.SecretClientBuilder; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_ALLOW_TELEMETRY; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CERTIFICATE_PASSWORD; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CERTIFICATE_PATH; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CLIENT_ID; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CLIENT_KEY; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_PREFIX; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_REFRESH_INTERVAL; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_SECRET_KEYS; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_TENANT_ID; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_VAULT_URI; +import static com.microsoft.azure.utils.Constants.DEFAULT_REFRESH_INTERVAL_MS; +import static com.microsoft.azure.utils.Constants.SPRINGBOOT_KEY_VAULT_APPLICATION_ID; import com.microsoft.azure.telemetry.TelemetrySender; import com.microsoft.azure.utils.Constants; import org.slf4j.Logger; @@ -31,7 +43,6 @@ import static com.microsoft.azure.telemetry.TelemetryData.SERVICE_NAME; import static com.microsoft.azure.telemetry.TelemetryData.getClassPackageSimpleName; -import static com.microsoft.azure.utils.Constants.SPRINGBOOT_KEY_VAULT_APPLICATION_ID; /** * A helper class to initialize the key vault secret client depending on which authentication method users choose. @@ -49,16 +60,31 @@ class KeyVaultEnvironmentPostProcessorHelper { sendTelemetry(); } - public void addKeyVaultPropertySource() { - final String vaultUri = getProperty(this.environment, Constants.AZURE_KEYVAULT_VAULT_URI); + /** + * Add a key vault property source. + * + *

+ * The normalizedName is used to target a specific key vault (note if the + * name is the empty string it works as before with only one key vault + * present). The normalized name is the name of the specific key vault plus + * a trailing "." at the end. + *

+ * + * @param normalizedName the normalized name. + */ + public void addKeyVaultPropertySource(String normalizedName) { + final String vaultUri = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_VAULT_URI); final Long refreshInterval = Optional.ofNullable( - this.environment.getProperty(Constants.AZURE_KEYVAULT_REFRESH_INTERVAL)) - .map(Long::valueOf).orElse(Constants.DEFAULT_REFRESH_INTERVAL_MS); + this.environment.getProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_REFRESH_INTERVAL)) + .map(Long::valueOf).orElse(DEFAULT_REFRESH_INTERVAL_MS); final Binder binder = Binder.get(this.environment); - final List secretKeys = binder.bind(Constants.AZURE_KEYVAULT_SECRET_KEYS, Bindable.listOf(String.class)) + final List secretKeys = binder.bind( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_SECRET_KEYS, Bindable.listOf(String.class)) .orElse(Collections.emptyList()); - final TokenCredential tokenCredential = getCredentials(); + final TokenCredential tokenCredential = getCredentials(normalizedName); final SecretClient secretClient = new SecretClientBuilder() .vaultUrl(vaultUri) .credential(tokenCredential) @@ -66,16 +92,28 @@ public void addKeyVaultPropertySource() { .buildClient(); try { final MutablePropertySources sources = this.environment.getPropertySources(); + final boolean caseSensitive = Boolean.parseBoolean( + this.environment.getProperty(Constants.AZURE_KEYVAULT_CASE_SENSITIVE_KEYS, "false")); final KeyVaultOperation kvOperation = new KeyVaultOperation(secretClient, vaultUri, refreshInterval, - secretKeys); + secretKeys, + caseSensitive); - if (sources.contains(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME)) { - sources.addAfter(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, + if (normalizedName.equals("")) { + if (sources.contains(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME)) { + sources.addAfter(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, new KeyVaultPropertySource(kvOperation)); + } else { + sources.addFirst(new KeyVaultPropertySource(kvOperation)); + } } else { - sources.addFirst(new KeyVaultPropertySource(kvOperation)); + if (sources.contains(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME)) { + sources.addAfter(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, + new KeyVaultPropertySource(normalizedName, kvOperation)); + } else { + sources.addFirst(new KeyVaultPropertySource(normalizedName, kvOperation)); + } } } catch (final Exception ex) { @@ -83,15 +121,36 @@ public void addKeyVaultPropertySource() { } } + /** + * Get the token credentials. + * + * @return the token credentials. + */ public TokenCredential getCredentials() { + return getCredentials(""); + } + + /** + * Get the token credentials. + * + * @param normalizedName the normalized name of the key vault. + * @return the token credentials. + */ + public TokenCredential getCredentials(String normalizedName) { //use service principle to authenticate - if (this.environment.containsProperty(Constants.AZURE_KEYVAULT_CLIENT_ID) - && this.environment.containsProperty(Constants.AZURE_KEYVAULT_CLIENT_KEY) - && this.environment.containsProperty(Constants.AZURE_KEYVAULT_TENANT_ID)) { + if (this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID) + && this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_KEY) + && this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_TENANT_ID)) { LOGGER.debug("Will use custom credentials"); - final String clientId = getProperty(this.environment, Constants.AZURE_KEYVAULT_CLIENT_ID); - final String clientKey = getProperty(this.environment, Constants.AZURE_KEYVAULT_CLIENT_KEY); - final String tenantId = getProperty(this.environment, Constants.AZURE_KEYVAULT_TENANT_ID); + final String clientId = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID); + final String clientKey = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_KEY); + final String tenantId = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_TENANT_ID); return new ClientSecretCredentialBuilder() .clientId(clientId) .clientSecret(clientKey) @@ -99,31 +158,42 @@ public TokenCredential getCredentials() { .build(); } //use certificate to authenticate - if (this.environment.containsProperty(Constants.AZURE_KEYVAULT_CLIENT_ID) - && this.environment.containsProperty(Constants.AZURE_KEYVAULT_CERTIFICATE_PATH) - && this.environment.containsProperty(Constants.AZURE_KEYVAULT_TENANT_ID)) { + if (this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID) + && this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CERTIFICATE_PATH) + && this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_TENANT_ID)) { // Password can be empty - final String certPwd = this.environment.getProperty(Constants.AZURE_KEYVAULT_CERTIFICATE_PASSWORD); - final String certPath = getProperty(this.environment, Constants.AZURE_KEYVAULT_CERTIFICATE_PATH); + final String certPwd = this.environment.getProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CERTIFICATE_PASSWORD); + final String certPath = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CERTIFICATE_PATH); if (StringUtils.isEmpty(certPwd)) { return new ClientCertificateCredentialBuilder() - .tenantId(getProperty(this.environment, Constants.AZURE_KEYVAULT_TENANT_ID)) - .clientId(getProperty(this.environment, Constants.AZURE_KEYVAULT_CLIENT_ID)) + .tenantId(getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_TENANT_ID)) + .clientId(getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID)) .pemCertificate(certPath) .build(); } else { return new ClientCertificateCredentialBuilder() - .tenantId(getProperty(this.environment, Constants.AZURE_KEYVAULT_TENANT_ID)) - .clientId(getProperty(this.environment, Constants.AZURE_KEYVAULT_CLIENT_ID)) + .tenantId(getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_TENANT_ID)) + .clientId(getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID)) .pfxCertificate(certPath, certPwd) .build(); } } //use MSI to authenticate - if (this.environment.containsProperty(Constants.AZURE_KEYVAULT_CLIENT_ID)) { + if (this.environment.containsProperty( + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID)) { LOGGER.debug("Will use MSI credentials with specified clientId"); - final String clientId = getProperty(this.environment, Constants.AZURE_KEYVAULT_CLIENT_ID); + final String clientId = getProperty(this.environment, + AZURE_KEYVAULT_PREFIX + normalizedName + AZURE_KEYVAULT_CLIENT_ID); return new ManagedIdentityCredentialBuilder().clientId(clientId).build(); } LOGGER.debug("Will use MSI credentials"); @@ -142,7 +212,7 @@ private String getProperty(final ConfigurableEnvironment env, final String prope private boolean allowTelemetry(final ConfigurableEnvironment env) { Assert.notNull(env, "env must not be null!"); - return env.getProperty(Constants.AZURE_KEYVAULT_ALLOW_TELEMETRY, Boolean.class, true); + return env.getProperty(AZURE_KEYVAULT_PREFIX + AZURE_KEYVAULT_ALLOW_TELEMETRY, Boolean.class, true); } private void sendTelemetry() { diff --git a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultOperation.java b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultOperation.java index f97e1595ee33..5bdd133a7fbe 100644 --- a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultOperation.java +++ b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultOperation.java @@ -3,72 +3,78 @@ package com.microsoft.azure.keyvault.spring; -import com.azure.core.http.rest.PagedIterable; import com.azure.security.keyvault.secrets.SecretClient; import com.azure.security.keyvault.secrets.models.KeyVaultSecret; -import com.azure.security.keyvault.secrets.models.SecretProperties; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Locale; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Encapsulate key vault secret client in this class to provide a delegate of key vault operations. */ public class KeyVaultOperation { - private final long cacheRefreshIntervalInMs; - private final List secretKeys; - private final Object refreshLock = new Object(); - private final SecretClient secretClient; - private final String vaultUri; - - private ArrayList propertyNames = new ArrayList<>(); - private String[] propertyNamesArr; - - private final AtomicLong lastUpdateTime = new AtomicLong(); - private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); + /** + * Stores the case sensitive flag. + */ + private final boolean caseSensitive; - public KeyVaultOperation(final SecretClient secretClient, - String vaultUri, - final long refreshInterval, - final List secretKeys) { - this.cacheRefreshIntervalInMs = refreshInterval; - this.secretKeys = secretKeys; - this.secretClient = secretClient; + private final SecretClient keyVaultClient; + private final String vaultUri; + private volatile List secretNames; + private final boolean secretNamesAlreadyConfigured; + private final long secretNamesRefreshIntervalInMs; + private volatile long secretNamesLastUpdateTime; + + public KeyVaultOperation( + final SecretClient keyVaultClient, + String vaultUri, + final long secretKeysRefreshIntervalInMs, + final List secretNames, + boolean caseSensitive + ) { + this.caseSensitive = caseSensitive; + this.keyVaultClient = keyVaultClient; // TODO(pan): need to validate why last '/' need to be truncated. this.vaultUri = StringUtils.trimTrailingCharacter(vaultUri.trim(), '/'); - fillSecretsList(); - } - - public String[] list() { - try { - this.rwLock.readLock().lock(); - return propertyNamesArr; - } finally { - this.rwLock.readLock().unlock(); - } + this.secretNames = Optional.ofNullable(secretNames) + .map(Collection::stream) + .orElseGet(Stream::empty) + .map(this::toKeyVaultSecretName) + .distinct() + .collect(Collectors.toList()); + this.secretNamesAlreadyConfigured = !this.secretNames.isEmpty(); + this.secretNamesRefreshIntervalInMs = secretKeysRefreshIntervalInMs; + this.secretNamesLastUpdateTime = 0; } - private String getKeyVaultSecretName(@NonNull String property) { - if (property.matches("[a-z0-9A-Z-]+")) { - return property.toLowerCase(Locale.US); - } else if (property.matches("[A-Z0-9_]+")) { - return property.toLowerCase(Locale.US).replaceAll("_", "-"); + public String[] getPropertyNames() { + refreshSecretKeysIfNeeded(); + if (!caseSensitive) { + return Optional.ofNullable(secretNames) + .map(Collection::stream) + .orElseGet(Stream::empty) + .flatMap(p -> Stream.of(p, p.replaceAll("-", "."))) + .distinct() + .toArray(String[]::new); } else { - return property.toLowerCase(Locale.US) - .replaceAll("-", "") // my-project -> myproject - .replaceAll("_", "") // my_project -> myproject - .replaceAll("\\.", "-"); // acme.myproject -> acme-myproject + return Optional.ofNullable(secretNames) + .map(Collection::stream) + .orElseGet(Stream::empty) + .distinct() + .toArray(String[]::new); } } + /** * For convention we need to support all relaxed binding format from spring, these may include: *
    @@ -85,67 +91,68 @@ private String getKeyVaultSecretName(@NonNull String property) { * @param property of secret instance. * @return the value of secret with given name or null. */ - public String get(final String property) { - Assert.hasText(property, "property should contain text."); - final String secretName = getKeyVaultSecretName(property); - - //if user don't set specific secret keys, then refresh token - if (this.secretKeys == null || secretKeys.size() == 0) { - // refresh periodically - refreshPropertyNames(); - } - if (this.propertyNames.contains(secretName)) { - final KeyVaultSecret secret = this.secretClient.getSecret(secretName); - return secret == null ? null : secret.getValue(); + private String toKeyVaultSecretName(@NonNull String property) { + if (!caseSensitive) { + if (property.matches("[a-z0-9A-Z-]+")) { + return property.toLowerCase(Locale.US); + } else if (property.matches("[A-Z0-9_]+")) { + return property.toLowerCase(Locale.US).replaceAll("_", "-"); + } else { + return property.toLowerCase(Locale.US) + .replaceAll("-", "") // my-project -> myproject + .replaceAll("_", "") // my_project -> myproject + .replaceAll("\\.", "-"); // acme.myproject -> acme-myproject + } } else { - return null; + return property; } } - private void refreshPropertyNames() { - if (System.currentTimeMillis() - this.lastUpdateTime.get() > this.cacheRefreshIntervalInMs) { - synchronized (this.refreshLock) { - if (System.currentTimeMillis() - this.lastUpdateTime.get() > this.cacheRefreshIntervalInMs) { - this.lastUpdateTime.set(System.currentTimeMillis()); - fillSecretsList(); - } - } - } + public String get(final String property) { + Assert.hasText(property, "property should contain text."); + refreshSecretKeysIfNeeded(); + return Optional.of(property) + .map(this::toKeyVaultSecretName) + .filter(secretNames::contains) + .map(this::getValueFromKeyVault) + .orElse(null); } - private void fillSecretsList() { - try { - this.rwLock.writeLock().lock(); - if (this.secretKeys == null || this.secretKeys.size() == 0) { - this.propertyNames.clear(); + private synchronized void refreshSecretKeysIfNeeded() { + if (needRefreshSecretKeys()) { + refreshKeyVaultSecretNames(); + } + } - final PagedIterable secretProperties = this.secretClient.listPropertiesOfSecrets(); - secretProperties.forEach(s -> { - final String secretName = s.getName().replace(this.vaultUri + "/secrets/", ""); - addSecretIfNotExist(secretName); - }); + private boolean needRefreshSecretKeys() { + return !secretNamesAlreadyConfigured + && System.currentTimeMillis() - this.secretNamesLastUpdateTime > this.secretNamesRefreshIntervalInMs; + } - this.lastUpdateTime.set(System.currentTimeMillis()); - } else { - for (final String secretKey : this.secretKeys) { - addSecretIfNotExist(secretKey); - } - } - this.propertyNamesArr = this.propertyNames.toArray(new String[0]); - } finally { - this.rwLock.writeLock().unlock(); - } + private void refreshKeyVaultSecretNames() { + secretNames = Optional.of(keyVaultClient) + .map(SecretClient::listPropertiesOfSecrets) + .map(secretProperties -> { + final List secretNameList = new ArrayList<>(); + secretProperties.forEach(s -> { + final String secretName = s.getName().replace(vaultUri + "/secrets/", ""); + secretNameList.add(secretName); + }); + return secretNameList; + }) + .map(Collection::stream) + .orElseGet(Stream::empty) + .map(this::toKeyVaultSecretName) + .distinct() + .collect(Collectors.toList()); + this.secretNamesLastUpdateTime = System.currentTimeMillis(); } - private void addSecretIfNotExist(final String secretName) { - final String secretNameLowerCase = secretName.toLowerCase(Locale.US); - if (!propertyNames.contains(secretNameLowerCase)) { - propertyNames.add(secretNameLowerCase); - } - final String secretNameSeparatedByDot = secretNameLowerCase.replaceAll("-", "."); - if (!propertyNames.contains(secretNameSeparatedByDot)) { - propertyNames.add(secretNameSeparatedByDot); - } + private String getValueFromKeyVault(String name) { + return Optional.ofNullable(name) + .map(keyVaultClient::getSecret) + .map(KeyVaultSecret::getValue) + .orElse(null); } } diff --git a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySource.java b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySource.java index dceb197c7b0c..6f2f9955a94a 100644 --- a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySource.java +++ b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySource.java @@ -3,24 +3,30 @@ package com.microsoft.azure.keyvault.spring; -import com.microsoft.azure.utils.Constants; import org.springframework.core.env.EnumerablePropertySource; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_PROPERTYSOURCE_NAME; +import org.springframework.core.env.PropertySource; /** * A key vault implementation of {@link EnumerablePropertySource} to enumerate all property pairs in Key Vault. */ -public class KeyVaultPropertySource extends EnumerablePropertySource { +public class KeyVaultPropertySource extends PropertySource { private final KeyVaultOperation operations; + public KeyVaultPropertySource(String keyVaultName, KeyVaultOperation operation) { + super(keyVaultName, operation); + this.operations = operation; + } + public KeyVaultPropertySource(KeyVaultOperation operation) { - super(Constants.AZURE_KEYVAULT_PROPERTYSOURCE_NAME, operation); + super(AZURE_KEYVAULT_PROPERTYSOURCE_NAME, operation); this.operations = operation; } public String[] getPropertyNames() { - return this.operations.list(); + return this.operations.getPropertyNames(); } diff --git a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/utils/Constants.java b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/utils/Constants.java index 2c2426ce955a..c3050b3a5af2 100644 --- a/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/utils/Constants.java +++ b/sdk/spring/azure-spring-boot/src/main/java/com/microsoft/azure/utils/Constants.java @@ -4,19 +4,24 @@ package com.microsoft.azure.utils; public class Constants { + /** + * The constant used to define the prefix of all Azure Key Vault properties. + */ + public static final String AZURE_KEYVAULT_PREFIX = "azure.keyvault."; + public static final String AZURE_KEYVAULT_USER_AGENT = "spring-boot-starter/" + PropertyLoader.getProjectVersion(); - public static final String AZURE_KEYVAULT_CLIENT_ID = "azure.keyvault.client-id"; - public static final String AZURE_KEYVAULT_CLIENT_KEY = "azure.keyvault.client-key"; - public static final String AZURE_KEYVAULT_TENANT_ID = "azure.keyvault.tenant-id"; - public static final String AZURE_KEYVAULT_CERTIFICATE_PATH = "azure.keyvault.certificate.path"; - public static final String AZURE_KEYVAULT_CERTIFICATE_PASSWORD = "azure.keyvault.certificate.password"; - public static final String AZURE_KEYVAULT_ENABLED = "azure.keyvault.enabled"; - public static final String AZURE_KEYVAULT_VAULT_URI = "azure.keyvault.uri"; - public static final String AZURE_KEYVAULT_REFRESH_INTERVAL = "azure.keyvault.refresh-interval"; - public static final String AZURE_KEYVAULT_SECRET_KEYS = "azure.keyvault.secret.keys"; + public static final String AZURE_KEYVAULT_CLIENT_ID = "client-id"; + public static final String AZURE_KEYVAULT_CLIENT_KEY = "client-key"; + public static final String AZURE_KEYVAULT_TENANT_ID = "tenant-id"; + public static final String AZURE_KEYVAULT_CERTIFICATE_PATH = "certificate.path"; + public static final String AZURE_KEYVAULT_CERTIFICATE_PASSWORD = "certificate.password"; + public static final String AZURE_KEYVAULT_ENABLED = "enabled"; + public static final String AZURE_KEYVAULT_VAULT_URI = "uri"; + public static final String AZURE_KEYVAULT_REFRESH_INTERVAL = "refresh-interval"; + public static final String AZURE_KEYVAULT_SECRET_KEYS = "secret.keys"; public static final String AZURE_KEYVAULT_PROPERTYSOURCE_NAME = "azurekv"; - public static final String AZURE_TOKEN_ACQUIRE_TIMEOUT_IN_SECONDS = "azure.keyvault.token-acquire-timeout-seconds"; - public static final String AZURE_KEYVAULT_ALLOW_TELEMETRY = "azure.keyvault.allow.telemetry"; + public static final String AZURE_TOKEN_ACQUIRE_TIMEOUT_IN_SECONDS = "token-acquire-timeout-seconds"; + public static final String AZURE_KEYVAULT_ALLOW_TELEMETRY = "allow.telemetry"; public static final long DEFAULT_REFRESH_INTERVAL_MS = 1800000L; public static final long TOKEN_ACQUIRE_TIMEOUT_SECS = 60L; @@ -32,4 +37,14 @@ public class Constants { public static final String SPRINGBOOT_KEY_VAULT_APPLICATION_ID = String.join("-", AZURE, SPRING, KEY_VAULT) + "/" + SPRINGBOOT_VERSION; + /** + * The constant used to define the order of the key vaults you are + * delivering (comma delimited, e.g 'myvault, myvault2'). + */ + public static final String AZURE_KEYVAULT_ORDER = "order"; + + /** + * Defines the constant for the property that enables/disables case sensitive keys. + */ + public static final String AZURE_KEYVAULT_CASE_SENSITIVE_KEYS = "azure.keyvault.case-sensitive-keys"; } diff --git a/sdk/spring/azure-spring-boot/src/main/resources/META-INF/spring.factories b/sdk/spring/azure-spring-boot/src/main/resources/META-INF/spring.factories index 776f90864ad4..e517f6f32156 100644 --- a/sdk/spring/azure-spring-boot/src/main/resources/META-INF/spring.factories +++ b/sdk/spring/azure-spring-boot/src/main/resources/META-INF/spring.factories @@ -5,7 +5,4 @@ com.microsoft.azure.spring.autoconfigure.cosmosdb.CosmosDbReactiveRepositoriesAu com.microsoft.azure.spring.autoconfigure.gremlin.GremlinAutoConfiguration,\ com.microsoft.azure.spring.autoconfigure.gremlin.GremlinRepositoriesAutoConfiguration,\ com.microsoft.azure.spring.autoconfigure.aad.AADAuthenticationFilterAutoConfiguration,\ -com.microsoft.azure.spring.autoconfigure.aad.AADOAuth2AutoConfiguration,\ -com.microsoft.azure.spring.autoconfigure.btoc.AADB2CAutoConfiguration,\ -com.microsoft.azure.spring.autoconfigure.metrics.AzureMonitorMetricsExportAutoConfiguration,\ -com.microsoft.azure.spring.autoconfigure.jms.ServiceBusJMSAutoConfiguration +com.microsoft.azure.spring.autoconfigure.aad.AADOAuth2AutoConfiguration diff --git a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/CaseSensitiveKeyVaultTest.java b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/CaseSensitiveKeyVaultTest.java new file mode 100644 index 000000000000..f55b24306216 --- /dev/null +++ b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/CaseSensitiveKeyVaultTest.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.keyvault.spring; + +import com.azure.security.keyvault.secrets.SecretClient; +import com.azure.security.keyvault.secrets.models.KeyVaultSecret; +import static com.microsoft.azure.utils.Constants.TOKEN_ACQUIRE_TIMEOUT_SECS; +import java.util.Arrays; +import java.util.List; +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import static org.mockito.Mockito.when; +import org.mockito.junit.MockitoJUnitRunner; + + +@RunWith(MockitoJUnitRunner.class) +public class CaseSensitiveKeyVaultTest { + @Mock + private SecretClient keyVaultClient; + + @Test + public void testGet() { + final List keys = Arrays.asList("key1", "Key2"); + + final KeyVaultOperation keyVaultOperation = new KeyVaultOperation( + keyVaultClient, + "https:fake.vault.com", + TOKEN_ACQUIRE_TIMEOUT_SECS, + keys, + true); + + final KeyVaultSecret key1 = new KeyVaultSecret("key1", "value1"); + when(keyVaultClient.getSecret("key1")).thenReturn(key1); + final KeyVaultSecret key2 = new KeyVaultSecret("Key2", "Value2"); + when(keyVaultClient.getSecret("Key2")).thenReturn(key2); + + assertEquals("value1", keyVaultOperation.get("key1")); + assertEquals("Value2", keyVaultOperation.get("Key2")); + } +} diff --git a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorTest.java b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorTest.java index bca8b8dc7620..6663972fa573 100644 --- a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorTest.java +++ b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultEnvironmentPostProcessorTest.java @@ -25,6 +25,7 @@ import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CERTIFICATE_PATH; import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_CLIENT_ID; +import static com.microsoft.azure.utils.Constants.AZURE_KEYVAULT_PREFIX; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; @@ -58,9 +59,9 @@ public void testGetCredentialsWhenUsingClientAndKey() { @Test public void testGetCredentialsWhenPFXCertConfigured() { - testProperties.put(AZURE_KEYVAULT_CLIENT_ID, "aaaa-bbbb-cccc-dddd"); + testProperties.put(AZURE_KEYVAULT_PREFIX + AZURE_KEYVAULT_CLIENT_ID, "aaaa-bbbb-cccc-dddd"); testProperties.put("azure.keyvault.tenant-id", "myid"); - testProperties.put(AZURE_KEYVAULT_CERTIFICATE_PATH, "fake-pfx-cert.pfx"); + testProperties.put(AZURE_KEYVAULT_PREFIX + AZURE_KEYVAULT_CERTIFICATE_PATH, "fake-pfx-cert.pfx"); propertySources.addLast(new MapPropertySource("Test_Properties", testProperties)); keyVaultEnvironmentPostProcessorHelper = new KeyVaultEnvironmentPostProcessorHelper(environment); @@ -124,6 +125,29 @@ public void postProcessorOrderConfigurable() { context.getBean(KeyVaultEnvironmentPostProcessor.class).getOrder()); }); } + + /** + * Test the multiple key vault support. + */ + @Test + public void testMultipleKeyVaults() { + testProperties.put("azure.keyvault.order", "myvault, myvault2"); + testProperties.put("azure.keyvault.myvault.client-id", "aaaa-bbbb-cccc-dddd"); + testProperties.put("azure.keyvault.myvault.client-key", "mySecret"); + testProperties.put("azure.keyvault.myvault.tenant-id", "myid"); + testProperties.put("azure.keyvault.myvault2.client-id", "aaaa-bbbb-cccc-dddd"); + testProperties.put("azure.keyvault.myvault2.client-key", "mySecret"); + testProperties.put("azure.keyvault.myvault2.tenant-id", "myid"); + propertySources.addLast(new MapPropertySource("Test_Properties", testProperties)); + + keyVaultEnvironmentPostProcessorHelper = new KeyVaultEnvironmentPostProcessorHelper(environment); + + final TokenCredential credentials = keyVaultEnvironmentPostProcessorHelper.getCredentials("myvault."); + assertThat(credentials, IsInstanceOf.instanceOf(ClientSecretCredential.class)); + + final TokenCredential credentials2 = keyVaultEnvironmentPostProcessorHelper.getCredentials("myvault2."); + assertThat(credentials2, IsInstanceOf.instanceOf(ClientSecretCredential.class)); + } } @Configuration diff --git a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultOperationUnitTest.java b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultOperationUnitTest.java index 31b652fcc8b0..51094f3020e4 100644 --- a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultOperationUnitTest.java +++ b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultOperationUnitTest.java @@ -63,7 +63,8 @@ public void setupSecretBundle(String id, String value, List secretKeysCo keyVaultOperation = new KeyVaultOperation(keyVaultClient, FAKE_VAULT_URI, Constants.TOKEN_ACQUIRE_TIMEOUT_SECS, - secretKeysConfig); + secretKeysConfig, + false); } @Test @@ -90,13 +91,13 @@ public void testGetAndHitWhenSecretsProvided() { public void testList() { //test list with no specific secret keys setupSecretBundle(TEST_PROPERTY_NAME_1, TEST_PROPERTY_NAME_1, null); - final String[] result = keyVaultOperation.list(); + final String[] result = keyVaultOperation.getPropertyNames(); assertThat(result.length).isEqualTo(1); assertThat(result[0]).isEqualToIgnoringCase(TEST_PROPERTY_NAME_1); //test list with specific secret key configs setupSecretBundle(TEST_PROPERTY_NAME_1, TEST_PROPERTY_NAME_1, SECRET_KEYS_CONFIG); - final String[] specificResult = keyVaultOperation.list(); + final String[] specificResult = keyVaultOperation.getPropertyNames(); assertThat(specificResult.length).isEqualTo(3); assertThat(specificResult[0]).isEqualTo(SECRET_KEYS_CONFIG.get(0)); } diff --git a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySourceUnitTest.java b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySourceUnitTest.java index 7aa681435c72..4b546a57f198 100644 --- a/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySourceUnitTest.java +++ b/sdk/spring/azure-spring-boot/src/test/java/com/microsoft/azure/keyvault/spring/KeyVaultPropertySourceUnitTest.java @@ -26,7 +26,7 @@ public void setup() { final String[] propertyNameList = new String[]{TEST_PROPERTY_NAME_1}; when(keyVaultOperation.get(anyString())).thenReturn(TEST_PROPERTY_NAME_1); - when(keyVaultOperation.list()).thenReturn(propertyNameList); + when(keyVaultOperation.getPropertyNames()).thenReturn(propertyNameList); keyVaultPropertySource = new KeyVaultPropertySource(keyVaultOperation); } diff --git a/sdk/spring/pom.xml b/sdk/spring/pom.xml index f0bee2850414..b9e7c1e1216c 100644 --- a/sdk/spring/pom.xml +++ b/sdk/spring/pom.xml @@ -19,6 +19,7 @@ azure-spring-boot-starter-metrics azure-spring-boot-starter-servicebus-jms azure-spring-boot-samples + azure-spring-boot-tests diff --git a/sdk/spring/tests.yml b/sdk/spring/tests.yml index 9da99dbd701d..0a51f922ad7b 100644 --- a/sdk/spring/tests.yml +++ b/sdk/spring/tests.yml @@ -1,9 +1,32 @@ trigger: none jobs: - - template: ../../eng/pipelines/templates/jobs/archetype-sdk-tests.yml - parameters: - ServiceDirectory: spring - EnvVars: - AZURE_TEST_MODE: LIVE - Artifacts: [] + - template: ../../eng/pipelines/templates/jobs/archetype-sdk-tests.yml + parameters: + TimeoutInMinutes: 240 + ServiceDirectory: spring + TestStepMavenInputs: + options: '-Dmaven.wagon.http.pool=false $(DefaultOptions) -Dmaven.javadoc.skip=true -Drevapi.skip=true -DskipSpringITs=false -pl $(ProjectList)' + goals: "verify" + + Artifacts: + - name: azure-spring-boot-tests + groupId: com.microsoft.azure + safeName: azurespringboot-tests + - name: azure-spring-boot-test-core + groupId: com.microsoft.azure + safeName: azurespringboottestcore + - name: azure-spring-boot-test-cosmosdb + groupId: com.microsoft.azure + safeName: azurespringboottestcosmosdb + - name: azure-spring-boot-test-aad + groupId: com.microsoft.azure + safeName: azurespringboottestaad + - name: azure-spring-boot-test-keyvault + groupId: com.microsoft.azure + safeName: azurespringboottestkeyvault + - name: azure-spring-boot-test-application + groupId: com.microsoft.azure + safeName: azurespringboottestapplication + EnvVars: + AZURE_TEST_MODE: LIVE