From 38d541c18de122d24b1bdf9088e88dfa1d4da297 Mon Sep 17 00:00:00 2001 From: Jens Pelzetter Date: Tue, 26 May 2020 15:18:17 +0200 Subject: [PATCH] More RESTful API Former-commit-id: 81232cd19a4403a73f4a9a78500d61a9282684b7 --- ccm-core/pom.xml | 6 + .../api/CcmCoreApi.java => api/AdminApi.java} | 18 +- .../java/org/libreccm/security/GroupsApi.java | 466 ++++++++++++++++++ .../main/java/org/libreccm/security/Role.java | 2 +- .../java/org/libreccm/security/RolesApi.java | 19 + .../java/org/libreccm/security/UsersApi.java | 78 ++- pom.xml | 17 + 7 files changed, 578 insertions(+), 28 deletions(-) rename ccm-core/src/main/java/org/libreccm/{core/api/CcmCoreApi.java => api/AdminApi.java} (73%) create mode 100644 ccm-core/src/main/java/org/libreccm/security/GroupsApi.java create mode 100644 ccm-core/src/main/java/org/libreccm/security/RolesApi.java diff --git a/ccm-core/pom.xml b/ccm-core/pom.xml index 8c09637e8..eb0104aa0 100644 --- a/ccm-core/pom.xml +++ b/ccm-core/pom.xml @@ -92,6 +92,12 @@ flyway-core + + org.apache.logging.log4j diff --git a/ccm-core/src/main/java/org/libreccm/core/api/CcmCoreApi.java b/ccm-core/src/main/java/org/libreccm/api/AdminApi.java similarity index 73% rename from ccm-core/src/main/java/org/libreccm/core/api/CcmCoreApi.java rename to ccm-core/src/main/java/org/libreccm/api/AdminApi.java index e75fc3b26..9e9b7b837 100644 --- a/ccm-core/src/main/java/org/libreccm/core/api/CcmCoreApi.java +++ b/ccm-core/src/main/java/org/libreccm/api/AdminApi.java @@ -3,11 +3,10 @@ * To change this template file, choose Tools | Templates * and open the template in the editor. */ -package org.libreccm.core.api; +package org.libreccm.api; -import org.libreccm.api.CorsFilter; -import org.libreccm.api.DefaultResponseHeaders; -import org.libreccm.api.PreflightRequestFilter; +import org.libreccm.security.GroupsApi; +import org.libreccm.security.RolesApi; import org.libreccm.security.UsersApi; import java.util.HashSet; @@ -20,17 +19,22 @@ import javax.ws.rs.core.Application; * * @author Jens Pelzetter */ -@ApplicationPath("/api") -public class CcmCoreApi extends Application { +@ApplicationPath("/api/admin") +public class AdminApi extends Application { @Override public Set> getClasses() { - final Set> classes = new HashSet<>(); + classes.add(CorsFilter.class); classes.add(DefaultResponseHeaders.class); classes.add(PreflightRequestFilter.class); + + classes.add(GroupsApi.class); + classes.add(RolesApi.class); classes.add(UsersApi.class); + + return classes; } diff --git a/ccm-core/src/main/java/org/libreccm/security/GroupsApi.java b/ccm-core/src/main/java/org/libreccm/security/GroupsApi.java new file mode 100644 index 000000000..8660cc866 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/security/GroupsApi.java @@ -0,0 +1,466 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */package org.libreccm.security; + +import org.libreccm.core.CoreConstants; +import org.libreccm.core.api.ExtractedIdentifier; +import org.libreccm.core.api.IdentifierExtractor; +import org.libreccm.core.api.JsonArrayCollector; + +import java.net.URI; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.json.JsonArray; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.transaction.Transactional; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +/** + * + * @author Jens Pelzetter + */ +@RequestScoped +@Path("/groups") +public class GroupsApi { + + @Inject + private IdentifierExtractor identifierExtractor; + + @Inject + private GroupManager groupManager; + + @Inject + private GroupRepository groupRepository; + + @Inject + private RoleManager roleManager; + + @Inject + private RoleRepository roleRepository; + + @Inject + private UserManager userManager; + + @Inject + private UserRepository userRepository; + + @GET + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public JsonArray getGroups( + @QueryParam("limit") @DefaultValue("20") final int limit, + @QueryParam("offset") @DefaultValue("0") final int offset + ) { + return groupRepository + .findAll(limit, offset) + .stream() + .map(Group::buildJson) + .map(JsonObjectBuilder::build) + .collect(new JsonArrayCollector()); + } + + @GET + @Path("/{groupIdentifier}") + @Produces(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public JsonObject getGroup( + @PathParam("groupIdentifier") final String identifierParam + ) { + return findGroup(identifierParam).toJson(); + } + + @POST + @Path("/") + @Consumes(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response addGroup(final JsonObject groupData) { + if (groupData.isNull("name") + || groupData.getString("name").matches("\\s*")) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity("Name of new group is missing.") + .build(); + } + final String name = groupData.getString("name"); + + if (groupRepository.findByName(name).isPresent()) { + return Response + .status(Response.Status.CONFLICT) + .entity( + String.format( + "A group with the name %s already exists.", + name + ) + ) + .build(); + } + + final Group group = new Group(); + group.setName(name); + groupRepository.save(group); + + return Response + .status(Response.Status.CREATED) + .contentLocation( + URI.create(String.format("/api/groups/%s", group.getName())) + ) + .build(); + } + + @PUT + @Path("/{groupIdentifier}") + @Consumes(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response updateGroup( + @PathParam("groupIdentifier") final String groupIdentifier, + final JsonObject groupData + ) { + final Group group = findGroup(groupIdentifier); + + boolean updated = false; + if (!groupData.isNull("name") + && !groupData.getString("name").matches("\\s*") + && !groupData.getString("name").equals(group.getName())) { + group.setName(groupData.getString("name")); + updated = true; + } + + if (updated) { + groupRepository.save(group); + } + + return Response + .status(Response.Status.OK) + .entity( + String.format( + "Group %s updated successfully.", group.getName() + ) + ) + .build(); + } + + @DELETE + @Path("/{groupIdentifier}") + @Consumes(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response deleteGroup( + @PathParam("groupIdentifier") final String groupIdentifier + ) { + final Group group = findGroup(groupIdentifier); + final String name = group.getName(); + groupRepository.delete(group); + return Response + .ok(String.format("Group %s deleted successfully.", name)) + .build(); + } + + @GET + @Path("/{groupIdentifier}/members") + @Produces(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public JsonArray getMembers( + @PathParam("groupIdentifier") final String groupIdentifier + ) { + return findGroup(groupIdentifier) + .getMemberships() + .stream() + .map(GroupMembership::toJson) + .collect(new JsonArrayCollector()); + } + + @PUT + @Path("/{groupIdentifier}/members/{userIdentifier}") + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response addMember( + @PathParam("groupIdentifier") final String groupIdentifier, + @PathParam("userIdentifier") final String userIdentifier + ) { + final Group group = findGroup(groupIdentifier); + final User user = findUser(userIdentifier); + + groupManager.addMemberToGroup(user, group); + + return Response + .ok( + String.format( + "User %s successfully added to group %s.", + user.getName(), + group.getName() + ) + ).build(); + + } + + @DELETE + @Path("/{groupIdentifier}/members/{userIdentifier}") + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response removeMember( + @PathParam("groupIdentifier") final String groupIdentifier, + @PathParam("userIdentifier") final String userIdentifier + ) { + final Group group = findGroup(groupIdentifier); + final User user = findUser(userIdentifier); + + groupManager.removeMemberFromGroup(user, group); + + return Response + .ok() + .entity( + String.format( + "User %s successfully removed to group %s.", + user.getName(), + group.getName() + ) + ) + .build(); + } + + @GET + @Path("/{groupIdentifier}/roles") + @Produces(MediaType.APPLICATION_JSON) + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public JsonArray getRoleMemberships( + @PathParam("groupIdentifier") + final String groupIdentifier + ) { + return findGroup(groupIdentifier) + .getRoleMemberships() + .stream() + .map(RoleMembership::toJson) + .collect(new JsonArrayCollector()); + } + + @PUT + @Path("/{groupIdentifier}/groups/{roleIdentifier}") + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response addRoleMembership( + @PathParam("groupIdentifier") + final String groupIdentifier, + @PathParam("roleIdentifier") + final String roleIdentifier + ) { + final Group group = findGroup(groupIdentifier); + final Role role = findRole(roleIdentifier); + + roleManager.assignRoleToParty(role, group); + + return Response + .ok() + .entity( + String.format( + "Role %s successfully assigned to group %s.", + role.getName(), + group.getName() + ) + ) + .build(); + } + + @DELETE + @Path("/{groupIdentifier}/groups/{roleIdentifier}") + @AuthorizationRequired + @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) + @Transactional(Transactional.TxType.REQUIRED) + public Response removeRoleMembership( + @PathParam("groupIdentifier") + final String groupIdentifier, + @PathParam("roleIdentifier") + final String roleIdentifier + ) { + final Group group = findGroup(groupIdentifier); + final Role role = findRole(roleIdentifier); + + roleManager.removeRoleFromParty(role, group); + + return Response + .ok() + .entity( + String.format( + "Role %s successfully removed from group %s.", + role.getName(), + group.getName() + ) + ) + .build(); + } + + private Group findGroup(final String groupIdentifier) { + final ExtractedIdentifier identifier = identifierExtractor + .extractIdentifier(groupIdentifier); + + switch (identifier.getType()) { + case ID: + return groupRepository + .findById(Long.parseLong(identifier.getIdentifier())) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No group with ID %s found", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + case UUID: + return groupRepository + .findByUuid(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No group with UUID %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + default: + return groupRepository + .findByName(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No group with name %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + } + } + + private Role findRole(final String roleIdentifier) { + final ExtractedIdentifier identifier = identifierExtractor + .extractIdentifier(roleIdentifier); + + switch (identifier.getType()) { + case ID: + return roleRepository + .findById(Long.parseLong(identifier.getIdentifier())) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No role with ID %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + case UUID: + return roleRepository + .findByUuid(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No role with UUID %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + default: + return roleRepository + .findByName(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No role with name %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND + ) + ); + } + } + + private User findUser(final String identifierParam) { + final ExtractedIdentifier identifier = identifierExtractor + .extractIdentifier(identifierParam); + + switch (identifier.getType()) { + case ID: + return userRepository + .findById(Long.parseLong(identifier.getIdentifier())) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No user with ID %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND) + ); + case UUID: + return userRepository + .findByUuid(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No user with UUID %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND) + ); + default: + return userRepository + .findByName(identifier.getIdentifier()) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "No user with name %s found.", + identifier.getIdentifier() + ), + Response.Status.NOT_FOUND) + ); + } + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/security/Role.java b/ccm-core/src/main/java/org/libreccm/security/Role.java index c1b56f900..2a98a695e 100644 --- a/ccm-core/src/main/java/org/libreccm/security/Role.java +++ b/ccm-core/src/main/java/org/libreccm/security/Role.java @@ -22,7 +22,6 @@ import com.fasterxml.jackson.annotation.JsonIdentityInfo; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.ObjectIdGenerators; import org.hibernate.search.annotations.Field; -import org.hibernate.validator.constraints.NotBlank; import org.libreccm.l10n.LocalizedString; import org.libreccm.workflow.TaskAssignment; @@ -57,6 +56,7 @@ import javax.persistence.NamedQuery; import javax.persistence.OneToMany; import javax.persistence.OrderBy; import javax.persistence.Table; +import javax.validation.constraints.NotBlank; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; diff --git a/ccm-core/src/main/java/org/libreccm/security/RolesApi.java b/ccm-core/src/main/java/org/libreccm/security/RolesApi.java new file mode 100644 index 000000000..33c680603 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/security/RolesApi.java @@ -0,0 +1,19 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package org.libreccm.security; + +import javax.enterprise.context.RequestScoped; +import javax.ws.rs.Path; + +/** + * + * @author Jens Pelzetter + */ +@RequestScoped +@Path("/roles") +public class RolesApi { + +} diff --git a/ccm-core/src/main/java/org/libreccm/security/UsersApi.java b/ccm-core/src/main/java/org/libreccm/security/UsersApi.java index 9f437438a..2d433da28 100644 --- a/ccm-core/src/main/java/org/libreccm/security/UsersApi.java +++ b/ccm-core/src/main/java/org/libreccm/security/UsersApi.java @@ -46,6 +46,8 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; /** + * Provides RESTful API endpoints for managing users. Access to all endpoints + * defined by this class requires admin privileges. * * @author Jens Pelzetter */ @@ -74,6 +76,14 @@ public class UsersApi { @Inject private UserRepository userRepository; + /** + * Retrieves all users. + * + * @param limit How many users should be retrieved? + * @param offset The first user to retrieve + * + * @return A JSON array with the all users. + */ @GET @Path("/") @Produces(MediaType.APPLICATION_JSON) @@ -92,6 +102,13 @@ public class UsersApi { .collect(new JsonArrayCollector()); } + /** + * Retrieves a specific user. + * + * @param identifierParam The identifier for the user. + * + * @return A JSON representation of the user. + */ @GET @Path("/{userIdentifier}") @Produces(MediaType.APPLICATION_JSON) @@ -104,6 +121,19 @@ public class UsersApi { return findUser(identifierParam).toJson(); } + /** + * Creates a new user. + * + * @param userData The data from which the user is generated. + * + * @return A HTTP response indicating the success or failure of the user + * generation. A 201 (Created) response code is send if the user was + * created successfully together with the URL of the new user. If + * the provided data does not contain a required value a response + * with the 400 (Bad Request) status code is returned. If a user + * with the name provided by the user data already exists a response + * with a 409 (Conflict) response is returned. + */ @POST @Path("/") @Consumes(MediaType.APPLICATION_JSON) @@ -111,7 +141,6 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public Response addUser(final JsonObject userData) { - final String givenName; if (userData.isNull("givenName")) { givenName = null; @@ -188,6 +217,16 @@ public class UsersApi { .build(); } + /** + * Updates a user. + * + * @param userIdentifier The identifier of the user to update. + * @param userData The updated of the user. Please note that the name + * of a user can't be updated. If the user data + * contains a value for this property it is ignored. + * + * @return A 200 (OK) response if the user is succesfully updated. + */ @PUT @Path("/{userIdentifier}") @Consumes(MediaType.APPLICATION_JSON) @@ -195,8 +234,7 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public Response updateUser( - @PathParam("userIdentifier") - final String userIdentifier, + @PathParam("userIdentifier") final String userIdentifier, final JsonObject userData ) { final User user = findUser(userIdentifier); @@ -236,6 +274,12 @@ public class UsersApi { .build(); } + /** + * Deletes a user. + * + * @param userIdentifier The identifier of the user to delete. + * @return A 200 (OK) response if the user was deleted successfully. + */ @DELETE @Path("/{userIdentifier}") @Consumes(MediaType.APPLICATION_JSON) @@ -243,8 +287,7 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public Response deleteUser( - @PathParam("userIdentifier") - final String userIdentifier + @PathParam("userIdentifier") final String userIdentifier ) { final User user = findUser(userIdentifier); final String name = user.getName(); @@ -262,8 +305,7 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public JsonArray getGroupMemberships( - @PathParam("userIdentifier") - final String userIdentifier + @PathParam("userIdentifier") final String userIdentifier ) { return findUser(userIdentifier) .getGroupMemberships() @@ -278,10 +320,8 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public Response addGroupMembership( - @PathParam("userIdentifier") - final String userIdentifier, - @PathParam("groupIdentifier") - final String groupIdentifier + @PathParam("userIdentifier") final String userIdentifier, + @PathParam("groupIdentifier") final String groupIdentifier ) { final User user = findUser(userIdentifier); final Group group = findGroup(groupIdentifier); @@ -306,10 +346,8 @@ public class UsersApi { @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN) @Transactional(Transactional.TxType.REQUIRED) public Response removeGroupMembership( - @PathParam("userIdentifier") - final String userIdentifier, - @PathParam("groupIdentifier") - final String groupIdentifier + @PathParam("userIdentifier") final String userIdentifier, + @PathParam("groupIdentifier") final String groupIdentifier ) { final User user = findUser(userIdentifier); final Group group = findGroup(groupIdentifier); @@ -320,7 +358,7 @@ public class UsersApi { .ok() .entity( String.format( - "User %s successfully removed to group %s.", + "User %s successfully removed from group %s.", user.getName(), group.getName() ) @@ -358,9 +396,9 @@ public class UsersApi { ) { final User user = findUser(userIdentifier); final Role role = findRole(roleIdentifier); - + roleManager.assignRoleToParty(role, user); - + return Response .ok() .entity( @@ -386,9 +424,9 @@ public class UsersApi { ) { final User user = findUser(userIdentifier); final Role role = findRole(roleIdentifier); - + roleManager.removeRoleFromParty(role, user); - + return Response .ok() .entity( diff --git a/pom.xml b/pom.xml index f3cbc5039..614a3152d 100644 --- a/pom.xml +++ b/pom.xml @@ -720,6 +720,23 @@ woodstox-core-asl 4.4.1 + + + + io.swagger.core.v3 + swagger-jaxrs2 + 2.1.2 + + + io.swagger.core.v3 + swagger-jaxrs2-servlet-initializer-v2 + 2.1.2 + + + io.swagger.core.v3 + swagger-annotations + 2.1.2 +