mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-29 18:59:11 +01:00
Add tag guard
This commit is contained in:
2
gradle/changelog/tag_guard.yaml
Normal file
2
gradle/changelog/tag_guard.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Extension point to protect tags against deletion
|
||||
29
scm-core/src/main/java/sonia/scm/repository/TagGuard.java
Normal file
29
scm-core/src/main/java/sonia/scm/repository/TagGuard.java
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TagGuard> 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<Long> value) {
|
||||
return value.map(Instant::ofEpochMilli).orElse(null);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setResourceLinks(ResourceLinks resourceLinks) {
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setTagGuardSet(Set<TagGuard> tagGuardSet) {
|
||||
this.tagGuardSet = tagGuardSet;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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<TagGuard> tagGuards;
|
||||
|
||||
@Inject
|
||||
public TagProtectionPreReceiveRepositoryHook(Set<TagGuard> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<TagGuard> 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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user