Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Keto Authorization with External HTTP Authorization #864

Merged
merged 11 commits into from
Jul 10, 2020
Prev Previous commit
Next Next commit
Update External HTTP Authorization Provider to use a more generalized…
… contract
  • Loading branch information
woop committed Jul 10, 2020
commit 825b07fdaa8b671a4c6b6783372918d4281b384d
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,16 @@
*/
package feast.auth.authorization;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
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.CheckProjectAccessRequest;
import feast.auth.generated.client.model.CheckProjectAccessResponse;
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
Expand All @@ -38,8 +34,15 @@
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
*
Expand All @@ -53,65 +56,85 @@ public HttpAuthorizationProvider(Map<String, String> options) {
}

ApiClient apiClient = new ApiClient();
apiClient.setBasePath(options.get("externalAuthUrl"));
apiClient.setBasePath(options.get("authorizationUrl"));
this.defaultApiClient = new DefaultApi(apiClient);
}

/**
* Validates whether a user has access to the project
* Validates whether a user has access to a project
*
* @param project Name of the Feast project
* @param projectId Name of the Feast project
* @param authentication Spring Security Authentication object
* @return AuthorizationResult result of authorization query
*/
public AuthorizationResult checkAccess(String project, Authentication authentication) {
CheckProjectAccessRequest checkProjectAccessRequest =
new CheckProjectAccessRequest().project(project).authentication(authentication);
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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably need to change this to include the resourcetype like org.feast.project:{projectId} or something

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I fixed it but havent pushed yet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the do-not-merge on for protection ;)

But no worries, this at least allows your team to move ahead. I'll submit a patch tomorrow. I am going to bed now.

checkAccessRequest.setSubject(subject);

try {
// Make authorization request to external service
CheckProjectAccessResponse response =
defaultApiClient.checkProjectAccessPost(checkProjectAccessRequest);
if (response == null || response.getAllowed() == null) {
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 authentication \n%s",
project, authenticationToJson(authentication)));
"Empty response returned for access to project %s for subject %s",
projectId, subject));
}
if (response.getAllowed()) {
if (authResult.getAllowed()) {
// Successfully authenticated
return AuthorizationResult.success();
}
// Could not determine project membership, deny access.
return AuthorizationResult.failed(
String.format(
"Access denied to project %s for with message: %s", project, response.getMessage()));
} catch (ApiException e) {
log.error("API exception has occurred while authenticating user: {}", e.getMessage(), 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", project));
return AuthorizationResult.failed(
String.format("Access denied to project %s for subject %s", projectId, subject));
}

/**
* Converts Spring Authentication object into Json String form.
* Extract a context object to send as metadata to the authorization service
*
* @param authentication Authentication object that contains request level authentication metadata
* @return Json representation of authentication object
* @param authentication Spring Security Authentication object
* @return Returns a context object that will be serialized and sent as metadata to the authorization service
*/
private static String authenticationToJson(Authentication authentication) {
ObjectWriter ow =
new ObjectMapper()
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
.writer()
.withDefaultPrettyPrinter();
try {
return ow.writeValueAsString(authentication);
} catch (JsonProcessingException e) {
throw new RuntimeException(
String.format(
"Could not convert Authentication object to JSON: %s", authentication.toString()));
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<String, Object> 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;
}
}
78 changes: 55 additions & 23 deletions auth/src/main/resources/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ info:
title: Feast Authorization Server
version: 1.0.0
servers:
- url: /v1
- url: /
paths:
/healthz:
get:
Expand All @@ -23,57 +23,89 @@ paths:
description: Ready
"500":
description: Not Ready
/checkAccessByResource:
/checkAccess:
post:
operationId: check_access_by_resource_post
operationId: check_access_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/checkAccessByResourceRequest'
description: Request containing both the Spring Authorization object as well
as the resource type and resource Id that access is being requested for.
$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/checkAccessByResourceResponse'
description: Authorized
"401":
$ref: '#/components/schemas/authorizationResult'
description: Authorization passed response
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/checkAccessByResourceResponse'
description: Unauthorized
$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-swagger-router-controller: swagger_server.controllers.default_controller
x-codegen-request-body-name: body
x-openapi-router-controller: openapi_server.controllers.default_controller
components:
schemas:
checkAccessByResourceRequest:
checkAccessRequest:
example:
authentication: '{}'
resourceType: project
resourceId: default
action: 'read'
context: '{}'
resource: 'feast:project'
subject: 'me@example.com'
properties:
authentication:
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
resourceType:
resource:
description: Resource is the resource that access is requested to.
type: string
resourceId:
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
checkAccessByResourceResponse:
authorizationResult:
example:
allowed: true
message: message
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
type: object
reason:
type: string
request:
type: string
status:
type: string
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,15 @@ public List<Project> 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"));
}
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ feast:
enabled: false
provider: http
options:
externalAuthUrl: http://localhost:8082
authorizationUrl: http://localhost:8082

grpc:
server:
Expand Down
4 changes: 2 additions & 2 deletions core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApplyFeatureSetResponse> responseObserver = StreamRecorder.create();
FeatureSetProto.FeatureSet incomingFeatureSet = newDummyFeatureSet("f2", 1, project).toProto();
Expand All @@ -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<ApplyFeatureSetResponse> responseObserver = StreamRecorder.create();
FeatureSetProto.FeatureSet incomingFeatureSet = newDummyFeatureSet("f2", 1, project).toProto();
Expand Down