diff --git a/auth/pom.xml b/auth/pom.xml index 4804075f8c5..42c6b25c3de 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -7,10 +7,21 @@ feast-parent ${revision} + feast-auth Feast Authentication and Authorization + + feast.auth.generated.client + 1.8.4 + 1.5.24 + 3.14.7 + 2.8.6 + 3.10 + 1.3.2 + 4.13 + dev.feast @@ -32,11 +43,6 @@ spring-security-oauth2-jose 5.3.0.RELEASE - - sh.ory.keto - keto-client - 0.4.4-alpha.1 - org.projectlombok lombok @@ -46,6 +52,85 @@ hibernate-validator 6.1.2.Final + + com.fasterxml.jackson.core + jackson-databind + + + junit + junit + + + io.swagger + swagger-annotations + ${swagger-core-version} + + + com.squareup.okhttp3 + okhttp + ${okhttp-version} + + + com.squareup.okhttp3 + logging-interceptor + ${okhttp-version} + + + com.google.code.gson + gson + ${gson-version} + + + io.gsonfire + gson-fire + ${gson-fire-version} + + + + com.google.code.findbugs + jsr305 + 3.0.2 + - + + + + org.openapitools + openapi-generator-maven-plugin + 4.3.1 + + + + generate + + + ${project.basedir}/src/main/resources/api.yaml + java + ${external.auth.client.package.name} + ${external.auth.client.package.name}.model + ${external.auth.client.package.name}.api + ${external.auth.client.package.name}.invoker + + ${project.groupId} + ${project.artifactId} + ${project.version} + true + java8 + Apache 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + ${project.build.directory}/generated-sources + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + feast.auth.generated.client.api + + + + diff --git a/auth/src/main/java/feast/auth/authorization/AuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/AuthorizationProvider.java index 7d4d77b7927..bf0e5797280 100644 --- a/auth/src/main/java/feast/auth/authorization/AuthorizationProvider.java +++ b/auth/src/main/java/feast/auth/authorization/AuthorizationProvider.java @@ -25,11 +25,11 @@ public interface AuthorizationProvider { /** - * Validates whether a user is allowed access to the project + * Validates whether a user is allowed access to a project * - * @param project Name of the Feast project + * @param projectId Id of the Feast project * @param authentication Spring Security Authentication object * @return AuthorizationResult result of authorization query */ - AuthorizationResult checkAccess(String project, Authentication authentication); + AuthorizationResult checkAccessToProject(String projectId, Authentication authentication); } diff --git a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java new file mode 100644 index 00000000000..d3535af6155 --- /dev/null +++ b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java @@ -0,0 +1,141 @@ +/* + * 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 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 java.util.Map; +import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * HTTPAuthorizationProvider uses an external HTTP service for authorizing requests. Please see + * auth/src/main/resources/api.yaml for the API specification of this external service. + */ +public class HttpAuthorizationProvider implements AuthorizationProvider { + + private static final Logger log = LoggerFactory.getLogger(HttpAuthorizationProvider.class); + + private final DefaultApi defaultApiClient; + + /** + * 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"; + + /** + * Initializes the HTTPAuthorizationProvider + * + * @param options String K/V pair of options to initialize the provider with. Expects at least a + * "basePath" for the provider URL + */ + public HttpAuthorizationProvider(Map options) { + if (options == null) { + throw new IllegalArgumentException( + "Cannot pass empty or null options to HTTPAuthorizationProvider"); + } + + ApiClient apiClient = new ApiClient(); + apiClient.setBasePath(options.get("authorizationUrl")); + this.defaultApiClient = new DefaultApi(apiClient); + } + + /** + * Validates whether a user has access to a project + * + * @param projectId Name of the Feast project + * @param authentication Spring Security Authentication object + * @return AuthorizationResult result of authorization query + */ + public AuthorizationResult checkAccessToProject(String projectId, Authentication authentication) { + + CheckAccessRequest checkAccessRequest = new CheckAccessRequest(); + Object context = getContext(authentication); + String subject = getSubjectFromAuth(authentication, DEFAULT_SUBJECT_CLAIM); + checkAccessRequest.setAction("ALL"); + checkAccessRequest.setContext(context); + checkAccessRequest.setResource(projectId); + checkAccessRequest.setSubject(subject); + + try { + // Make authorization request to external service + feast.auth.generated.client.model.AuthorizationResult authResult = + defaultApiClient.checkAccessPost(checkAccessRequest); + if (authResult == null) { + throw new RuntimeException( + String.format( + "Empty response returned for access to project %s for subject %s", + projectId, subject)); + } + if (authResult.getAllowed()) { + // Successfully authenticated + return AuthorizationResult.success(); + } + } catch (ApiException e) { + log.error("API exception has occurred during authorization: {}", e.getMessage(), e); + } + + // Could not determine project membership, deny access. + return AuthorizationResult.failed( + String.format("Access denied to project %s for subject %s", projectId, subject)); + } + + /** + * Extract a context object to send as metadata to the authorization service + * + * @param authentication Spring Security Authentication object + * @return Returns a context object that will be serialized and sent as metadata to the + * authorization service + */ + 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/authorization/Keto/KetoAuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/Keto/KetoAuthorizationProvider.java deleted file mode 100644 index 6350bc18488..00000000000 --- a/auth/src/main/java/feast/auth/authorization/Keto/KetoAuthorizationProvider.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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.Keto; - -import feast.auth.authorization.AuthorizationProvider; -import feast.auth.authorization.AuthorizationResult; -import java.util.List; -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; -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.OryAccessControlPolicyRole; - -/** Authorization Provider implementation for Ory Keto */ -public class KetoAuthorizationProvider implements AuthorizationProvider { - - private final EnginesApi apiInstance; - - /** - * Initializes the KetoAuthorizationProvider - * - * @param options String K/V pair of options to initialize the provider with. Expects at least a - * "basePath" for the provider URL - */ - public KetoAuthorizationProvider(Map options) { - if (options == null) { - throw new IllegalArgumentException("Cannot pass empty or null options to KetoAuth"); - } - ApiClient defaultClient = Configuration.getDefaultApiClient(); - defaultClient.setBasePath(options.get("basePath")); - this.apiInstance = new EnginesApi(defaultClient); - } - - /** - * Validates whether a user has access to the project - * - * @param project Name of the Feast project - * @param authentication Spring Security Authentication object - * @return AuthorizationResult result of authorization query - */ - public AuthorizationResult checkAccess(String project, Authentication authentication) { - String email = getEmailFromAuth(authentication); - try { - // Get all roles from Keto - List roles = - this.apiInstance.listOryAccessControlPolicyRoles("glob", 500L, 500L, email); - - // Loop through all roles the user has - for (OryAccessControlPolicyRole role : roles) { - // If the user has an admin or project specific role, return. - if (("roles:admin").equals(role.getId()) - || (String.format("roles:feast:%s-member", project)).equals(role.getId())) { - return AuthorizationResult.success(); - } - } - } catch (ApiException e) { - System.err.println("Exception when calling EnginesApi#doOryAccessControlPoliciesAllow"); - System.err.println("Status code: " + e.getCode()); - System.err.println("Reason: " + e.getResponseBody()); - System.err.println("Response headers: " + e.getResponseHeaders()); - e.printStackTrace(); - } - // Could not determine project membership, deny access. - return AuthorizationResult.failed( - String.format("Access denied to project %s for user %s", project, email)); - } - - /** - * Get user email from their authentication object. - * - * @param authentication Spring Security Authentication object, used to extract user details - * @return String user email - */ - private String getEmailFromAuth(Authentication authentication) { - Jwt principle = ((Jwt) authentication.getPrincipal()); - Map claims = principle.getClaims(); - String email = (String) claims.get("email"); - - if (email.isEmpty()) { - throw new IllegalStateException("JWT does not have a valid email set."); - } - boolean validEmail = (new EmailValidator()).isValid(email, null); - if (!validEmail) { - throw new IllegalStateException("JWT contains an invalid email address"); - } - return email; - } -} diff --git a/auth/src/main/java/feast/auth/config/SecurityConfig.java b/auth/src/main/java/feast/auth/config/SecurityConfig.java index f5f14ccaef1..f377c76a874 100644 --- a/auth/src/main/java/feast/auth/config/SecurityConfig.java +++ b/auth/src/main/java/feast/auth/config/SecurityConfig.java @@ -18,9 +18,10 @@ import feast.auth.authentication.DefaultJwtAuthenticationProvider; import feast.auth.authorization.AuthorizationProvider; -import feast.auth.authorization.Keto.KetoAuthorizationProvider; +import feast.auth.authorization.HttpAuthorizationProvider; import java.util.ArrayList; import java.util.List; +import java.util.Map; import net.devh.boot.grpc.server.security.authentication.BearerAuthenticationReader; import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader; import net.devh.boot.grpc.server.security.check.AccessPredicateVoter; @@ -107,8 +108,9 @@ AuthorizationProvider authorizationProvider() { if (securityProperties.getAuthentication().isEnabled() && securityProperties.getAuthorization().isEnabled()) { switch (securityProperties.getAuthorization().getProvider()) { - case "keto": - return new KetoAuthorizationProvider(securityProperties.getAuthorization().getOptions()); + case "http": + Map options = securityProperties.getAuthorization().getOptions(); + return new HttpAuthorizationProvider(options); default: throw new IllegalArgumentException( "Please configure an Authorization Provider if you have enabled authorization."); diff --git a/auth/src/main/java/feast/auth/config/SecurityProperties.java b/auth/src/main/java/feast/auth/config/SecurityProperties.java index 4d875caeb58..11eb2f2c13b 100644 --- a/auth/src/main/java/feast/auth/config/SecurityProperties.java +++ b/auth/src/main/java/feast/auth/config/SecurityProperties.java @@ -50,7 +50,7 @@ public static class AuthorizationProperties { private boolean enabled; // Named authorization provider to use. - @OneOfStrings({"none", "keto"}) + @OneOfStrings({"none", "http"}) private String provider; // K/V options to initialize the provider with diff --git a/auth/src/main/resources/api.yaml b/auth/src/main/resources/api.yaml new file mode 100644 index 00000000000..8e6ba5a375b --- /dev/null +++ b/auth/src/main/resources/api.yaml @@ -0,0 +1,111 @@ +openapi: 3.0.1 +info: + description: 'Feast Authorization Server' + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: Feast Authorization Server + version: 1.0.0 +servers: + - url: / +paths: + /healthz: + get: + responses: + "200": + description: Online + "500": + description: Offline + /readiness: + get: + responses: + "200": + description: Ready + "500": + description: Not Ready + /checkAccess: + post: + operationId: check_access_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/checkAccessRequest' + description: Request containing user, resource, and action information. Used to make an authorization decision. + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/authorizationResult' + description: Authorization passed response + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/authorizationResult' + description: Authorization failed response + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/inline_response_500' + description: The standard error format + summary: Check whether request is authorized to access a specific resource + x-codegen-request-body-name: body +components: + schemas: + checkAccessRequest: + example: + action: 'read' + context: '{}' + resource: 'feast:project' + subject: 'me@example.com' + properties: + action: + description: Action is the action that is being taken on the requested resource. + type: string + context: + description: Context is the request's environmental context. + properties: {} + type: object + resource: + description: Resource is the resource that access is requested to. + type: string + subject: + description: Subject is the subject that is requesting access, typically the user. + type: string + title: Input for checking if a request is allowed or not. + type: object + authorizationResult: + example: + allowed: true + properties: + allowed: + description: Allowed is true if the request should be allowed and false + otherwise. + type: boolean + required: + - allowed + title: AuthorizationResult is the result of an access control decision. It contains + the decision outcome. + type: object + inline_response_500: + properties: + code: + format: int64 + type: integer + details: + items: + properties: {} + type: object + type: array + message: + type: string + reason: + type: string + request: + type: string + status: + type: string \ No newline at end of file diff --git a/core/src/main/java/feast/core/service/AccessManagementService.java b/core/src/main/java/feast/core/service/AccessManagementService.java index dea284c2308..bd5eeed906b 100644 --- a/core/src/main/java/feast/core/service/AccessManagementService.java +++ b/core/src/main/java/feast/core/service/AccessManagementService.java @@ -112,14 +112,15 @@ public List listProjects() { * Determine whether a user belongs to a Project * * @param securityContext User's Spring Security Context. Used to identify user. - * @param project Name of the project for which membership should be tested. + * @param projectId Id (name) of the project for which membership should be tested. */ - public void checkIfProjectMember(SecurityContext securityContext, String project) { + public void checkIfProjectMember(SecurityContext securityContext, String projectId) { Authentication authentication = securityContext.getAuthentication(); if (!this.securityProperties.getAuthorization().isEnabled()) { return; } - AuthorizationResult result = this.authorizationProvider.checkAccess(project, authentication); + AuthorizationResult result = + this.authorizationProvider.checkAccessToProject(projectId, authentication); if (!result.isAllowed()) { throw new AccessDeniedException(result.getFailureReason().orElse("AccessDenied")); } diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index f03be52d9f1..fbdf6036328 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -91,9 +91,9 @@ feast: authorization: enabled: false - provider: none + provider: http options: - basePath: http://localhost:3000 + authorizationUrl: http://localhost:8082 grpc: server: diff --git a/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java b/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java index 5da7b1f566d..bd59510c673 100644 --- a/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java +++ b/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java @@ -96,7 +96,7 @@ void cantApplyFeatureSetIfNotProjectMember() throws InvalidProtocolBufferExcepti doReturn(AuthorizationResult.failed(null)) .when(authProvider) - .checkAccess(anyString(), any(Authentication.class)); + .checkAccessToProject(anyString(), any(Authentication.class)); StreamRecorder responseObserver = StreamRecorder.create(); FeatureSetProto.FeatureSet incomingFeatureSet = newDummyFeatureSet("f2", 1, project).toProto(); @@ -121,7 +121,7 @@ void canApplyFeatureSetIfProjectMember() throws InvalidProtocolBufferException { when(context.getAuthentication()).thenReturn(auth); doReturn(AuthorizationResult.success()) .when(authProvider) - .checkAccess(anyString(), any(Authentication.class)); + .checkAccessToProject(anyString(), any(Authentication.class)); StreamRecorder responseObserver = StreamRecorder.create(); FeatureSetProto.FeatureSet incomingFeatureSet = newDummyFeatureSet("f2", 1, project).toProto();