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: