diff --git a/gradle/changelog/import_repository_permissions.yaml b/gradle/changelog/import_repository_permissions.yaml new file mode 100644 index 0000000000..1941ed1ac0 --- /dev/null +++ b/gradle/changelog/import_repository_permissions.yaml @@ -0,0 +1,3 @@ +- type: added + description: Import repository permissions from repository archive ([#1520](https://github.com/scm-manager/scm-manager/pull/1520)) + diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java index 3e1870a945..3de8adef38 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java @@ -28,8 +28,10 @@ import org.apache.commons.compress.archivers.ArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; import sonia.scm.ContextEntry; +import sonia.scm.importexport.RepositoryMetadataXmlGenerator.RepositoryMetadata; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.api.ImportFailedException; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -40,7 +42,10 @@ import java.io.BufferedInputStream; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.Collection; +import java.util.HashSet; +import static sonia.scm.importexport.FullScmRepositoryExporter.METADATA_FILE_NAME; import static sonia.scm.importexport.FullScmRepositoryExporter.SCM_ENVIRONMENT_FILE_NAME; import static sonia.scm.importexport.FullScmRepositoryExporter.STORE_DATA_FILE_NAME; @@ -73,9 +78,10 @@ public class FullScmRepositoryImporter { TarArchiveInputStream tais = new TarArchiveInputStream(gcis) ) { checkScmEnvironment(repository, tais); - skipRepositoryMetadata(tais); + Collection importedPermissions = processRepositoryMetadata(tais); Repository createdRepository = importRepositoryFromFile(repository, tais); importStoresForCreatedRepository(createdRepository, tais); + importRepositoryPermissions(createdRepository, importedPermissions); return createdRepository; } } else { @@ -93,6 +99,14 @@ public class FullScmRepositoryImporter { } } + private void importRepositoryPermissions(Repository repository, Collection importedPermissions) { + Collection existingPermissions = repository.getPermissions(); + RepositoryImportPermissionMerger permissionMerger = new RepositoryImportPermissionMerger(); + Collection permissions = permissionMerger.merge(existingPermissions, importedPermissions); + repository.setPermissions(permissions); + repositoryManager.modify(repository); + } + private void importStoresForCreatedRepository(Repository repository, TarArchiveInputStream tais) throws IOException { ArchiveEntry metadataEntry = tais.getNextEntry(); if (metadataEntry.getName().equals(STORE_DATA_FILE_NAME) && !metadataEntry.isDirectory()) { @@ -135,7 +149,7 @@ public class FullScmRepositoryImporter { boolean validEnvironment = compatibilityChecker.check(JAXB.unmarshal(new NoneClosingInputStream(tais), ScmEnvironment.class)); if (!validEnvironment) { throw new ImportFailedException( - ContextEntry.ContextBuilder.entity(repository).build(), + ContextEntry.ContextBuilder.noContext(), "Incompatible SCM-Manager environment. Could not import file." ); } @@ -147,8 +161,17 @@ public class FullScmRepositoryImporter { } } - private void skipRepositoryMetadata(TarArchiveInputStream tais) throws IOException { - tais.getNextEntry(); + private Collection processRepositoryMetadata(TarArchiveInputStream tais) throws IOException { + ArchiveEntry metadataEntry = tais.getNextEntry(); + if (metadataEntry.getName().equals(METADATA_FILE_NAME)) { + RepositoryMetadata metadata = JAXB.unmarshal(new NoneClosingInputStream(tais), RepositoryMetadata.class); + return new HashSet<>(metadata.getPermissions()); + } else { + throw new ImportFailedException( + ContextEntry.ContextBuilder.noContext(), + String.format("Invalid import format. Missing SCM-Manager metadata description file %s.", METADATA_FILE_NAME) + ); + } } @SuppressWarnings("java:S4929") // we only want to override close here diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportPermissionMerger.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportPermissionMerger.java new file mode 100644 index 0000000000..a20066b441 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportPermissionMerger.java @@ -0,0 +1,54 @@ +/* + * 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.importexport; + +import sonia.scm.repository.RepositoryPermission; + +import java.util.Collection; +import java.util.HashSet; +import java.util.function.Predicate; + +class RepositoryImportPermissionMerger { + + public Collection merge(Collection existingPermissions, + Collection importedPermissions) { + HashSet permissions = new HashSet<>(existingPermissions); + importedPermissions + .stream() + .filter(permissionDoesNotExistYet(permissions)) + .forEach(permissions::add); + + return permissions; + } + + private Predicate permissionDoesNotExistYet(HashSet permissions) { + return importedPermission -> + permissions.stream() + .noneMatch(existingPermission -> + existingPermission.getName().equals(importedPermission.getName()) + && existingPermission.isGroupPermission() == importedPermission.isGroupPermission() + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryMetadataXmlGenerator.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryMetadataXmlGenerator.java index 5596218820..162e7f4167 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryMetadataXmlGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryMetadataXmlGenerator.java @@ -25,6 +25,7 @@ package sonia.scm.importexport; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import sonia.scm.ContextEntry; import sonia.scm.repository.Repository; @@ -57,9 +58,10 @@ class RepositoryMetadataXmlGenerator { @AllArgsConstructor @NoArgsConstructor + @Getter @XmlRootElement(name = "metadata") @XmlAccessorType(XmlAccessType.FIELD) - private static class RepositoryMetadata { + static class RepositoryMetadata { private String namespace; private String name; diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java index 7292ec7faa..5ff051920b 100644 --- a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java @@ -36,6 +36,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryTestData; import sonia.scm.repository.api.ImportFailedException; import sonia.scm.repository.api.RepositoryService; @@ -47,13 +48,13 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; +import java.util.Collection; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -105,9 +106,13 @@ class FullScmRepositoryImporterTest { void shouldImportScmRepositoryArchive() throws IOException { when(compatibilityChecker.check(any())).thenReturn(true); when(repositoryManager.create(eq(REPOSITORY), any())).thenReturn(REPOSITORY); + Collection existingPermissions = REPOSITORY.getPermissions(); Repository repository = fullImporter.importFromStream(REPOSITORY, Resources.getResource("sonia/scm/repository/import/scm-import.tar.gz").openStream()); assertThat(repository).isEqualTo(REPOSITORY); verify(storeImporter).importFromTarArchive(eq(REPOSITORY), any(InputStream.class)); + verify(repositoryManager).modify(REPOSITORY); + Collection updatedPermissions = REPOSITORY.getPermissions(); + assertThat(updatedPermissions).isNotEqualTo(existingPermissions); } } diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportPermissionMergerTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportPermissionMergerTest.java new file mode 100644 index 0000000000..472775d32c --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportPermissionMergerTest.java @@ -0,0 +1,125 @@ +/* + * 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.importexport; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; +import sonia.scm.repository.RepositoryPermission; + +import java.util.Collection; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +class RepositoryImportPermissionMergerTest { + + private final RepositoryImportPermissionMerger merger = new RepositoryImportPermissionMerger(); + + @Test + void shouldReturnExistingPermissionsIfNoImportedPermissions() { + RepositoryPermission owner = new RepositoryPermission("owner", ImmutableSet.of("*"), false); + Collection existing = ImmutableSet.of(owner); + Collection imported = Collections.emptySet(); + + Collection result = merger.merge(existing, imported); + + assertThat(result) + .hasSize(1) + .contains(owner); + } + + @Test + void shouldReturnMergedPermissions() { + RepositoryPermission owner = new RepositoryPermission("owner", ImmutableSet.of("*"), false); + RepositoryPermission trillian = new RepositoryPermission("trillian", ImmutableSet.of("read"), false); + Collection existing = ImmutableSet.of(owner); + Collection imported = ImmutableSet.of(trillian); + + Collection result = merger.merge(existing, imported); + + assertThat(result) + .hasSize(2) + .contains(owner) + .contains(trillian); + } + + @Test + void shouldReturnOnlyMergePermissionIfNotExistYet() { + RepositoryPermission owner = new RepositoryPermission("owner", ImmutableSet.of("*"), false); + RepositoryPermission trillian = new RepositoryPermission("trillian", ImmutableSet.of("read", "write"), false); + RepositoryPermission importedOwner = new RepositoryPermission("owner", ImmutableSet.of("read, write"), false); + RepositoryPermission importedTrillian = new RepositoryPermission("trillian", ImmutableSet.of("read"), false); + + Collection existing = ImmutableSet.of(owner, trillian); + Collection imported = ImmutableSet.of(importedOwner, importedTrillian); + + Collection result = merger.merge(existing, imported); + + assertThat(result) + .hasSize(2) + .contains(owner) + .contains(trillian) + .doesNotContain(importedOwner) + .doesNotContain(importedTrillian); + } + + @Test + void shouldAddPermissionWithSameNameIfOneIsGroupPermission() { + RepositoryPermission owner = new RepositoryPermission("owner", ImmutableSet.of("*"), false); + RepositoryPermission trillian = new RepositoryPermission("trillian", ImmutableSet.of("read", "write"), false); + RepositoryPermission importedOwner = new RepositoryPermission("owner", ImmutableSet.of("read, write"), true); + RepositoryPermission importedTrillian = new RepositoryPermission("trillian", ImmutableSet.of("read"), true); + + Collection existing = ImmutableSet.of(owner, trillian); + Collection imported = ImmutableSet.of(importedOwner, importedTrillian); + + Collection result = merger.merge(existing, imported); + + assertThat(result) + .hasSize(4) + .contains(owner) + .contains(trillian) + .contains(importedOwner) + .contains(importedTrillian); + } + + @Test + void shouldNotAddPermissionMultipleTimes() { + RepositoryPermission owner = new RepositoryPermission("owner", ImmutableSet.of("*"), false); + RepositoryPermission importedTrillian1 = new RepositoryPermission("trillian", ImmutableSet.of("read, write"), false); + RepositoryPermission importedTrillian2 = new RepositoryPermission("trillian", ImmutableSet.of("read"), false); + + Collection existing = ImmutableSet.of(owner); + Collection imported = ImmutableSet.of(importedTrillian1, importedTrillian2); + + Collection result = merger.merge(existing, imported); + + assertThat(result) + .hasSize(2) + .contains(owner) + .contains(importedTrillian1) + .doesNotContain(importedTrillian2); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/import/scm-import.tar.gz b/scm-webapp/src/test/resources/sonia/scm/repository/import/scm-import.tar.gz index bcd6e85167..e5aca16df9 100644 Binary files a/scm-webapp/src/test/resources/sonia/scm/repository/import/scm-import.tar.gz and b/scm-webapp/src/test/resources/sonia/scm/repository/import/scm-import.tar.gz differ