From 2000730c8d62cbbef28c79c59f5f04b4e9bd4086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 16 Sep 2020 07:32:16 +0200 Subject: [PATCH] Add REST endpoint for namespace permissions --- .../NamespacePermissionResource.java | 338 ++++++++++++++++++ .../api/v2/resources/NamespaceResource.java | 9 +- .../NamespaceToNamespaceDtoMapper.java | 20 +- ...sitoryPermissionCollectionToDtoMapper.java | 22 +- ...issionToRepositoryPermissionDtoMapper.java | 29 +- .../scm/api/v2/resources/ResourceLinks.java | 36 ++ .../lifecycle/modules/ScmServletModule.java | 3 + .../resources/NamespaceRootResourceTest.java | 333 +++++++++++++++-- .../api/v2/resources/ResourceLinksMock.java | 1 + 9 files changed, 744 insertions(+), 47 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java new file mode 100644 index 0000000000..6ec97c282b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java @@ -0,0 +1,338 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.NotFoundException; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; +import sonia.scm.repository.NamespacePermissions; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.validation.Valid; +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.Collection; +import java.util.Optional; +import java.util.function.Predicate; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; + +@Slf4j +public class NamespacePermissionResource { + + private RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper; + private RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper; + private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; + private ResourceLinks resourceLinks; + private final NamespaceManager manager; + + @Inject + public NamespacePermissionResource( + RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper, + RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper, + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper, + ResourceLinks resourceLinks, + NamespaceManager manager) { + this.dtoToModelMapper = dtoToModelMapper; + this.modelToDtoMapper = modelToDtoMapper; + this.repositoryPermissionCollectionToDtoMapper = repositoryPermissionCollectionToDtoMapper; + this.resourceLinks = resourceLinks; + this.manager = manager; + } + + /** + * Adds a new namespace permission for the user or group + * + * @param permission permission to add + * @return a web response with the status code 201 and the url to GET the added permission + */ + @POST + @Path("") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Create namespace-specific permission", description = "Adds a new permission to the namespace for the user or group.", tags = {"Namespace", "Permissions"}) + @ApiResponse( + responseCode = "201", + description = "creates", + headers = @Header( + name = "Location", + description = "uri of the created permission", + schema = @Schema(type = "string") + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "409", description = "conflict") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response create(@PathParam("namespace") String namespaceName, @Valid RepositoryPermissionDto permission) { + log.info("try to add new permission: {}", permission); + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionWrite().check(); + checkPermissionAlreadyExists(permission, namespace); + namespace.addPermission(dtoToModelMapper.map(permission)); + manager.modify(namespace); + String urlPermissionName = modelToDtoMapper.getUrlPermissionName(permission); + return Response.created(URI.create(resourceLinks.namespacePermission().self(namespaceName, urlPermissionName))).build(); + } + + /** + * Get the searched permission with permission name related to a namespace + * + * @param namespaceName the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace or the permission does not exists + */ + @GET + @Path("{permission-name}") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Get single repository-specific permission", description = "Get the searched permission with permission name related to a repository.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public RepositoryPermissionDto get(@PathParam("namespace") String namespaceName, @PathParam("permission-name") String permissionName) { + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionRead().check(); + return + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .map(permission -> modelToDtoMapper.map(permission, namespace)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + } + + /** + * Get all permissions related to a namespace + * + * @param namespaceMame the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace does not exists + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "List of namespace-specific permissions", description = "Get all permissions related to a namespace.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public HalRepresentation getAll(@PathParam("namespace") String namespaceMame) { + Namespace namespace = load(namespaceMame); + NamespacePermissions.permissionRead().check(); + return repositoryPermissionCollectionToDtoMapper.map(namespace); + } + + /** + * Update a permission to the user or group managed by the repository + * ignore the user input for groupPermission and take it from the path parameter (if the group prefix (@) exists it is a group permission) + * + * @param permission permission to modify + * @param permissionName permission to modify + * @return a web response with the status code 204 + */ + @PUT + @Path("{permission-name}") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Update repository-specific permission", description = "Update a permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void update(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName, + @Valid RepositoryPermissionDto permission) { + log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission); + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionWrite().check(); + String extractedPermissionName = getPermissionName(permissionName); + if (!isPermissionExist(new RepositoryPermissionDto(extractedPermissionName, isGroupPermission(permissionName)), namespace)) { + throw notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName)); + } + permission.setGroupPermission(isGroupPermission(permissionName)); + if (!extractedPermissionName.equals(permission.getName())) { + checkPermissionAlreadyExists(permission, namespace); + } + + RepositoryPermission existingPermission = namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + RepositoryPermission newPermission = dtoToModelMapper.map(permission); + if (!namespace.removePermission(existingPermission)) { + throw new IllegalStateException(String.format("could not delete modified permission %s from namespace %s", existingPermission, namespaceName)); + } + namespace.addPermission(newPermission); + manager.modify(namespace); + log.info("the permission with name: {} is updated.", permissionName); + } + + /** + * 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 + @Path("{permission-name}") + @Operation(summary = "Delete repository-specific permission", description = "Delete a permission with the given name.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "delete success or nothing to delete") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void delete(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName) { + log.info("try to delete the permission with name: {}.", permissionName); + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionWrite().check(); + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .ifPresent(permission -> { + namespace.removePermission(permission); + manager.modify(namespace); + }); + log.info("the permission with name: {} is deleted.", permissionName); + } + + private Predicate filterPermission(String name) { + return permission -> getPermissionName(name).equals(permission.getName()) + && + permission.isGroupPermission() == isGroupPermission(name); + } + + private String getPermissionName(String permissionName) { + return Optional.of(permissionName) + .filter(p -> !isGroupPermission(permissionName)) + .orElse(permissionName.substring(1)); + } + + private boolean isGroupPermission(String permissionName) { + return permissionName.startsWith(GROUP_PREFIX); + } + + private Namespace load(String namespaceMame) { + return manager.get(namespaceMame) + .orElseThrow(() -> notFound(entity("Namespace", namespaceMame))); + } + + private void checkPermissionAlreadyExists(RepositoryPermissionDto permission, Namespace namespace) { + if (isPermissionExist(permission, namespace)) { + throw alreadyExists(entity("Permission", permission.getName()).in(Namespace.class, namespace.getNamespace())); + } + } + + private boolean isPermissionExist(RepositoryPermissionDto permission, Namespace namespace) { + return namespace.getPermissions() + .stream() + .anyMatch(p -> p.getName().equals(permission.getName()) && p.isGroupPermission() == permission.isGroupPermission()); + } +} + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java index 8c7fdc0a05..1f0c828ca7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java @@ -32,6 +32,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -44,11 +45,13 @@ public class NamespaceResource { private final RepositoryManager manager; private final NamespaceToNamespaceDtoMapper namespaceMapper; + private final Provider namespacePermissionResource; @Inject - public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper) { + public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper, Provider namespacePermissionResource) { this.manager = manager; this.namespaceMapper = namespaceMapper; + this.namespacePermissionResource = namespacePermissionResource; } /** @@ -97,4 +100,8 @@ public class NamespaceResource { .orElseThrow(() -> notFound(entity("Namespace", namespace))); } + @Path("permissions") + public NamespacePermissionResource permissions() { + return namespacePermissionResource.get(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java index 7a464299ea..5067f95c0f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java @@ -24,6 +24,9 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Links; +import sonia.scm.repository.NamespacePermissions; + import javax.inject.Inject; import static de.otto.edison.hal.Link.link; @@ -39,12 +42,15 @@ class NamespaceToNamespaceDtoMapper { } NamespaceDto map(String namespace) { - return new NamespaceDto( - namespace, - linkingTo() - .self(links.namespace().self(namespace)) - .single(link("repositories", links.repositoryCollection().forNamespace(namespace))) - .build() - ); + Links.Builder linkingTo = linkingTo(); + linkingTo + .self(links.namespace().self(namespace)) + .single(link("repositories", links.repositoryCollection().forNamespace(namespace))); + + if (NamespacePermissions.permissionRead().isPermitted()) { + linkingTo + .single(link("permissions", links.namespacePermission().all(namespace))); + } + return new NamespaceDto(namespace, linkingTo.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java index 765ffe8168..db285c369a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java @@ -21,13 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -57,6 +59,14 @@ public class RepositoryPermissionCollectionToDtoMapper { return new HalRepresentation(createLinks(repository), embedDtos(repositoryPermissionDtoList)); } + public HalRepresentation map(Namespace namespace) { + List repositoryPermissionDtoList = namespace.getPermissions() + .stream() + .map(permission -> repositoryPermissionToRepositoryPermissionDtoMapper.map(permission, namespace)) + .collect(toList()); + return new HalRepresentation(createLinks(namespace), embedDtos(repositoryPermissionDtoList)); + } + private Links createLinks(Repository repository) { RepositoryPermissions.permissionRead(repository).check(); Links.Builder linksBuilder = linkingTo() @@ -67,6 +77,16 @@ public class RepositoryPermissionCollectionToDtoMapper { return linksBuilder.build(); } + private Links createLinks(Namespace namespace) { + NamespacePermissions.permissionRead().check(); + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(resourceLinks.namespacePermission().all(namespace.getNamespace())).build()); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("create", resourceLinks.namespacePermission().create(namespace.getNamespace()))); + } + return linksBuilder.build(); + } + private Embedded embedDtos(List repositoryPermissionDtoList) { return embeddedBuilder() .with("permissions", repositoryPermissionDtoList) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java index 11fd658c33..37e3b097f9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; @@ -31,6 +31,8 @@ import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -51,18 +53,19 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Repository repository); + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Namespace namespace); @BeforeMapping void validatePermissions(@Context Repository repository) { RepositoryPermissions.permissionRead(repository).check(); } - /** - * Add the self, update and delete links. - * - * @param target the mapped dto - * @param repository the repository - */ + @BeforeMapping + void validatePermissions(@Context Namespace namespace) { + NamespacePermissions.permissionRead().check(); + } + @AfterMapping void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) { String permissionName = getUrlPermissionName(target); @@ -75,6 +78,18 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { target.add(linksBuilder.build()); } + @AfterMapping + void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Namespace namespace) { + String permissionName = getUrlPermissionName(target); + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.namespacePermission().self(namespace.getNamespace(), permissionName)); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("update", resourceLinks.namespacePermission().update(namespace.getNamespace(), permissionName))); + linksBuilder.single(link("delete", resourceLinks.namespacePermission().delete(namespace.getNamespace(), permissionName))); + } + target.add(linksBuilder.build()); + } + public String getUrlPermissionName(RepositoryPermissionDto repositoryPermissionDto) { return Optional.of(repositoryPermissionDto.getName()) .filter(p -> !repositoryPermissionDto.isGroupPermission()) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3dd2180ede..99a2058249 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -916,4 +916,40 @@ class ResourceLinks { return namespaceLinkBuilder.method("getNamespaceResource").parameters().method("get").parameters(namespace).href(); } } + + public NamespacePermissionLinks namespacePermission() { + return new NamespacePermissionLinks(scmPathInfoStore.get()); + } + + static class NamespacePermissionLinks { + private final LinkBuilder permissionLinkBuilder; + + NamespacePermissionLinks(ScmPathInfo pathInfo) { + permissionLinkBuilder = new LinkBuilder(pathInfo, NamespaceRootResource.class, NamespaceResource.class, NamespacePermissionResource.class); + } + + String all(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("getAll").parameters().href(); + } + + String create(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("create").parameters().href(); + } + + String self(String namespace, String permissionName) { + return getLink(namespace, permissionName, "get"); + } + + String update(String namespace, String permissionName) { + return getLink(namespace, permissionName, "update"); + } + + String delete(String namespace, String permissionName) { + return getLink(namespace, permissionName, "delete"); + } + + private String getLink(String namespace, String permissionName, String methodName) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method(methodName).parameters(permissionName).href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index ec7499aa02..776c196190 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -64,10 +64,12 @@ import sonia.scm.net.ahc.XmlContentTransformer; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; +import sonia.scm.repository.DefaultNamespaceManager; import sonia.scm.repository.DefaultRepositoryManager; import sonia.scm.repository.DefaultRepositoryProvider; import sonia.scm.repository.DefaultRepositoryRoleManager; import sonia.scm.repository.HealthCheckContextListener; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.NamespaceStrategyProvider; import sonia.scm.repository.Repository; @@ -191,6 +193,7 @@ class ScmServletModule extends ServletModule { bindDecorated(GroupManager.class, DefaultGroupManager.class, GroupManagerProvider.class); bind(GroupDisplayManager.class, DefaultGroupDisplayManager.class); + bind(NamespaceManager.class, DefaultNamespaceManager.class); bind(GroupCollector.class, DefaultGroupCollector.class); bind(CGIExecutorFactory.class, DefaultCGIExecutorFactory.class); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java index 669cc70544..d322b627bb 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java @@ -24,83 +24,354 @@ package sonia.scm.api.v2.resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.RestDispatcher; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import static com.google.inject.util.Providers.of; import static java.util.Arrays.asList; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class NamespaceRootResourceTest { @Mock RepositoryManager repositoryManager; + @Mock + NamespaceManager namespaceManager; + @Mock + Subject subject; RestDispatcher dispatcher = new RestDispatcher(); MockHttpResponse response = new MockHttpResponse(); ResourceLinks links = ResourceLinksMock.createMock(URI.create("/")); + @InjectMocks + RepositoryPermissionToRepositoryPermissionDtoMapperImpl repositoryPermissionToRepositoryPermissionDtoMapper; + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @BeforeEach void setUpResources() { NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links); NamespaceCollectionToDtoMapper namespaceCollectionToDtoMapper = new NamespaceCollectionToDtoMapper(namespaceMapper, links); + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(repositoryPermissionToRepositoryPermissionDtoMapper, links); + RepositoryPermissionDtoToRepositoryPermissionMapperImpl dtoToModelMapper = new RepositoryPermissionDtoToRepositoryPermissionMapperImpl(); + NamespaceCollectionResource namespaceCollectionResource = new NamespaceCollectionResource(repositoryManager, namespaceCollectionToDtoMapper); - NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper); + NamespacePermissionResource namespacePermissionResource = new NamespacePermissionResource(dtoToModelMapper, repositoryPermissionToRepositoryPermissionDtoMapper, repositoryPermissionCollectionToDtoMapper, links, namespaceManager); + NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper, of(namespacePermissionResource)); dispatcher.addSingletonResource(new NamespaceRootResource(of(namespaceCollectionResource), of(namespaceResource))); } - @Test - void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); - - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - - dispatcher.invoke(request, response); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") - .contains("\"_embedded\"") - .contains("\"namespace\":\"hitchhiker\"") - .contains("\"namespace\":\"space\""); + @BeforeEach + void mockExistingNamespaces() { + lenient().when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + Namespace hitchhikerNamespace = new Namespace("hitchhiker"); + hitchhikerNamespace.setPermissions(singleton(new RepositoryPermission("humans", "READ", true))); + Namespace spaceNamespace = new Namespace("space"); + lenient().when(namespaceManager.getAll()).thenReturn(asList(hitchhikerNamespace, spaceNamespace)); + lenient().when(namespaceManager.get("hitchhiker")).thenReturn(Optional.of(hitchhikerNamespace)); + lenient().when(namespaceManager.get("space")).thenReturn(Optional.of(spaceNamespace)); } - @Test - void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithoutSpecialPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + @BeforeEach + void mockNoPermissions() { + lenient().when(subject.isPermitted(anyString())).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionRead"); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"namespace\":\"space\"") - .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") + .contains("\"_embedded\"") + .contains("\"namespace\":\"hitchhiker\"") + .contains("\"namespace\":\"space\""); + } + + @Test + void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"namespace\":\"space\"") + .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}") + .doesNotContain("permissions"); + } + + @Test + void shouldHandleUnknownNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotReturnPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } } - @Test - void shouldHandleUnknownNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithReadPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + @BeforeEach + void grantReadPermission() { + lenient().when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldContainPermissionLinkWhenPermitted() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); - assertThat(response.getStatus()).isEqualTo(404); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"permissions\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldReturnPermissions() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("create"); + } + + @Test + void shouldReturnSinglePermission() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("update") + .doesNotContain("delete"); + } + + @Test + void shouldHandleMissingNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "no_such_namespace/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void shouldNotDeletePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Nested + class WithWritePermission { + + @BeforeEach + void grantWritePermission() { + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(true); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite"); + } + + @Test + void shouldContainCreateLink() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"create\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldContainModificationLinks() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"update\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"") + .contains("\"delete\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\""); + } + + @Test + void shouldCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(201); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("dent"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isFalse(); + return true; + }) + ); + assertThat(response.getOutputHeaders().get("Location")) + .containsExactly(URI.create("/v2/namespaces/space/permissions/dent")); + } + + @Test + void shouldUpdatePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("humans"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isTrue(); + return true; + }) + ); + } + + @Test + void shouldHandleNotExistingPermissionOnUpdate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldHandleExistingPermissionOnCreate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + verify(namespaceManager, never()).modify(any()); + } + + @Test + void shouldDeleteExistingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).isEmpty(); + return true; + }) + ); + } + + @Test + void shouldHandleRedundantDeleteIdempotent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager, never()).modify(any()); + } + } } } 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 38a1055211..f2d6e85710 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 @@ -78,6 +78,7 @@ public class ResourceLinksMock { lenient().when(resourceLinks.annotate()).thenReturn(new ResourceLinks.AnnotateLinks(pathInfo)); lenient().when(resourceLinks.namespace()).thenReturn(new ResourceLinks.NamespaceLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); + lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); return resourceLinks; }