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

6936 Implement email domain based groups #6974

Merged
merged 18 commits into from
Jul 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9673e82
Refactor ConfirmEmailService to offer verification checking and start…
poikilotherm May 28, 2020
45cb9a7
Add a very basic first implementation of the mail domain group infras…
poikilotherm May 28, 2020
1a6b680
Make domain comparison use lowercase. Add some more JavaDocs. #6936
poikilotherm May 29, 2020
0d7379c
Add tests for MailDomainGroup and MailDomainGroupProvider. #6936
poikilotherm May 29, 2020
39075d9
Add generator for MailDomainGroups during testing. #6936
poikilotherm May 29, 2020
fa2a544
Make MailDomainGroupServiceBean more testable by making the mail bise…
poikilotherm May 29, 2020
1b5e0e1
Add test for MailDomainGroupServiceBean and fix error when filtering …
poikilotherm May 29, 2020
69a485f
Wire the new mail domain group in the GroupServiceBean, so it will be…
poikilotherm May 29, 2020
8d2b9cb
Make MailDomainGroup and its test usable from other packages (for JSO…
poikilotherm May 29, 2020
8aaab61
Add JSON parser and printer for MailDomainGroup, to be used for REST …
poikilotherm May 29, 2020
dc0da65
Add persistence handling to create and update MailDomainGroups to the…
poikilotherm Jun 8, 2020
7c72ed5
Add first REST endpoints to get, create and update MailDomainGroups. …
poikilotherm Jun 8, 2020
694ee50
Extend exception handling at REST endpoints. Does now provide a catch…
poikilotherm Jun 9, 2020
389324b
Remove try-catch-boilerplate for mail domain group creation by using …
poikilotherm Jun 9, 2020
09c7f64
Make JsonParser fail on empty (maybe filtered invalid) domains array …
poikilotherm Jun 9, 2020
bd0f685
Add deletion endpoint for MailDomainGroup and make consistent use of …
poikilotherm Jun 9, 2020
6b65788
Add db migration for MailDomainGroups as PersistedGlobalGroup uses SI…
poikilotherm Jun 15, 2020
76ae927
Add docs for mail domain groups only, without refactoring to a groups…
poikilotherm Jun 15, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/sphinx-guides/source/admin/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This guide documents the functionality only available to superusers (such as "da
dataverses-datasets
solr-search-index
ip-groups
mail-groups
monitoring
reporting-tools-and-queries
maintenance
Expand Down
82 changes: 82 additions & 0 deletions doc/sphinx-guides/source/admin/mail-groups.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
Mail Domain Groups
==================

Groups can be defined based on the domain part of users (verified) email addresses. Email addresses that match
one or more groups configuration will add the user to them.

Within the scientific community, in many cases users will use a institutional email address for their account in a
Dataverse installation. This might offer a simple solution for building groups of people, as the domain part can be
seen as a selector for group membership.

Some use cases: installations that like to avoid Shibboleth, enable self sign up, offer multi-tenancy or can't use
:doc:`ip-groups` plus many more.

.. hint:: Please be aware that non-verified mail addresses will exclude the user even if matching. This is to avoid
privilege escalation.

Listing Mail Domain Groups
--------------------------

Mail Domain Groups can be listed with the following curl command:

``curl http://localhost:8080/api/admin/groups/domain``

Listing a specific Mail Domain Group
------------------------------------

Let's say you used "domainGroup1" as the alias of the Mail Domain Group you created below.
To list just that Mail Domain Group, you can include the alias in the curl command like this:

``curl http://localhost:8080/api/admin/groups/domain/domainGroup1``


Creating a Mail Domain Group
----------------------------

Mail Domain Groups can be created with a simple JSON file:

.. code-block:: json
:caption: domainGroup1.json
:name: domainGroup1.json

{
"name": "Users from @example.org",
"alias": "exampleorg",
"description": "Any verified user from Example Org will be included in this group.",
"domains": ["example.org"]
}

Giving a ``description`` is optional. The ``name`` will be visible in the permission UI, so be sure to pick a sensible
value.

The ``domains`` field is mandatory to be an array. This enables creation of multi-domain groups, too.

Obviously you can create as many of these groups you might like, as long as the ``alias`` is unique.

To load it into your Dataverse installation, either use a ``POST`` or ``PUT`` request (see below):

``curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/groups/domain --upload-file domainGroup1.json``

Updating a Mail Domain Group
----------------------------

Editing a group is done by replacing it. Grab your group definition like the :ref:`above example <domainGroup1.json>`,
change it as you like and ``PUT`` it into your installation:

``curl -X PUT -H 'Content-type: application/json' http://localhost:8080/api/admin/groups/domain/domainGroup1 --upload-file domainGroup1.json``

Please make sure that the alias of the group you want to change is included in the path. You also need to ensure
that this alias matches with the one given in your JSON file.

.. hint:: This is an idempotent call, so it will create the group given if not present.

Deleting a Mail Domain Group
----------------------------

To delete a Mail Domain Group with an alias of "domainGroup1", use the curl command below:

``curl -X DELETE http://localhost:8080/api/admin/groups/domain/domainGroup1``

Please note: it is not recommended to delete a Mail Domain Group that has been assigned roles. If you want to delete
a Mail Domain Group, you should first remove its permissions.

70 changes: 70 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/Groups.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroup;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroupProvider;
import edu.harvard.iq.dataverse.authorization.groups.impl.maildomain.MailDomainGroup;
import edu.harvard.iq.dataverse.authorization.groups.impl.maildomain.MailDomainGroupProvider;
import edu.harvard.iq.dataverse.authorization.groups.impl.shib.ShibGroup;
import edu.harvard.iq.dataverse.authorization.groups.impl.shib.ShibGroupProvider;
import edu.harvard.iq.dataverse.util.json.JsonParseException;
import edu.harvard.iq.dataverse.util.json.JsonParser;

import javax.ejb.Stateless;
import javax.interceptor.Interceptors;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import static edu.harvard.iq.dataverse.util.json.JsonPrinter.*;

import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
Expand All @@ -35,13 +42,15 @@ public class Groups extends AbstractApiBean {

private IpGroupProvider ipGroupPrv;
private ShibGroupProvider shibGroupPrv;
private MailDomainGroupProvider mailDomainGroupPrv;

Pattern legalGroupName = Pattern.compile("^[-_a-zA-Z0-9]+$");

@PostConstruct
void postConstruct() {
ipGroupPrv = groupSvc.getIpGroupProvider();
shibGroupPrv = groupSvc.getShibGroupProvider();
mailDomainGroupPrv = groupSvc.getMailDomainGroupProvider();
}

/**
Expand Down Expand Up @@ -208,5 +217,66 @@ public Response deleteShibGroup( @PathParam("primaryKey") String id ) {
return error(Response.Status.BAD_REQUEST, "Could not find Shibboleth group with an id of " + id);
}
}


/**
* Creates a new {@link MailDomainGroup}. The name of the group is based on the
* {@code alias:} field, but might be changed to ensure uniqueness.
* @param dto
* @return Response describing the created group or the error that prevented
* that group from being created.
*/
@POST
@Path("domain")
public Response createMailDomainGroup(JsonObject dto) throws JsonParseException {
MailDomainGroup grp = new JsonParser().parseMailDomainGroup(dto);
mailDomainGroupPrv.saveOrUpdate(Optional.empty(), grp);

return created("/groups/domain/" + grp.getPersistedGroupAlias(), json(grp) );
}

/**
* Creates or updates the {@link MailDomainGroup} named {@code groupName}.
* @param groupAlias Name of the group.
* @param dto data of the group.
* @return Response describing the created group or the error that prevented
* that group from being created.
*/
@PUT
@Path("domain/{groupAlias}")
public Response updateMailDomainGroups(@PathParam("groupAlias") String groupAlias, JsonObject dto) throws JsonParseException {
if ( groupAlias == null || groupAlias.trim().isEmpty() ) {
return badRequest("Group name cannot be empty");
}
if ( ! legalGroupName.matcher(groupAlias).matches() ) {
return badRequest("Group name can contain only letters, digits, and the chars '-' and '_'");
}

MailDomainGroup grp = new JsonParser().parseMailDomainGroup(dto);
mailDomainGroupPrv.saveOrUpdate(Optional.of(groupAlias), grp);

return created("/groups/domain/" + grp.getPersistedGroupAlias(), json(grp) );
}

@GET
@Path("domain")
public Response listMailDomainGroups() {
return ok( mailDomainGroupPrv.findGlobalGroups()
.stream().map(g->json(g)).collect(toJsonArray()) );
}

@GET
@Path("domain/{groupAlias}")
public Response getMailDomainGroup(@PathParam("groupAlias") String groupAlias) {
MailDomainGroup grp = mailDomainGroupPrv.get(groupAlias);
return (grp == null) ? notFound( "Group " + groupAlias + " not found") : ok(json(grp));
}

@DELETE
@Path("domain/{groupAlias}")
public Response deleteMailDomainGroup(@PathParam("groupAlias") String groupAlias) {
mailDomainGroupPrv.delete(groupAlias);
return ok("Group " + groupAlias + " deleted.");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package edu.harvard.iq.dataverse.api.errorhandlers;

import edu.harvard.iq.dataverse.util.json.JsonPrinter;

import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonArrayBuilder;
import javax.json.JsonObject;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.stream.Collectors;

@Provider
public class ConstraintViolationExceptionHandler implements ExceptionMapper<ConstraintViolationException> {

public class ValidationError {
private String path;
private String message;

public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}

@Override
public Response toResponse(ConstraintViolationException exception) {

List<ValidationError> errors = exception.getConstraintViolations().stream()
.map(this::toValidationError)
.collect(Collectors.toList());

return Response.status(Response.Status.BAD_REQUEST)
.entity( Json.createObjectBuilder()
.add("status", "ERROR")
.add("code", Response.Status.BAD_REQUEST.getStatusCode())
.add("message", "JPA validation constraints failed persistence. See list of violations for details.")
.add("violations", toJsonArray(errors))
.build())
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

private ValidationError toValidationError(ConstraintViolation constraintViolation) {
ValidationError error = new ValidationError();
error.setPath(constraintViolation.getPropertyPath().toString());
error.setMessage(constraintViolation.getMessage());
return error;
}

private JsonArray toJsonArray(List<ValidationError> list) {
JsonArrayBuilder builder = Json.createArrayBuilder();
list.stream()
.forEach(error -> builder.add(
Json.createObjectBuilder()
.add("path", error.getPath())
.add("message", error.getMessage())));
return builder.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package edu.harvard.iq.dataverse.api.errorhandlers;

import edu.harvard.iq.dataverse.util.json.JsonParseException;

import javax.json.Json;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Make a failing JSON parsing request appear to be a BadRequest (error code 400)
* and send a message what just failed...
*/
@Provider
public class JsonParseExceptionHandler implements ExceptionMapper<JsonParseException>{

@Context
HttpServletRequest request;

@Override
public Response toResponse(JsonParseException ex){
return Response.status(Response.Status.BAD_REQUEST)
.entity( Json.createObjectBuilder()
.add("status", "ERROR")
.add("code", Response.Status.BAD_REQUEST.getStatusCode())
.add("message", ex.getMessage())
.build())
.type(MediaType.APPLICATION_JSON_TYPE).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package edu.harvard.iq.dataverse.api.errorhandlers;

import javax.annotation.Priority;
import javax.json.Json;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Produces a generic 500 message for the API, being a fallback handler for not specially treated exceptions.
*/
@Provider
public class ThrowableHandler implements ExceptionMapper<Throwable>{

private static final Logger logger = Logger.getLogger(ThrowableHandler.class.getName());

@Context
HttpServletRequest request;

@Override
public Response toResponse(Throwable ex){
String incidentId = UUID.randomUUID().toString();
logger.log(Level.SEVERE, "Uncaught REST API exception:\n"+
" Incident: " + incidentId +"\n"+
" URL: "+getOriginalURL(request)+"\n"+
" Method: "+request.getMethod(), ex);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity( Json.createObjectBuilder()
.add("status", "ERROR")
.add("code", Response.Status.INTERNAL_SERVER_ERROR.getStatusCode())
.add("type", ex.getClass().getSimpleName())
.add("message", "Internal server error. More details available at the server logs.")
.add("incidentId", incidentId)
.build())
.type(MediaType.APPLICATION_JSON_TYPE).build();
}

private String getOriginalURL(HttpServletRequest req) {
// Rebuild the original request URL: http://stackoverflow.com/a/5212336/356408
String scheme = req.getScheme(); // http
String serverName = req.getServerName(); // hostname.com
int serverPort = req.getServerPort(); // 80
String contextPath = req.getContextPath(); // /mywebapp
String servletPath = req.getServletPath(); // /servlet/MyServlet
String pathInfo = req.getPathInfo(); // /a/b;c=123
String queryString = req.getQueryString(); // d=789

// Reconstruct original requesting URL
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(serverName);
if (serverPort != 80 && serverPort != 443) {
url.append(":").append(serverPort);
}
url.append(contextPath).append(servletPath);
if (pathInfo != null) {
url.append(pathInfo);
}
if (queryString != null) {
url.append("?").append(queryString);
}

return url.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ public void deleteAuthenticatedUser(Object pk) {
if (apiToken != null) {
em.remove(apiToken);
}
// @todo: this should be handed down to the service instead of doing it here.
ConfirmEmailData confirmEmailData = confirmEmailService.findSingleConfirmEmailDataByUser(user);
if (confirmEmailData != null) {
/**
Expand Down
Loading