Add tag guard

This commit is contained in:
Rene Pfeuffer
2024-10-16 16:14:22 +02:00
parent 3a88dff70f
commit afdaee0442
11 changed files with 316 additions and 6 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Extension point to protect tags against deletion

View 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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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());

View File

@@ -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");

View File

@@ -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));
}
}
}