From 13e35c47375b330fe116e4935ead8d6271712256 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Thu, 4 Mar 2021 12:15:43 +0800 Subject: [PATCH] Support Keto authorization directly Signed-off-by: Khor Shu Heng --- common/pom.xml | 7 +- .../common/auth/config/SecurityConfig.java | 35 +- .../auth/config/SecurityProperties.java | 2 +- .../keto/KetoAuthorizationProvider.java | 165 ++++++++ .../auth/CoreServiceKetoAuthorizationIT.java | 352 ++++++++++++++++++ .../test/resources/keto/docker-compose.yml | 7 +- 6 files changed, 560 insertions(+), 8 deletions(-) create mode 100644 common/src/main/java/feast/common/auth/providers/keto/KetoAuthorizationProvider.java create mode 100644 core/src/test/java/feast/core/auth/CoreServiceKetoAuthorizationIT.java diff --git a/common/pom.xml b/common/pom.xml index ce89e9c..99008e1 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -125,7 +125,12 @@ com.google.auth google-auth-library-oauth2-http - + + sh.ory.keto + keto-client + 0.5.7-alpha.1.pre.0 + + org.openapitools diff --git a/common/src/main/java/feast/common/auth/config/SecurityConfig.java b/common/src/main/java/feast/common/auth/config/SecurityConfig.java index aa7f8a2..94a4018 100644 --- a/common/src/main/java/feast/common/auth/config/SecurityConfig.java +++ b/common/src/main/java/feast/common/auth/config/SecurityConfig.java @@ -19,6 +19,7 @@ import feast.common.auth.authentication.DefaultJwtAuthenticationProvider; import feast.common.auth.authorization.AuthorizationProvider; import feast.common.auth.providers.http.HttpAuthorizationProvider; +import feast.common.auth.providers.keto.KetoAuthorizationProvider; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -107,12 +108,40 @@ AccessDecisionManager accessDecisionManager() { AuthorizationProvider authorizationProvider() { if (securityProperties.getAuthentication().isEnabled() && securityProperties.getAuthorization().isEnabled()) { + // Merge authentication and authorization options to create HttpAuthorizationProvider. + Map options = securityProperties.getAuthorization().getOptions(); + + options.putAll(securityProperties.getAuthentication().getOptions()); switch (securityProperties.getAuthorization().getProvider()) { case "http": - // Merge authenticatoin and authorization options to create HttpAuthorizationProvider. - Map options = securityProperties.getAuthorization().getOptions(); - options.putAll(securityProperties.getAuthentication().getOptions()); return new HttpAuthorizationProvider(options); + case "keto": + String subjectClaim = + options.get(SecurityProperties.AuthenticationProperties.SUBJECT_CLAIM); + String flavor = options.get("flavor"); + String action = options.get("action"); + String subjectPrefix = options.get("subjectPrefix"); + String resourcePrefix = options.get("resourcePrefix"); + + KetoAuthorizationProvider.Builder builder = + new KetoAuthorizationProvider.Builder(options.get("authorizationUrl")); + if (subjectClaim != null) { + builder = builder.withSubjectClaim(subjectClaim); + } + if (flavor != null) { + builder = builder.withFlavor(flavor); + } + if (action != null) { + builder = builder.withAction(action); + } + if (subjectPrefix != null) { + builder = builder.withSubjectPrefix(subjectPrefix); + } + if (resourcePrefix != null) { + builder = builder.withResourcePrefix(resourcePrefix); + } + + return builder.build(); default: throw new IllegalArgumentException( "Please configure an Authorization Provider if you have enabled authorization."); diff --git a/common/src/main/java/feast/common/auth/config/SecurityProperties.java b/common/src/main/java/feast/common/auth/config/SecurityProperties.java index 135cc4b..f48d734 100644 --- a/common/src/main/java/feast/common/auth/config/SecurityProperties.java +++ b/common/src/main/java/feast/common/auth/config/SecurityProperties.java @@ -53,7 +53,7 @@ public static class AuthorizationProperties { private boolean enabled; // Named authorization provider to use. - @OneOfStrings({"none", "http"}) + @OneOfStrings({"none", "http", "keto"}) private String provider; // K/V options to initialize the provider with diff --git a/common/src/main/java/feast/common/auth/providers/keto/KetoAuthorizationProvider.java b/common/src/main/java/feast/common/auth/providers/keto/KetoAuthorizationProvider.java new file mode 100644 index 0000000..adbd426 --- /dev/null +++ b/common/src/main/java/feast/common/auth/providers/keto/KetoAuthorizationProvider.java @@ -0,0 +1,165 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 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.common.auth.providers.keto; + +import feast.common.auth.authorization.AuthorizationProvider; +import feast.common.auth.authorization.AuthorizationResult; +import feast.common.auth.utils.AuthUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import sh.ory.keto.ApiClient; +import sh.ory.keto.ApiException; +import sh.ory.keto.Configuration; +import sh.ory.keto.api.EnginesApi; +import sh.ory.keto.model.OryAccessControlPolicyAllowedInput; + +public class KetoAuthorizationProvider implements AuthorizationProvider { + + /** Builder for KetoAuthorizationProvider */ + public static class Builder { + private final String url; + private String subjectClaim = "email"; + private String flavor = "glob"; + private String action = "edit"; + private String subjectPrefix = ""; + private String resourcePrefix = ""; + + /** + * Initialized builder for Keto authorization provider. + * + * @param url Url string for Keto server. + * @return Returns Builder + */ + public Builder(String url) { + this.url = url; + } + + /** + * Set subject claim for authentication + * + * @param subjectClaim Subject claim. Default: email. + * @return Returns Builder + */ + public Builder withSubjectClaim(String subjectClaim) { + this.subjectClaim = subjectClaim; + return this; + } + + /** + * Set flavor for Keto authorization. One of [exact, glob regex] + * + * @param flavor Keto authorization flavor. Default: glob. + * @return Returns Builder + */ + public Builder withFlavor(String flavor) { + this.flavor = flavor; + return this; + } + + /** + * Set action that corresponds to the permission to edit a Feast project resource. + * + * @param action Keto action. Default: edit. + * @return Returns Builder + */ + public Builder withAction(String action) { + this.action = action; + return this; + } + + /** + * If set, The subject will be prefixed before sending the request to Keto. Example: + * users:someuser@email.com + * + * @param prefix Subject prefix. Default: Empty string. + * @return Returns Builder + */ + public Builder withSubjectPrefix(String prefix) { + this.subjectPrefix = prefix; + return this; + } + + /** + * If set, The resource will be prefixed before sending the request to Keto. Example: + * projects:somefeastproject + * + * @param prefix Resource prefix. Default: Empty string. + * @return Returns Builder + */ + public Builder withResourcePrefix(String prefix) { + this.resourcePrefix = prefix; + return this; + } + + /** + * Build KetoAuthorizationProvider + * + * @return Returns KetoAuthorizationProvider + */ + public KetoAuthorizationProvider build() { + return new KetoAuthorizationProvider(this); + } + } + + private static final Logger log = LoggerFactory.getLogger(KetoAuthorizationProvider.class); + + private final EnginesApi apiInstance; + private final String subjectClaim; + private final String flavor; + private final String action; + private final String subjectPrefix; + private final String resourcePrefix; + + private KetoAuthorizationProvider(Builder builder) { + ApiClient defaultClient = Configuration.getDefaultApiClient(); + defaultClient.setBasePath(builder.url); + apiInstance = new EnginesApi(defaultClient); + subjectClaim = builder.subjectClaim; + flavor = builder.flavor; + action = builder.action; + subjectPrefix = builder.subjectPrefix; + resourcePrefix = builder.resourcePrefix; + } + + @Override + public AuthorizationResult checkAccessToProject(String projectId, Authentication authentication) { + String subject = AuthUtils.getSubjectFromAuth(authentication, subjectClaim); + OryAccessControlPolicyAllowedInput body = new OryAccessControlPolicyAllowedInput(); + body.setAction(action); + body.setSubject(String.format("%s%s", subjectPrefix, subject)); + body.setResource(String.format("%s%s", resourcePrefix, projectId)); + try { + sh.ory.keto.model.AuthorizationResult authResult = + apiInstance.doOryAccessControlPoliciesAllow(flavor, body); + if (authResult == null) { + throw new RuntimeException( + String.format( + "Empty response returned for access to project %s for subject %s", + projectId, subject)); + } + if (authResult.getAllowed()) { + return AuthorizationResult.success(); + } + } catch (ApiException e) { + log.error("API exception has occurred during authorization: {}", e.getMessage(), e); + } + + return AuthorizationResult.failed( + String.format("Access denied to project %s for subject %s", projectId, subject)); + } +} diff --git a/core/src/test/java/feast/core/auth/CoreServiceKetoAuthorizationIT.java b/core/src/test/java/feast/core/auth/CoreServiceKetoAuthorizationIT.java new file mode 100644 index 0000000..0a09cce --- /dev/null +++ b/core/src/test/java/feast/core/auth/CoreServiceKetoAuthorizationIT.java @@ -0,0 +1,352 @@ +/* + * 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.core.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.testcontainers.containers.wait.strategy.Wait.forHttp; + +import avro.shaded.com.google.common.collect.ImmutableMap; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit.WireMockClassRule; +import com.google.protobuf.InvalidProtocolBufferException; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import feast.common.it.BaseIT; +import feast.common.it.DataGenerator; +import feast.common.it.SimpleCoreClient; +import feast.core.auth.infra.JwtHelper; +import feast.core.config.FeastProperties; +import feast.proto.core.CoreServiceGrpc; +import feast.proto.core.EntityProto; +import feast.proto.types.ValueProto; +import io.grpc.CallCredentials; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.util.SocketUtils; +import org.testcontainers.containers.DockerComposeContainer; +import sh.ory.keto.ApiClient; +import sh.ory.keto.ApiException; +import sh.ory.keto.Configuration; +import sh.ory.keto.api.EnginesApi; +import sh.ory.keto.model.OryAccessControlPolicy; +import sh.ory.keto.model.OryAccessControlPolicyRole; + +@SpringBootTest( + properties = { + "feast.security.authentication.enabled=true", + "feast.security.authorization.enabled=true", + "feast.security.authorization.provider=keto", + "feast.security.authorization.options.action=actions:any", + "feast.security.authorization.options.subjectPrefix=users:", + "feast.security.authorization.options.resourcePrefix=resources:projects:", + }) +public class CoreServiceKetoAuthorizationIT extends BaseIT { + + @Autowired FeastProperties feastProperties; + + private static final String DEFAULT_FLAVOR = "glob"; + private static int KETO_PORT = 4466; + private static int feast_core_port; + private static int JWKS_PORT = SocketUtils.findAvailableTcpPort(); + + private static JwtHelper jwtHelper = new JwtHelper(); + + static String project = "myproject"; + static String subjectInProject = "good_member@example.com"; + static String subjectIsAdmin = "bossman@example.com"; + static String subjectClaim = "sub"; + + static SimpleCoreClient insecureApiClient; + + @ClassRule public static WireMockClassRule wireMockRule = new WireMockClassRule(JWKS_PORT); + + @Rule public WireMockClassRule instanceRule = wireMockRule; + + @ClassRule + public static DockerComposeContainer environment = + new DockerComposeContainer(new File("src/test/resources/keto/docker-compose.yml")) + .withExposedService("keto_1", KETO_PORT, forHttp("/health/ready").forStatusCode(200)); + + @DynamicPropertySource + static void initialize(DynamicPropertyRegistry registry) { + + // Start Keto and with Docker Compose + environment.start(); + + // Seed Keto with data + String ketoExternalHost = environment.getServiceHost("keto_1", KETO_PORT); + Integer ketoExternalPort = environment.getServicePort("keto_1", KETO_PORT); + String ketoExternalUrl = String.format("http://%s:%s", ketoExternalHost, ketoExternalPort); + try { + seedKeto(ketoExternalUrl); + } catch (ApiException e) { + throw new RuntimeException(String.format("Could not seed Keto store %s", ketoExternalUrl)); + } + + // Start Wiremock Server to act as fake JWKS server + wireMockRule.start(); + JWKSet keySet = jwtHelper.getKeySet(); + String jwksJson = String.valueOf(keySet.toPublicJWKSet().toJSONObject()); + + // When Feast Core looks up a Json Web Token Key Set, we provide our self-signed public key + wireMockRule.stubFor( + WireMock.get(WireMock.urlPathEqualTo("/.well-known/jwks.json")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(jwksJson))); + + String jwkEndpointURI = + String.format("http://localhost:%s/.well-known/jwks.json", wireMockRule.port()); + + // Initialize dynamic properties + registry.add("feast.security.authentication.options.subjectClaim", () -> subjectClaim); + registry.add("feast.security.authentication.options.jwkEndpointURI", () -> jwkEndpointURI); + registry.add("feast.security.authorization.options.authorizationUrl", () -> ketoExternalUrl); + registry.add("feast.security.authorization.options.flavor", () -> DEFAULT_FLAVOR); + } + + @BeforeAll + public static void globalSetUp(@Value("${grpc.server.port}") int port) { + feast_core_port = port; + // Create insecure Feast Core gRPC client + Channel insecureChannel = + ManagedChannelBuilder.forAddress("localhost", feast_core_port).usePlaintext().build(); + CoreServiceGrpc.CoreServiceBlockingStub insecureCoreService = + CoreServiceGrpc.newBlockingStub(insecureChannel); + insecureApiClient = new SimpleCoreClient(insecureCoreService); + } + + @BeforeEach + public void setUp() { + SimpleCoreClient secureApiClient = getSecureApiClient(subjectIsAdmin); + EntityProto.EntitySpecV2 expectedEntitySpec = + DataGenerator.createEntitySpecV2( + "entity1", + "Entity 1 description", + ValueProto.ValueType.Enum.STRING, + ImmutableMap.of("label_key", "label_value")); + secureApiClient.simpleApplyEntity(project, expectedEntitySpec); + } + + @AfterAll + static void tearDown() { + environment.stop(); + wireMockRule.stop(); + } + + @Test + public void shouldGetVersionFromFeastCoreAlways() { + SimpleCoreClient secureApiClient = + getSecureApiClient("fakeUserThatIsAuthenticated@example.com"); + + String feastCoreVersionSecure = secureApiClient.getFeastCoreVersion(); + String feastCoreVersionInsecure = insecureApiClient.getFeastCoreVersion(); + + assertEquals(feastCoreVersionSecure, feastCoreVersionInsecure); + assertEquals(feastProperties.getVersion(), feastCoreVersionSecure); + } + + @Test + public void shouldNotAllowUnauthenticatedEntityListing() { + Exception exception = + assertThrows( + StatusRuntimeException.class, + () -> { + insecureApiClient.simpleListEntities("8"); + }); + + String expectedMessage = "UNAUTHENTICATED: Authentication failed"; + String actualMessage = exception.getMessage(); + assertEquals(actualMessage, expectedMessage); + } + + @Test + public void shouldAllowAuthenticatedEntityListing() { + SimpleCoreClient secureApiClient = + getSecureApiClient("AuthenticatedUserWithoutAuthorization@example.com"); + EntityProto.EntitySpecV2 expectedEntitySpec = + DataGenerator.createEntitySpecV2( + "entity1", + "Entity 1 description", + ValueProto.ValueType.Enum.STRING, + ImmutableMap.of("label_key", "label_value")); + List listEntitiesResponse = secureApiClient.simpleListEntities("myproject"); + EntityProto.Entity actualEntity = listEntitiesResponse.get(0); + + assert listEntitiesResponse.size() == 1; + assertEquals(actualEntity.getSpec().getName(), expectedEntitySpec.getName()); + } + + @Test + void cantApplyEntityIfNotProjectMember() throws InvalidProtocolBufferException { + String userName = "random_user@example.com"; + SimpleCoreClient secureApiClient = getSecureApiClient(userName); + EntityProto.EntitySpecV2 expectedEntitySpec = + DataGenerator.createEntitySpecV2( + "entity1", + "Entity 1 description", + ValueProto.ValueType.Enum.STRING, + ImmutableMap.of("label_key", "label_value")); + + StatusRuntimeException exception = + assertThrows( + StatusRuntimeException.class, + () -> secureApiClient.simpleApplyEntity(project, expectedEntitySpec)); + + String expectedMessage = + String.format( + "PERMISSION_DENIED: Access denied to project %s for subject %s", project, userName); + String actualMessage = exception.getMessage(); + assertEquals(actualMessage, expectedMessage); + } + + @Test + void canApplyEntityIfProjectMember() { + SimpleCoreClient secureApiClient = getSecureApiClient(subjectInProject); + EntityProto.EntitySpecV2 expectedEntitySpec = + DataGenerator.createEntitySpecV2( + "entity_6", + "Entity 1 description", + ValueProto.ValueType.Enum.STRING, + ImmutableMap.of("label_key", "label_value")); + + secureApiClient.simpleApplyEntity(project, expectedEntitySpec); + + EntityProto.Entity actualEntity = secureApiClient.simpleGetEntity(project, "entity_6"); + + assertEquals(expectedEntitySpec.getName(), actualEntity.getSpec().getName()); + assertEquals(expectedEntitySpec.getValueType(), actualEntity.getSpec().getValueType()); + } + + @Test + void canApplyEntityIfAdmin() { + SimpleCoreClient secureApiClient = getSecureApiClient(subjectIsAdmin); + EntityProto.EntitySpecV2 expectedEntitySpec = + DataGenerator.createEntitySpecV2( + "entity_7", + "Entity 1 description", + ValueProto.ValueType.Enum.STRING, + ImmutableMap.of("label_key", "label_value")); + + secureApiClient.simpleApplyEntity(project, expectedEntitySpec); + + EntityProto.Entity actualEntity = secureApiClient.simpleGetEntity(project, "entity_7"); + + assertEquals(expectedEntitySpec.getName(), actualEntity.getSpec().getName()); + assertEquals(expectedEntitySpec.getValueType(), actualEntity.getSpec().getValueType()); + } + + @TestConfiguration + public static class TestConfig extends BaseTestConfig {} + + private static void seedKeto(String url) throws ApiException { + ApiClient ketoClient = Configuration.getDefaultApiClient(); + ketoClient.setBasePath(url); + EnginesApi enginesApi = new EnginesApi(ketoClient); + + // Add policies + OryAccessControlPolicy adminPolicy = getAdminPolicy(); + enginesApi.upsertOryAccessControlPolicy(DEFAULT_FLAVOR, adminPolicy); + + OryAccessControlPolicy projectPolicy = getMyProjectMemberPolicy(); + enginesApi.upsertOryAccessControlPolicy(DEFAULT_FLAVOR, projectPolicy); + + // Add policy roles + OryAccessControlPolicyRole adminPolicyRole = getAdminPolicyRole(); + enginesApi.upsertOryAccessControlPolicyRole(DEFAULT_FLAVOR, adminPolicyRole); + + OryAccessControlPolicyRole myProjectMemberPolicyRole = getMyProjectMemberPolicyRole(); + enginesApi.upsertOryAccessControlPolicyRole(DEFAULT_FLAVOR, myProjectMemberPolicyRole); + } + + private static OryAccessControlPolicyRole getMyProjectMemberPolicyRole() { + OryAccessControlPolicyRole role = new OryAccessControlPolicyRole(); + role.setId(String.format("roles:%s-project-members", project)); + role.setMembers(Collections.singletonList("users:" + subjectInProject)); + return role; + } + + private static OryAccessControlPolicyRole getAdminPolicyRole() { + OryAccessControlPolicyRole role = new OryAccessControlPolicyRole(); + role.setId("roles:admin"); + role.setMembers(Collections.singletonList("users:" + subjectIsAdmin)); + return role; + } + + private static OryAccessControlPolicy getAdminPolicy() { + OryAccessControlPolicy policy = new OryAccessControlPolicy(); + policy.setId("policies:admin"); + policy.subjects(Collections.singletonList("roles:admin")); + policy.resources(Collections.singletonList("resources:**")); + policy.actions(Collections.singletonList("actions:**")); + policy.effect("allow"); + policy.conditions(null); + return policy; + } + + private static OryAccessControlPolicy getMyProjectMemberPolicy() { + OryAccessControlPolicy policy = new OryAccessControlPolicy(); + policy.setId(String.format("policies:%s-project-members-policy", project)); + policy.subjects(Collections.singletonList(String.format("roles:%s-project-members", project))); + policy.resources( + Arrays.asList( + String.format("resources:projects:%s", project), + String.format("resources:projects:%s:**", project))); + policy.actions(Collections.singletonList("actions:**")); + policy.effect("allow"); + policy.conditions(null); + return policy; + } + + // Create secure Feast Core gRPC client for a specific user + private static SimpleCoreClient getSecureApiClient(String subjectEmail) { + CallCredentials callCredentials = null; + try { + callCredentials = jwtHelper.getCallCredentials(subjectEmail); + } catch (JOSEException e) { + throw new RuntimeException( + String.format("Could not build call credentials: %s", e.getMessage())); + } + Channel secureChannel = + ManagedChannelBuilder.forAddress("localhost", feast_core_port).usePlaintext().build(); + + CoreServiceGrpc.CoreServiceBlockingStub secureCoreService = + CoreServiceGrpc.newBlockingStub(secureChannel).withCallCredentials(callCredentials); + + return new SimpleCoreClient(secureCoreService); + } +} diff --git a/core/src/test/resources/keto/docker-compose.yml b/core/src/test/resources/keto/docker-compose.yml index 4d0fc43..714f83e 100644 --- a/core/src/test/resources/keto/docker-compose.yml +++ b/core/src/test/resources/keto/docker-compose.yml @@ -7,6 +7,7 @@ services: image: oryd/keto:v0.4.3-alpha.2 environment: - DSN=postgres://keto:keto@db:5432/keto?sslmode=disable + - SERVE_CORS_ENABLED=true command: - serve ports: @@ -32,11 +33,11 @@ services: adaptor: depends_on: - - keto + - keto image: gcr.io/kf-feast/feast-keto-auth-server:latest environment: SERVER_PORT: 8080 KETO_URL: http://keto:4466 ports: - - 8080 - restart: on-failure \ No newline at end of file + - 8080 + restart: on-failure