diff --git a/gradle/changelog/tag_guard.yaml b/gradle/changelog/tag_guard.yaml new file mode 100644 index 0000000000..b7e1a8a187 --- /dev/null +++ b/gradle/changelog/tag_guard.yaml @@ -0,0 +1,2 @@ +- type: added + description: Extension point to protect tags against deletion diff --git a/scm-core/src/main/java/sonia/scm/repository/TagGuard.java b/scm-core/src/main/java/sonia/scm/repository/TagGuard.java new file mode 100644 index 0000000000..22143b001c --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/TagGuard.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * Plugins can implement this interface to prevent the deletion of certain tags. + * + * @since 3.6.0 + */ +@ExtensionPoint +public interface TagGuard { + boolean canDelete(TagGuardDeletionRequest deletionRequest); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/TagGuardDeletionRequest.java b/scm-core/src/main/java/sonia/scm/repository/TagGuardDeletionRequest.java new file mode 100644 index 0000000000..6d41a64acc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/TagGuardDeletionRequest.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import lombok.Value; + +@Value +public class TagGuardDeletionRequest { + Repository repository; + Tag deletedTag; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java index e7334266a7..7139239ba4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java @@ -16,6 +16,7 @@ package sonia.scm.api.v2.resources; +import com.google.common.annotations.VisibleForTesting; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import jakarta.inject.Inject; @@ -27,10 +28,13 @@ import org.mapstruct.ObjectFactory; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.Tag; +import sonia.scm.repository.TagGuard; +import sonia.scm.repository.TagGuardDeletionRequest; import sonia.scm.web.EdisonHalAppender; import java.time.Instant; import java.util.Optional; +import java.util.Set; import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; @@ -41,6 +45,8 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper { @Inject private ResourceLinks resourceLinks; + @Inject + private Set tagGuardSet; @Mapping(target = "date", source = "date", qualifiedByName = "mapDate") @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes @@ -54,7 +60,7 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper { .single(link("sources", resourceLinks.source().self(repository.getNamespace(), repository.getName(), tag.getRevision()))) .single(link("changeset", resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), tag.getRevision()))); - if (tag.getDeletable() && RepositoryPermissions.push(repository).isPermitted()) { + if (tag.getDeletable() && RepositoryPermissions.push(repository).isPermitted() && tagGuardSet.stream().allMatch(guard -> guard.canDelete(new TagGuardDeletionRequest(repository, tag)))) { linksBuilder .single(link("delete", resourceLinks.tag().delete(repository.getNamespace(), repository.getName(), tag.getName()))); } @@ -69,4 +75,14 @@ public abstract class TagToTagDtoMapper extends HalAppenderMapper { Instant map(Optional value) { return value.map(Instant::ofEpochMilli).orElse(null); } + + @VisibleForTesting + void setResourceLinks(ResourceLinks resourceLinks) { + this.resourceLinks = resourceLinks; + } + + @VisibleForTesting + void setTagGuardSet(Set tagGuardSet) { + this.tagGuardSet = tagGuardSet; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionException.java b/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionException.java new file mode 100644 index 0000000000..8fbd9deeb5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import sonia.scm.ExceptionWithContext; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +class TagProtectionException extends ExceptionWithContext { + + TagProtectionException(Repository repository, Tag tag, String message) { + super(entity(Tag.class, tag.getName()).in(repository).build(), message); + } + + @Override + public String getCode() { + return "99UQi5FKd1"; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHook.java b/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHook.java new file mode 100644 index 0000000000..c9184f298d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHook.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import com.github.legman.Subscribe; +import com.google.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.EagerSingleton; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.api.HookFeature; + +import java.util.Set; + +@Slf4j +@Extension +@EagerSingleton +public class TagProtectionPreReceiveRepositoryHook { + + private final Set tagGuards; + + @Inject + public TagProtectionPreReceiveRepositoryHook(Set tagGuards) { + this.tagGuards = tagGuards; + } + + @Subscribe(async = false) + public void onEvent(PreReceiveRepositoryHookEvent event) { + if (tagGuards.isEmpty()) { + log.trace("no tag guards available, skipping tag protection"); + return; + } + Repository repository = event.getRepository(); + if (repository == null) { + log.trace("received hook without repository, skipping tag protection"); + return; + } + if (!event.getContext().isFeatureSupported(HookFeature.TAG_PROVIDER)) { + log.trace("repository {} does not support tag provider, skipping tag protection", repository); + return; + } + + event + .getContext() + .getTagProvider() + .getDeletedTags() + .forEach(tag -> { + boolean tagMustBeProtected = tagGuards.stream().anyMatch(guard -> !guard.canDelete(new TagGuardDeletionRequest(repository, tag))); + if (tagMustBeProtected) { + String message = String.format("Deleting tag '%s' not allowed in repository %s", tag.getName(), repository); + log.info(message); + + if (event.getContext().isFeatureSupported(HookFeature.MESSAGE_PROVIDER)) { + event.getContext().getMessageProvider().sendMessage(message); + } + throw new TagProtectionException(repository, tag, message); + } else { + log.trace("tag {} does not have to be protected", tag); + } + }); + } +} diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index d1100d03a9..0a43603ff2 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -507,6 +507,10 @@ "E4TrutUSv1": { "summary": "Speicherung des Repositories fehlgeschlagen", "description": "Beim Speichern des Repositories ist ein Fehler aufgetreten. Weitere Hinweise finden sich im Server Log." + }, + "99UQi5FKd1": { + "displayName": "Tags sind geschützt", + "description": "Einige Tags sind geschützt und können nicht gelöscht werden. Der Schutz ist global konfiguriert." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 8bbe2dd175..8570efc85b 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -448,6 +448,10 @@ "BaSXkAztI1": { "displayName": "Repository is read-only", "description": "This repository is read-only and cannot be modified." + }, + "99UQi5FKd1": { + "displayName": "Tags are protected", + "description": "Some tags are protected and cannot be removed. The protection is configured globally." } }, "healthChecksFailures": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java index 08e03776c1..eb786e2d01 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java @@ -49,6 +49,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.Collections; +import static java.util.Collections.emptySet; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -95,6 +96,7 @@ public class TagRootResourceTest extends RepositoryTestBase { @Before public void prepareEnvironment() { + tagToTagDtoMapper.setTagGuardSet(emptySet()); tagCollectionToDtoMapper = new TagCollectionToDtoMapper(resourceLinks, tagToTagDtoMapper); tagRootResource = new TagRootResource(serviceFactory, tagCollectionToDtoMapper, tagToTagDtoMapper, resourceLinks); dispatcher.addSingletonResource(getRepositoryRootResource()); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java index 8da5df1243..2aa1257abe 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java @@ -22,20 +22,19 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryTestData; import sonia.scm.repository.Signature; import sonia.scm.repository.SignatureStatus; import sonia.scm.repository.Tag; +import sonia.scm.repository.TagGuard; import java.net.URI; import java.time.Instant; import java.util.Collections; +import java.util.HashSet; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -45,8 +44,8 @@ class TagToTagDtoMapperTest { @SuppressWarnings("unused") // Is injected private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("https://hitchhiker.com")); + private final HashSet tagGuardSet = new HashSet<>(); - @InjectMocks private TagToTagDtoMapperImpl mapper; @Mock @@ -57,6 +56,13 @@ class TagToTagDtoMapperTest { ThreadContext.bind(subject); } + @BeforeEach + void initMapper() { + mapper = new TagToTagDtoMapperImpl(); + mapper.setResourceLinks(resourceLinks); + mapper.setTagGuardSet(tagGuardSet); + } + @AfterEach void tearDown() { ThreadContext.unbindSubject(); @@ -99,7 +105,7 @@ class TagToTagDtoMapperTest { } @Test - void shouldAddDeleteLink() { + void shouldAddDeleteLinkWithoutGuard() { Repository repository = RepositoryTestData.createHeartOfGold(); when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true); final Tag tag = new Tag("1.0.0", "42"); @@ -107,6 +113,28 @@ class TagToTagDtoMapperTest { assertThat(dto.getLinks().getLinkBy("delete")).isNotEmpty(); } + @Test + void shouldAddDeleteLinkIfGuardsAreOkay() { + tagGuardSet.add(deletionRequest -> true); + tagGuardSet.add(deletionRequest -> true); + Repository repository = RepositoryTestData.createHeartOfGold(); + when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true); + final Tag tag = new Tag("1.0.0", "42"); + TagDto dto = mapper.map(tag, repository); + assertThat(dto.getLinks().getLinkBy("delete")).isNotEmpty(); + } + + @Test + void shouldNotAddDeleteLinkIfGuardInterferes() { + tagGuardSet.add(deletionRequest -> true); + tagGuardSet.add(deletionRequest -> false); + Repository repository = RepositoryTestData.createHeartOfGold(); + when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true); + final Tag tag = new Tag("1.0.0", "42"); + TagDto dto = mapper.map(tag, repository); + assertThat(dto.getLinks().getLinkBy("delete")).isEmpty(); + } + @Test void shouldNotAddDeleteLinkIfPermissionsAreMissing() { final Tag tag = new Tag("1.0.0", "42"); diff --git a/scm-webapp/src/test/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHookTest.java b/scm-webapp/src/test/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHookTest.java new file mode 100644 index 0000000000..0b615f5b12 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/TagProtectionPreReceiveRepositoryHookTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +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.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.api.HookFeature; + +import java.util.List; +import java.util.Set; + +import static java.util.Collections.emptySet; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TagProtectionPreReceiveRepositoryHookTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private PreReceiveRepositoryHookEvent event; + + @Test + void shouldDoNothingWithoutGuards() { + new TagProtectionPreReceiveRepositoryHook(emptySet()) + .onEvent(event); + + verifyNoInteractions(event); + } + + @Nested + class WithDeletedTag { + + @BeforeEach + void setUpEvent() { + when(event.getRepository()).thenReturn(new Repository("42", "git", "hitchhiker", "hog")); + when(event.getContext().isFeatureSupported(HookFeature.TAG_PROVIDER)).thenReturn(true); + when(event.getContext().getTagProvider().getDeletedTags()).thenReturn(List.of(new Tag("protected", "1"))); + } + + @Test + void shouldProtectTag() { + TagGuard guard = new TagGuard() { + @Override + public boolean canDelete(TagGuardDeletionRequest request) { + return !(request.getRepository().getId().equals("42") + && request.getDeletedTag().getName().equals("protected")); + } + }; + + TagProtectionPreReceiveRepositoryHook hook = new TagProtectionPreReceiveRepositoryHook(Set.of(guard)); + + assertThatThrownBy(() -> hook.onEvent(event)) + .isInstanceOf(TagProtectionException.class); + } + + @Test + void shouldNotProtectTagIfItCanBeDeleted() { + TagGuard guard = new TagGuard() { + @Override + public boolean canDelete(TagGuardDeletionRequest request) { + return true; + } + }; + + TagProtectionPreReceiveRepositoryHook hook = new TagProtectionPreReceiveRepositoryHook(Set.of(guard)); + + assertDoesNotThrow(() -> hook.onEvent(event)); + } + + } +}