Skip to content

Commit

Permalink
Merge pull request #6974 from poikilotherm/6936-domaingroups
Browse files Browse the repository at this point in the history
6936 Implement email domain based groups
  • Loading branch information
kcondon authored Jul 1, 2020
2 parents ce86fed + 76ae927 commit c79ce40
Show file tree
Hide file tree
Showing 21 changed files with 1,182 additions and 22 deletions.
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

0 comments on commit c79ce40

Please sign in to comment.