diff --git a/scm-core/src/main/java/sonia/scm/repository/Permission.java b/scm-core/src/main/java/sonia/scm/repository/Permission.java index 8b42d68a1f..20cdc83cef 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Permission.java +++ b/scm-core/src/main/java/sonia/scm/repository/Permission.java @@ -1,32 +1,32 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * +/* + Copyright (c) 2010, Sebastian Sdorra + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of SCM-Manager; nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + http://bitbucket.org/sdorra/scm-manager + */ @@ -56,10 +56,11 @@ import java.io.Serializable; public class Permission implements PermissionObject, Serializable { - /** Field description */ private static final long serialVersionUID = -2915175031430884040L; - //~--- constructors --------------------------------------------------------- + private boolean groupPermission = false; + private String name; + private PermissionType type = PermissionType.READ; /** * Constructs a new {@link Permission}. @@ -152,12 +153,7 @@ public class Permission implements PermissionObject, Serializable return Objects.hashCode(name, type, groupPermission); } - /** - * Method description - * - * - * @return - */ + @Override public String toString() { @@ -241,15 +237,4 @@ public class Permission implements PermissionObject, Serializable { this.type = type; } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private boolean groupPermission = false; - - /** Field description */ - private String name; - - /** Field description */ - private PermissionType type = PermissionType.READ; } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 1e439a6a16..afb1417670 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -16,6 +16,7 @@ public class VndMediaType { public static final String USER = PREFIX + "user" + SUFFIX; public static final String GROUP = PREFIX + "group" + SUFFIX; public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; + public static final String PERMISSION = PREFIX + "permission" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX; public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index 66bb574e38..84268f80c6 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -297,7 +297,25 @@ ${jersey-client.version} test - + + + org.junit.jupiter + junit-jupiter-api + 5.2.0 + test + + + org.junit.jupiter + junit-jupiter-params + 5.2.0 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.2.0 + test + diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java index 08d6b984d2..fef7fc96c4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java @@ -1,30 +1,30 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. 2. Redistributions in - * binary form must reproduce the above copyright notice, this list of - * conditions and the following disclaimer in the documentation and/or other - * materials provided with the distribution. 3. Neither the name of SCM-Manager; - * nor the names of its contributors may be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * +/* + Copyright (c) 2010, Sebastian Sdorra All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. 2. Redistributions in + binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. 3. Neither the name of SCM-Manager; + nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + http://bitbucket.org/sdorra/scm-manager + */ @@ -36,11 +36,11 @@ package sonia.scm.api.rest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -//~--- JDK imports ------------------------------------------------------------ - import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; +//~--- JDK imports ------------------------------------------------------------ + /** * * @author Sebastian Sdorra @@ -56,14 +56,14 @@ public class StatusExceptionMapper private static final Logger logger = LoggerFactory.getLogger(StatusExceptionMapper.class); - //~--- constructors --------------------------------------------------------- + private final Response.Status status; + private final Class type; /** - * Constructs ... + * Map an Exception to a HTTP Response * - * - * @param type - * @param status + * @param type the exception class + * @param status the http status to be mapped */ public StatusExceptionMapper(Class type, Response.Status status) { @@ -71,15 +71,12 @@ public class StatusExceptionMapper this.status = status; } - //~--- methods -------------------------------------------------------------- - /** - * Method description + * provide a http responses from an exception * + * @param exception the thrown exception * - * @param exception - * - * @return + * @return the http response with the exception presentation */ @Override public Response toResponse(E exception) @@ -95,12 +92,4 @@ public class StatusExceptionMapper return Response.status(status).build(); } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final Response.Status status; - - /** Field description */ - private final Class type; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index 0ac6929689..0605d943e7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -25,6 +25,8 @@ public class MapperModule extends AbstractModule { bind(RepositoryTypeCollectionToDtoMapper.class); bind(BranchToBranchDtoMapper.class).to(Mappers.getMapper(BranchToBranchDtoMapper.class).getClass()); + bind(PermissionDtoToPermissionMapper.class).to(Mappers.getMapper(PermissionDtoToPermissionMapper.class).getClass()); + bind(PermissionToPermissionDtoMapper.class).to(Mappers.getMapper(PermissionToPermissionDtoMapper.class).getClass()); bind(UriInfoStore.class).in(ServletScopes.REQUEST); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NotFoundExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NotFoundExceptionMapper.java new file mode 100644 index 0000000000..04264a7629 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NotFoundExceptionMapper.java @@ -0,0 +1,49 @@ +/* + Copyright (c) 2014, Sebastian Sdorra All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. 2. Redistributions in + binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. 3. Neither the name of SCM-Manager; + nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + http://bitbucket.org/sdorra/scm-manager + + */ + + +package sonia.scm.api.v2.resources; + + +import sonia.scm.NotFoundException; +import sonia.scm.api.rest.StatusExceptionMapper; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +/** + * @since 2.0.0 + */ +@Provider +public class NotFoundExceptionMapper extends StatusExceptionMapper { + + public NotFoundExceptionMapper() { + super(NotFoundException.class, Response.Status.NOT_FOUND); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java deleted file mode 100644 index 6c4b52c16d..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java +++ /dev/null @@ -1,18 +0,0 @@ -package sonia.scm.api.v2.resources; - -import javax.ws.rs.DefaultValue; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.QueryParam; -import javax.ws.rs.core.Response; - -public class PermissionCollectionResource { - @GET - @Path("") - public Response getAll(@DefaultValue("0") @QueryParam("page") int page, - @DefaultValue("10") @QueryParam("pageSize") int pageSize, - @QueryParam("sortBy") String sortBy, - @DefaultValue("false") @QueryParam("desc") boolean desc) { - throw new UnsupportedOperationException(); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java new file mode 100644 index 0000000000..b184bc3934 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java @@ -0,0 +1,35 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter @Setter @ToString +public class PermissionDto extends HalRepresentation { + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String name; + + /** + * the type can be replaced with a dto enum if the mapstruct 1.3.0 is stable + * the mapstruct has a Bug on mapping enums in the 1.2.0-Final Version + * + * see the bug fix: https://github.com/mapstruct/mapstruct/commit/460e87eef6eb71245b387fdb0509c726676a8e19 + * + **/ + @JsonInclude(JsonInclude.Include.NON_NULL) + private String type ; + + + private boolean groupPermission = false; + + + @Override + @SuppressWarnings("squid:S1185") // We want to have this method available in this package + protected HalRepresentation add(Links links) { + return super.add(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java new file mode 100644 index 0000000000..1e90c23aa7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java @@ -0,0 +1,21 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.Permission; + +@Mapper +public abstract class PermissionDtoToPermissionMapper { + + public abstract Permission map(PermissionDto permissionDto); + + /** + * this method is needed to modify an existing permission object + * + * @param target the target permission + * @param permissionDto the source dto + * @return the mapped target permission object + */ + public abstract void modify(@MappingTarget Permission target, PermissionDto permissionDto); + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java index cd1e970e43..d874489bad 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java @@ -1,20 +1,231 @@ package sonia.scm.api.v2.resources; -import javax.inject.Inject; -import javax.inject.Provider; -import javax.ws.rs.Path; +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.ResponseHeader; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import sonia.scm.AlreadyExistsException; +import sonia.scm.NotFoundException; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Permission; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryNotFoundException; +import sonia.scm.web.VndMediaType; +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +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.core.Response; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j public class PermissionRootResource { - private final Provider permissionCollectionResource; + private PermissionDtoToPermissionMapper dtoToModelMapper; + private PermissionToPermissionDtoMapper modelToDtoMapper; + private ResourceLinks resourceLinks; + private final RepositoryManager manager; + @Inject - public PermissionRootResource(Provider permissionCollectionResource) { - this.permissionCollectionResource = permissionCollectionResource; + public PermissionRootResource(PermissionDtoToPermissionMapper dtoToModelMapper, PermissionToPermissionDtoMapper modelToDtoMapper, ResourceLinks resourceLinks, RepositoryManager manager) { + this.dtoToModelMapper = dtoToModelMapper; + this.modelToDtoMapper = modelToDtoMapper; + this.resourceLinks = resourceLinks; + this.manager = manager; } + + /** + * Adds a new permission to the user or group managed by the repository + * + * @param permission permission to add + * @return a web response with the status code 201 and the url to GET the added permission + */ + @POST + @StatusCodes({ + @ResponseCode(code = 201, condition = "creates", additionalHeaders = { + @ResponseHeader(name = "Location", description = "uri of the created permission") + }), + @ResponseCode(code = 500, condition = "internal server error"), + @ResponseCode(code = 404, condition = "not found"), + @ResponseCode(code = 409, condition = "conflict") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + @Consumes(VndMediaType.PERMISSION) + public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, PermissionDto permission) throws Exception { + log.info("try to add new permission: {}", permission); + Repository repository = load(namespace, name); + checkPermissionAlreadyExists(permission, repository); + repository.getPermissions().add(dtoToModelMapper.map(permission)); + manager.modify(repository); + return Response.created(URI.create(resourceLinks.permission().self(namespace, name, permission.getName()))).build(); + } + + + /** + * Get the searched permission with permission name related to a repository + * + * @param namespace the repository namespace + * @param name the repository name + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the repository does not exists + */ + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "ok"), + @ResponseCode(code = 404, condition = "not found"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.PERMISSION) + @TypeHint(PermissionDto.class) + @Path("{permission-name}") + public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName) throws NotFoundException { + Repository repository = load(namespace, name); + return Response.ok( + repository.getPermissions() + .stream() + .filter(permission -> permissionName.equals(permission.getName())) + .map(permission -> modelToDtoMapper.map(permission, new NamespaceAndName(repository.getNamespace(), repository.getName()))) + .findFirst() + .orElseThrow(NotFoundException::new) + ).build(); + } + + + /** + * Get all permissions related to a repository + * + * @param namespace the repository namespace + * @param name the repository name + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the repository does not exists + */ + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "ok"), + @ResponseCode(code = 404, condition = "not found"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.PERMISSION) + @TypeHint(PermissionDto.class) @Path("") - public PermissionCollectionResource getPermissionCollectionResource() { - return permissionCollectionResource.get(); + public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws RepositoryNotFoundException { + Repository repository = load(namespace, name); + List permissionDtoList = repository.getPermissions() + .stream() + .map(per -> modelToDtoMapper.map(per, new NamespaceAndName(repository.getNamespace(), repository.getName()))) + .collect(Collectors.toList()); + return Response.ok(permissionDtoList).build(); + } + + + /** + * Update a permission to the user or group managed by the repository + * + * @param permission permission to modify + * @param permissionName permission to modify + * @return a web response with the status code 204 + */ + @PUT + @StatusCodes({ + @ResponseCode(code = 204, condition = "update success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + @Consumes(VndMediaType.PERMISSION) + @Path("{permission-name}") + public Response update(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("permission-name") String permissionName, + PermissionDto permission) throws Exception { + log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission); + Repository repository = load(namespace, name); + Permission existingPermission = repository.getPermissions() + .stream() + .filter(perm -> StringUtils.isNotBlank(perm.getName()) && perm.getName().equals(permissionName)) + .findFirst() + .orElseThrow(() -> new NotFoundException()); + dtoToModelMapper.modify(existingPermission, permission); + manager.modify(repository); + log.info("the permission with name: {} is updated.", permissionName); + return Response.noContent().build(); + } + + /** + * Update a permission to the user or group managed by the repository + * + * @param permissionName permission to delete + * @return a web response with the status code 204 + */ + @DELETE + @StatusCodes({ + @ResponseCode(code = 204, condition = "delete success or nothing to delete"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + @Path("{permission-name}") + public Response delete(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("permission-name") String permissionName) throws Exception { + log.info("try to delete the permission with name: {}.", permissionName); + Repository repository = load(namespace, name); + repository.getPermissions() + .stream() + .filter(perm -> StringUtils.isNotBlank(perm.getName()) && perm.getName().equals(permissionName)) + .findFirst() + .ifPresent(p -> repository.getPermissions().remove(p)) + ; + manager.modify(repository); + log.info("the permission with name: {} is updated.", permissionName); + return Response.noContent().build(); + } + + + /** + * check if the actual user is permitted to manage the repository permissions + * return the repository if the user is permitted + * + * @param namespace the repository namespace + * @param name the repository name + * @return the repository if the user is permitted + * @throws RepositoryNotFoundException if the repository does not exists + */ + private Repository load(String namespace, String name) throws RepositoryNotFoundException { + return Optional.ofNullable(manager.get(new NamespaceAndName(namespace, name))) + .orElseThrow(() -> new RepositoryNotFoundException(name)); + } + + /** + * check if the permission already exists in the repository + * + * @param permission the searched permission + * @param repository the repository to be inspected + * @throws AlreadyExistsException if the permission already exists in the repository + */ + private void checkPermissionAlreadyExists(PermissionDto permission, Repository repository) throws AlreadyExistsException { + boolean isPermissionAlreadyExist = repository.getPermissions() + .stream() + .anyMatch(p -> p.getName().equals(permission.getName())); + if (isPermissionAlreadyExist) { + throw new AlreadyExistsException(); + } } } + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java new file mode 100644 index 0000000000..215a282767 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java @@ -0,0 +1,41 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Links; +import org.mapstruct.AfterMapping; +import org.mapstruct.Context; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Permission; + +import javax.inject.Inject; + +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; + +@Mapper +public abstract class PermissionToPermissionDtoMapper { + + @Inject + private ResourceLinks resourceLinks; + + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract PermissionDto map(Permission permission, @Context NamespaceAndName namespaceAndName); + + /** + * Add the self, update and delete links. + * + * @param target the mapped dto + * @param namespaceAndName the repository namespace and name + */ + @AfterMapping + void appendLinks(@MappingTarget PermissionDto target, @Context NamespaceAndName namespaceAndName) { + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.permission().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getName())); + linksBuilder.single(link("update", resourceLinks.permission().update(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getName()))); + linksBuilder.single(link("delete", resourceLinks.permission().delete(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getName()))); + target.add(linksBuilder.build()); + } +} + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index f46bc22cdf..0263b81048 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -36,7 +36,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper TEST_PERMISSIONS = Lists + .newArrayList( + new Permission("user_write", PermissionType.WRITE, false), + new Permission("user_read", PermissionType.READ, false), + new Permission("user_owner", PermissionType.OWNER, false), + new Permission("group_read", PermissionType.READ, true), + new Permission("group_write", PermissionType.WRITE, true), + new Permission("group_owner", PermissionType.OWNER, true) + ); + private final ExpectedRequest requestGETAllPermissions = new ExpectedRequest() + .description("GET all permissions") + .method("GET") + .path(PATH_OF_ALL_PERMISSIONS); + private final ExpectedRequest requestPOSTPermission = new ExpectedRequest() + .description("create new permission") + .method("POST") + .content(PERMISSION_TEST_PAYLOAD) + .path(PATH_OF_ALL_PERMISSIONS); + private final ExpectedRequest requestGETPermission = new ExpectedRequest() + .description("GET permission") + .method("GET") + .path(PATH_OF_ONE_PERMISSION); + private final ExpectedRequest requestDELETEPermission = new ExpectedRequest() + .description("delete permission") + .method("DELETE") + .path(PATH_OF_ONE_PERMISSION); + private final ExpectedRequest requestPUTPermission = new ExpectedRequest() + .description("update permission") + .method("PUT") + .content(PERMISSION_TEST_PAYLOAD) + .path(PATH_OF_ONE_PERMISSION); + + private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); + + @Mock + private RepositoryManager repositoryManager; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private PermissionToPermissionDtoMapperImpl permissionToPermissionDtoMapper; + + @InjectMocks + private PermissionDtoToPermissionMapperImpl permissionDtoToPermissionMapper; + + private PermissionRootResource permissionRootResource; + + @BeforeEach + @Before + public void prepareEnvironment() { + initMocks(this); + permissionRootResource = new PermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, resourceLinks, repositoryManager); + RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider + .of(new RepositoryResource(null, null, null, null, null, null, null,null, MockProvider.of(permissionRootResource))), null); + dispatcher.getRegistry().addSingletonResource(repositoryRootResource); + dispatcher.getProviderFactory().registerProvider(NotFoundExceptionMapper.class); + dispatcher.getProviderFactory().registerProvider(NotFoundExceptionMapper.class); + dispatcher.getProviderFactory().registerProvider(AlreadyExistsExceptionMapper.class); + dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class); + } + + @TestFactory + @DisplayName("test endpoints on missing repository and user is Admin") + Stream missedRepositoryTestFactory() { + return createDynamicTestsToAssertResponses( + requestGETAllPermissions.expectedResponseStatus(404), + requestGETPermission.expectedResponseStatus(404), + requestPOSTPermission.expectedResponseStatus(404), + requestDELETEPermission.expectedResponseStatus(404), + requestPUTPermission.expectedResponseStatus(404)); + } + + @TestFactory + @DisplayName("test endpoints on missing permission and user is Admin") + Stream missedPermissionTestFactory() { + authorizedUserHasARepository(); + return createDynamicTestsToAssertResponses( + requestGETPermission.expectedResponseStatus(404), + requestPOSTPermission.expectedResponseStatus(201), + requestGETAllPermissions.expectedResponseStatus(200), + requestDELETEPermission.expectedResponseStatus(204), + requestPUTPermission.expectedResponseStatus(404)); + } + + @TestFactory + @DisplayName("test endpoints on missing permission and user is not Admin") + Stream missedPermissionUserForbiddenTestFactory() { + Repository mockRepository = mock(Repository.class); + when(mockRepository.getId()).thenReturn(REPOSITORY_NAME); + doThrow(AuthorizationException.class).when(repositoryManager).get(any(NamespaceAndName.class)); + return createDynamicTestsToAssertResponses( + requestGETPermission.expectedResponseStatus(403), + requestPOSTPermission.expectedResponseStatus(403), + requestGETAllPermissions.expectedResponseStatus(403), + requestDELETEPermission.expectedResponseStatus(403), + requestPUTPermission.expectedResponseStatus(403)); + } + + @Test + public void shouldGetAllPermissions() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + assertGettingExpectedPermissions(ImmutableList.copyOf(TEST_PERMISSIONS)); + } + + @Test + public void shouldGetPermissionByName() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission expectedPermission = TEST_PERMISSIONS.get(0); + assertExpectedRequest(requestGETPermission + .expectedResponseStatus(200) + .path(PATH_OF_ALL_PERMISSIONS + expectedPermission.getName()) + .responseValidator((response) -> { + String body = response.getContentAsString(); + ObjectMapper mapper = new ObjectMapper(); + try { + PermissionDto actualPermissionDto = mapper.readValue(body, PermissionDto.class); + assertThat(actualPermissionDto) + .as("response payload match permission object model") + .isEqualToComparingFieldByFieldRecursively(getExpectedPermissionDto(expectedPermission)) + ; + } catch (IOException e) { + fail(); + } + }) + ); + } + + @Test + public void shouldGetCreatedPermissions() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission newPermission = new Permission("new_group_perm", PermissionType.WRITE, true); + ArrayList permissions = Lists.newArrayList(TEST_PERMISSIONS); + permissions.add(newPermission); + ImmutableList expectedPermissions = ImmutableList.copyOf(permissions); + assertExpectedRequest(requestPOSTPermission + .content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : true}") + .expectedResponseStatus(201) + .responseValidator(response -> assertThat(response.getContentAsString()) + .as("POST response has no body") + .isBlank()) + ); + assertGettingExpectedPermissions(expectedPermissions); + } + + @Test + public void shouldNotAddExistingPermission() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission newPermission = TEST_PERMISSIONS.get(0); + assertExpectedRequest(requestPOSTPermission + .content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : true}") + .expectedResponseStatus(409) + ); + } + + @Test + public void shouldGetUpdatedPermissions() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission modifiedPermission = TEST_PERMISSIONS.get(0); + // modify the type to owner + modifiedPermission.setType(PermissionType.OWNER); + ImmutableList expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS); + assertExpectedRequest(requestPUTPermission + .content("{\"name\" : \"" + modifiedPermission.getName() + "\" , \"type\" : \"OWNER\" , \"groupPermission\" : false}") + .path(PATH_OF_ALL_PERMISSIONS + modifiedPermission.getName()) + .expectedResponseStatus(204) + .responseValidator(response -> assertThat(response.getContentAsString()) + .as("PUT response has no body") + .isBlank()) + ); + assertGettingExpectedPermissions(expectedPermissions); + } + + + @Test + public void shouldDeletePermissions() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission deletedPermission = TEST_PERMISSIONS.get(0); + ImmutableList expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); + assertExpectedRequest(requestDELETEPermission + .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) + .expectedResponseStatus(204) + .responseValidator(response -> assertThat(response.getContentAsString()) + .as("DELETE response has no body") + .isBlank()) + ); + assertGettingExpectedPermissions(expectedPermissions); + } + + @Test + public void deletingNotExistingPermissionShouldProcess() throws URISyntaxException { + authorizedUserHasARepositoryWithPermissions(TEST_PERMISSIONS); + Permission deletedPermission = TEST_PERMISSIONS.get(0); + ImmutableList expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); + assertExpectedRequest(requestDELETEPermission + .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) + .expectedResponseStatus(204) + .responseValidator(response -> assertThat(response.getContentAsString()) + .as("DELETE response has no body") + .isBlank()) + ); + assertGettingExpectedPermissions(expectedPermissions); + assertExpectedRequest(requestDELETEPermission + .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) + .expectedResponseStatus(204) + .responseValidator(response -> assertThat(response.getContentAsString()) + .as("DELETE response has no body") + .isBlank()) + ); + assertGettingExpectedPermissions(expectedPermissions); + } + + private void assertGettingExpectedPermissions(ImmutableList expectedPermissions) throws URISyntaxException { + assertExpectedRequest(requestGETAllPermissions + .expectedResponseStatus(200) + .responseValidator((response) -> { + String body = response.getContentAsString(); + ObjectMapper mapper = new ObjectMapper(); + try { + List actualPermissionDtos = mapper.readValue(body, new TypeReference>() { + }); + assertThat(actualPermissionDtos) + .as("response payload match permission object models") + .hasSize(expectedPermissions.size()) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(getExpectedPermissionDtos(Lists.newArrayList(expectedPermissions))) + ; + } catch (IOException e) { + fail(); + } + }) + ); + } + + private PermissionDto[] getExpectedPermissionDtos(ArrayList permissions) { + return permissions + .stream() + .map(this::getExpectedPermissionDto) + .toArray(PermissionDto[]::new); + } + + private PermissionDto getExpectedPermissionDto(Permission permission) { + PermissionDto result = new PermissionDto(); + result.setName(permission.getName()); + result.setGroupPermission(permission.isGroupPermission()); + result.setType(permission.getType().name()); + String permissionHref = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS + permission.getName(); + result.add(linkingTo() + .self(permissionHref) + .single(link("update", permissionHref)) + .single(link("delete", permissionHref)) + .build()); + return result; + } + + private Repository authorizedUserHasARepository() { + Repository mockRepository = mock(Repository.class); + when(mockRepository.getId()).thenReturn(REPOSITORY_NAME); + when(mockRepository.getNamespace()).thenReturn(REPOSITORY_NAMESPACE); + when(mockRepository.getName()).thenReturn(REPOSITORY_NAME); + when(repositoryManager.get(any(NamespaceAndName.class))).thenReturn(mockRepository); + return mockRepository; + } + + private void authorizedUserHasARepositoryWithPermissions(ArrayList permissions) { + when(authorizedUserHasARepository().getPermissions()).thenReturn(permissions); + } + + private Stream createDynamicTestsToAssertResponses(ExpectedRequest... expectedRequests) { + return Stream.of(expectedRequests) + .map(entry -> dynamicTest("the endpoint " + entry.description + " should return the status code " + entry.expectedResponseStatus, () -> assertExpectedRequest(entry))); + } + + private MockHttpResponse assertExpectedRequest(ExpectedRequest entry) throws URISyntaxException { + MockHttpResponse response = new MockHttpResponse(); + HttpRequest request = null; + request = MockHttpRequest + .create(entry.method, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + entry.path) + .content(entry.content) + .contentType(VndMediaType.PERMISSION); + dispatcher.invoke(request, response); + log.info("Test the Request :{}", entry); + assertThat(response.getStatus()) + .as("assert status code") + .isEqualTo(entry.expectedResponseStatus); + if (entry.responseValidator != null) { + entry.responseValidator.accept(response); + } + return response; + } + + @ToString + public class ExpectedRequest { + private String description; + private String method; + private String path; + private int expectedResponseStatus; + private byte[] content = new byte[]{}; + private Consumer responseValidator; + + public ExpectedRequest description(String description) { + this.description = description; + return this; + } + + public ExpectedRequest method(String method) { + this.method = method; + return this; + } + + public ExpectedRequest path(String path) { + this.path = path; + return this; + } + + public ExpectedRequest content(String content) { + if (content != null) { + this.content = content.getBytes(); + } + return this; + } + + public ExpectedRequest expectedResponseStatus(int expectedResponseStatus) { + this.expectedResponseStatus = expectedResponseStatus; + return this; + } + + public ExpectedRequest responseValidator(Consumer responseValidator) { + this.responseValidator = responseValidator; + return this; + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 882d754329..6df5ac1a7a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -23,7 +23,7 @@ public class ResourceLinksMock { when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); when(resourceLinks.changeset()).thenReturn(new ResourceLinks.ChangesetLinks(uriInfo)); when(resourceLinks.source()).thenReturn(new ResourceLinks.SourceLinks(uriInfo)); - when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo)); + when(resourceLinks.permission()).thenReturn(new ResourceLinks.PermissionLinks(uriInfo)); when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo)); when(resourceLinks.branch()).thenReturn(new ResourceLinks.BranchLinks(uriInfo)); when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo));