diff --git a/auth/pom.xml b/auth/pom.xml index d90dad4905..65311586e9 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -28,6 +28,10 @@ feast-common ${project.version} + + org.springframework + spring-context-support + net.devh grpc-server-spring-boot-starter @@ -91,6 +95,17 @@ jsr305 3.0.2 + + org.springframework + spring-test + test + + + org.mockito + mockito-core + ${mockito.version} + test + @@ -131,6 +146,10 @@ feast.auth.generated.client.api + + org.jacoco + jacoco-maven-plugin + diff --git a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java index ccd1b83f4f..1e8a064223 100644 --- a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java +++ b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java @@ -16,14 +16,16 @@ */ package feast.auth.authorization; +import feast.auth.config.CacheConfiguration; import feast.auth.generated.client.api.DefaultApi; import feast.auth.generated.client.invoker.ApiClient; import feast.auth.generated.client.invoker.ApiException; import feast.auth.generated.client.model.CheckAccessRequest; +import feast.auth.utils.AuthUtils; import java.util.Map; -import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.jwt.Jwt; @@ -41,7 +43,7 @@ public class HttpAuthorizationProvider implements AuthorizationProvider { * The default subject claim is the key within the Authentication object where the user's identity * can be found */ - private final String DEFAULT_SUBJECT_CLAIM = "email"; + private final String subjectClaim; /** * Initializes the HTTPAuthorizationProvider @@ -58,26 +60,29 @@ public HttpAuthorizationProvider(Map options) { ApiClient apiClient = new ApiClient(); apiClient.setBasePath(options.get("authorizationUrl")); this.defaultApiClient = new DefaultApi(apiClient); + subjectClaim = options.get("subjectClaim"); } /** - * Validates whether a user has access to a project + * Validates whether a user has access to a project. @Cacheable is using {@link + * CacheConfiguration} settings to cache output of the method {@link AuthorizationResult} for a + * specified duration set in cache settings. * * @param projectId Name of the Feast project * @param authentication Spring Security Authentication object * @return AuthorizationResult result of authorization query */ + @Cacheable(value = CacheConfiguration.AUTHORIZATION_CACHE, keyGenerator = "authKeyGenerator") public AuthorizationResult checkAccessToProject(String projectId, Authentication authentication) { CheckAccessRequest checkAccessRequest = new CheckAccessRequest(); Object context = getContext(authentication); - String subject = getSubjectFromAuth(authentication, DEFAULT_SUBJECT_CLAIM); + String subject = AuthUtils.getSubjectFromAuth(authentication, subjectClaim); String resource = "projects:" + projectId; checkAccessRequest.setAction("ALL"); checkAccessRequest.setContext(context); checkAccessRequest.setResource(resource); checkAccessRequest.setSubject(subject); - try { Jwt credentials = ((Jwt) authentication.getCredentials()); // Make authorization request to external service @@ -114,31 +119,4 @@ private Object getContext(Authentication authentication) { // Not implemented yet, left empty return new Object(); } - - /** - * Get user email from their authentication object. - * - * @param authentication Spring Security Authentication object, used to extract user details - * @param subjectClaim Indicates the claim where the subject can be found - * @return String user email - */ - private String getSubjectFromAuth(Authentication authentication, String subjectClaim) { - Jwt principle = ((Jwt) authentication.getPrincipal()); - Map claims = principle.getClaims(); - String subjectValue = (String) claims.get(subjectClaim); - - if (subjectValue.isEmpty()) { - throw new IllegalStateException( - String.format("JWT does not have a valid claim %s.", subjectClaim)); - } - - if (subjectClaim.equals("email")) { - boolean validEmail = (new EmailValidator()).isValid(subjectValue, null); - if (!validEmail) { - throw new IllegalStateException("JWT contains an invalid email address"); - } - } - - return subjectValue; - } } diff --git a/auth/src/main/java/feast/auth/config/CacheConfiguration.java b/auth/src/main/java/feast/auth/config/CacheConfiguration.java new file mode 100644 index 0000000000..e8c46b3613 --- /dev/null +++ b/auth/src/main/java/feast/auth/config/CacheConfiguration.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.auth.config; + +import com.google.common.cache.CacheBuilder; +import feast.auth.utils.AuthUtils; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; + +/** CacheConfiguration class defines Cache settings for HttpAuthorizationProvider class. */ +@Configuration +@EnableCaching +@Setter +@Getter +public class CacheConfiguration implements CachingConfigurer { + + private static final int CACHE_SIZE = 10000; + + public static int TTL = 60; + + public static final String AUTHORIZATION_CACHE = "authorization"; + + @Autowired SecurityProperties secutiryProps; + + @Bean + public CacheManager cacheManager() { + ConcurrentMapCacheManager cacheManager = + new ConcurrentMapCacheManager(AUTHORIZATION_CACHE) { + + @Override + protected Cache createConcurrentMapCache(final String name) { + return new ConcurrentMapCache( + name, + CacheBuilder.newBuilder() + .expireAfterWrite(TTL, TimeUnit.SECONDS) + .maximumSize(CACHE_SIZE) + .build() + .asMap(), + false); + } + }; + + return cacheManager; + } + + /* + * KeyGenerator used by {@link Cacheable} for caching authorization requests. + * Key format : checkAccessToProject-- + */ + @Bean + public KeyGenerator authKeyGenerator() { + return (Object target, Method method, Object... params) -> { + String projectId = (String) params[0]; + Authentication authentication = (Authentication) params[1]; + String subject = + AuthUtils.getSubjectFromAuth( + authentication, secutiryProps.getAuthorization().getOptions().get("subjectClaim")); + return String.format("%s-%s-%s", method.getName(), projectId, subject); + }; + } + + @Override + public CacheResolver cacheResolver() { + // TODO Auto-generated method stub + return null; + } + + @Override + public KeyGenerator keyGenerator() { + return null; + } + + @Override + public CacheErrorHandler errorHandler() { + // TODO Auto-generated method stub + return null; + } +} diff --git a/auth/src/main/java/feast/auth/utils/AuthUtils.java b/auth/src/main/java/feast/auth/utils/AuthUtils.java new file mode 100644 index 0000000000..d211165c86 --- /dev/null +++ b/auth/src/main/java/feast/auth/utils/AuthUtils.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.auth.utils; + +import java.util.Map; +import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; + +public class AuthUtils { + + // Suppresses default constructor, ensuring non-instantiability. + private AuthUtils() {} + + /** + * Get user email from their authentication object. + * + * @param authentication Spring Security Authentication object, used to extract user details + * @param subjectClaim Indicates the claim where the subject can be found + * @return String user email + */ + public static String getSubjectFromAuth(Authentication authentication, String subjectClaim) { + Jwt principle = ((Jwt) authentication.getPrincipal()); + Map claims = principle.getClaims(); + String subjectValue = (String) claims.getOrDefault(subjectClaim, ""); + + if (subjectValue.isEmpty()) { + throw new IllegalStateException( + String.format("JWT does not have a valid claim %s.", subjectClaim)); + } + + if (subjectClaim.equals("email")) { + boolean validEmail = (new EmailValidator()).isValid(subjectValue, null); + if (!validEmail) { + throw new IllegalStateException("JWT contains an invalid email address"); + } + } + return subjectValue; + } +} diff --git a/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java new file mode 100644 index 0000000000..44129d41ce --- /dev/null +++ b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.auth.authorization; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import feast.auth.config.CacheConfiguration; +import feast.auth.config.SecurityProperties; +import feast.auth.config.SecurityProperties.AuthenticationProperties; +import feast.auth.config.SecurityProperties.AuthorizationProperties; +import feast.auth.generated.client.api.DefaultApi; +import feast.auth.generated.client.model.AuthorizationResult; +import feast.auth.generated.client.model.CheckAccessRequest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.internal.util.reflection.FieldSetter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@ContextConfiguration( + classes = {CacheConfiguration.class, HttpAuthorizationProviderCachingTest.Config.class}) +public class HttpAuthorizationProviderCachingTest { + + // static since field needs to updated in provider() bean + private static DefaultApi api = Mockito.mock(DefaultApi.class); + + @Autowired AuthorizationProvider provider; + + @Configuration + static class Config { + @Bean + SecurityProperties securityProps() { + // setting TTL static variable in SecurityProperties bean, since CacheConfiguration bean is + // dependent on SecurityProperties. + CacheConfiguration.TTL = 1; + AuthenticationProperties authentication = Mockito.mock(AuthenticationProperties.class); + AuthorizationProperties authorization = new AuthorizationProperties(); + authorization.setEnabled(true); + authorization.setProvider("http"); + Map options = new HashMap<>(); + options.put("authorizationUrl", "localhost"); + options.put("subjectClaim", "email"); + authorization.setOptions(options); + SecurityProperties sp = new SecurityProperties(); + sp.setAuthentication(authentication); + sp.setAuthorization(authorization); + return sp; + } + + @Bean + AuthorizationProvider provider() throws NoSuchFieldException, SecurityException { + Map options = new HashMap<>(); + options.put("authorizationUrl", "localhost"); + options.put("subjectClaim", "email"); + HttpAuthorizationProvider provider = new HttpAuthorizationProvider(options); + FieldSetter.setField(provider, provider.getClass().getDeclaredField("defaultApiClient"), api); + return provider; + } + } + + @Test + public void testCheckAccessToProjectShouldReadFromCacheWhenAvailable() throws Exception { + Authentication auth = Mockito.mock(Authentication.class); + Jwt jwt = Mockito.mock(Jwt.class); + Map claims = new HashMap<>(); + claims.put("email", "test@test.com"); + doReturn(jwt).when(auth).getCredentials(); + doReturn(jwt).when(auth).getPrincipal(); + doReturn(claims).when(jwt).getClaims(); + doReturn("test_token").when(jwt).getTokenValue(); + AuthorizationResult authResult = new AuthorizationResult(); + authResult.setAllowed(true); + doReturn(authResult) + .when(api) + .checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + + // Should save the result in cache + provider.checkAccessToProject("test", auth); + // Should read from cache + provider.checkAccessToProject("test", auth); + verify(api, times(1)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + + // cache ttl is set to 1 second for testing. + Thread.sleep(1100); + + // Should make an invocation to external service + provider.checkAccessToProject("test", auth); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + // Should read from cache + provider.checkAccessToProject("test", auth); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + } +} diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index fbdf603632..669192f336 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -94,6 +94,7 @@ feast: provider: http options: authorizationUrl: http://localhost:8082 + subjectClaim: email grpc: server: