From 6d2ec8a70a802e81e420a1df998cfad6cb63b9ad Mon Sep 17 00:00:00 2001 From: Thomas Zerr Date: Mon, 3 Mar 2025 11:16:10 +0100 Subject: [PATCH] Add API for updating RepositoryPermissions Squash commits of branch feature/permission-updater: - Add API for updating RepositoryPermissions - Add licensing and logging - Fix assertion depending on the order of item serialization - Replace manual logger setup with @Slf4j annotation --- .../update/RepositoryPermissionUpdater.java | 23 +++ .../lifecycle/modules/BootstrapModule.java | 3 + .../DefaultRepositoryPermissionUpdater.java | 134 ++++++++++++++++++ ...efaultRepositoryPermissionUpdaterTest.java | 119 ++++++++++++++++ .../metadataWithPermissionsToRemove.xml | 36 +++++ 5 files changed, 315 insertions(+) create mode 100644 scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java create mode 100644 scm-webapp/src/main/java/sonia/scm/update/DefaultRepositoryPermissionUpdater.java create mode 100644 scm-webapp/src/test/java/sonia/scm/update/DefaultRepositoryPermissionUpdaterTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithPermissionsToRemove.xml diff --git a/scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java b/scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java new file mode 100644 index 0000000000..50b5278394 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java @@ -0,0 +1,23 @@ +/* + * 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.update; + +import sonia.scm.repository.RepositoryPermissionHolder; + +public interface RepositoryPermissionUpdater { + void removePermission(RepositoryPermissionHolder permissionHolder, String permissionName); +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java index 9dd37a7ff5..71a21e2c0d 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/BootstrapModule.java @@ -62,8 +62,10 @@ import sonia.scm.store.JAXBConfigurationStoreFactory; import sonia.scm.store.JAXBDataStoreFactory; import sonia.scm.store.JAXBPropertyFileAccess; import sonia.scm.update.BlobDirectoryAccess; +import sonia.scm.update.DefaultRepositoryPermissionUpdater; import sonia.scm.update.NamespaceUpdateIterator; import sonia.scm.update.PropertyFileAccess; +import sonia.scm.update.RepositoryPermissionUpdater; import sonia.scm.update.RepositoryUpdateIterator; import sonia.scm.update.StoreUpdateStepUtilFactory; import sonia.scm.update.UpdateStepRepositoryMetadataAccess; @@ -121,6 +123,7 @@ public class BootstrapModule extends AbstractModule { bind(RepositoryUpdateIterator.class, FileRepositoryUpdateIterator.class); bind(NamespaceUpdateIterator.class, FileNamespaceUpdateIterator.class); bind(StoreUpdateStepUtilFactory.class, FileStoreUpdateStepUtilFactory.class); + bind(RepositoryPermissionUpdater.class, DefaultRepositoryPermissionUpdater.class); bind(new TypeLiteral>() {}).to(new TypeLiteral() {}); // bind metrics diff --git a/scm-webapp/src/main/java/sonia/scm/update/DefaultRepositoryPermissionUpdater.java b/scm-webapp/src/main/java/sonia/scm/update/DefaultRepositoryPermissionUpdater.java new file mode 100644 index 0000000000..c5587cd1e4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/DefaultRepositoryPermissionUpdater.java @@ -0,0 +1,134 @@ +/* + * 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.update; + +import jakarta.inject.Inject; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.migration.UpdateException; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryPermissionHolder; +import sonia.scm.store.DataStore; +import sonia.scm.store.DataStoreFactory; + +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +public class DefaultRepositoryPermissionUpdater implements RepositoryPermissionUpdater { + + private final RepositoryLocationResolver locationResolver; + private final DataStore namespaceStore; + private final JAXBContext jaxbRepositoryContext; + + @Inject + public DefaultRepositoryPermissionUpdater(RepositoryLocationResolver locationResolver, + DataStoreFactory dataStoreFactory) { + this.locationResolver = locationResolver; + this.namespaceStore = dataStoreFactory.withType(Namespace.class).withName("namespaces").build(); + this.jaxbRepositoryContext = createJAXBContext(); + } + + private JAXBContext createJAXBContext() { + try { + return JAXBContext.newInstance(Repository.class); + } catch (JAXBException e) { + throw new UpdateException("could not create Repository XML marshaller", e); + } + } + + @Override + public void removePermission(RepositoryPermissionHolder permissionHolder, String permissionName) { + List permissionsToUpdate = permissionHolder + .getPermissions() + .stream() + .filter(permission -> shouldPermissionBeRemoved(permission, permissionName)) + .toList(); + permissionsToUpdate.forEach(permissionHolder::removePermission); + + List updatedPermissions = permissionsToUpdate + .stream() + .map(permission -> removePermissionFromVerbs(permission, permissionName)) + .toList(); + updatedPermissions.forEach(permission -> { + + if (permissionHolder instanceof Repository repository) { + log.debug( + "removing permission {} from {} inside repository {}", + permissionName, + permission.getName(), + repository + ); + } + + if (permissionHolder instanceof Namespace namespace) { + log.debug( + "removing permission {} from {} inside namespace {}", + permissionName, + permission.getName(), + namespace.getNamespace()); + } + + permissionHolder.addPermission(permission); + }); + + if (permissionHolder instanceof Repository repository) { + Path path = locationResolver.forClass(Path.class).getLocation(repository.getId()); + this.writeRepository(repository, path); + } + + if (permissionHolder instanceof Namespace namespace) { + this.writeNamespace(namespace); + } + } + + private boolean shouldPermissionBeRemoved(RepositoryPermission permission, String permissionName) { + return permission.getVerbs().contains(permissionName); + } + + private RepositoryPermission removePermissionFromVerbs(RepositoryPermission permission, String permissionName) { + return new RepositoryPermission( + permission.getName(), + permission + .getVerbs() + .stream() + .filter(verb -> !verb.equals(permissionName)) + .collect(Collectors.toUnmodifiableSet()), + permission.isGroupPermission() + ); + } + + private void writeRepository(Repository repository, Path repositoryPath) { + try { + Marshaller marshaller = jaxbRepositoryContext.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + marshaller.marshal(repository, repositoryPath.resolve("metadata.xml").toFile()); + } catch (JAXBException e) { + throw new UpdateException("could not write repository structure", e); + } + } + + private void writeNamespace(Namespace namespace) { + this.namespaceStore.put(namespace.getId(), namespace); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/update/DefaultRepositoryPermissionUpdaterTest.java b/scm-webapp/src/test/java/sonia/scm/update/DefaultRepositoryPermissionUpdaterTest.java new file mode 100644 index 0000000000..1ce521d6f0 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/update/DefaultRepositoryPermissionUpdaterTest.java @@ -0,0 +1,119 @@ +/* + * 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.update; + +import com.google.common.io.Resources; +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.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.store.DataStore; +import sonia.scm.store.InMemoryByteDataStoreFactory; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultRepositoryPermissionUpdaterTest { + + private final Repository repository = new Repository( + "1", + "git", + "Kanto", + "Saffron City", + "Sabrina", + "City", + new RepositoryPermission("Trainer Red", Set.of("read", "pull", "readCIStatus"), false) + ); + private final InMemoryByteDataStoreFactory dataStoreFactory = new InMemoryByteDataStoreFactory(); + @Mock + private RepositoryLocationResolver locationResolver; + @Mock + private RepositoryLocationResolver.RepositoryLocationResolverInstance resolverInstance; + private DefaultRepositoryPermissionUpdater updater; + + @TempDir + private Path tempDir; + + @BeforeEach + void setup() { + updater = new DefaultRepositoryPermissionUpdater(locationResolver, dataStoreFactory); + } + + @Nested + class RemovePermission { + + @Test + void shouldRemovePermissionFromUserAndGroupsForNamespace() { + RepositoryPermission userPermission = new RepositoryPermission( + "Trainer Red", Set.of("read", "pull", "readCIStatus"), false + ); + RepositoryPermission groupPermission = new RepositoryPermission( + "Elite Four", Set.of("read", "pull", "readCIStatus"), true + ); + + DataStore namespaceStore = dataStoreFactory.withType(Namespace.class).withName("namespaces").build(); + Namespace namespace = new Namespace("Kanto"); + namespace.setPermissions(Set.of(userPermission, groupPermission)); + namespaceStore.put(namespace.getId(), namespace); + + updater.removePermission(namespace, "readCIStatus"); + + assertThat(namespaceStore.get(namespace.getId()).getPermissions()).containsOnly( + new RepositoryPermission("Trainer Red", Set.of("read", "pull"), false), + new RepositoryPermission("Elite Four", Set.of("read", "pull"), true) + ); + } + + @Test + void shouldRemovePermissionFromUserAndGroupsForRepository() throws IOException { + URL metadataUrl = Resources.getResource( + "sonia/scm/update/repository/metadataWithPermissionsToRemove.xml" + ); + Files.copy(metadataUrl.openStream(), tempDir.resolve("metadata.xml")); + when(locationResolver.forClass(Path.class)).thenReturn(resolverInstance); + when(resolverInstance.getLocation(repository.getId())).thenReturn(tempDir); + + updater.removePermission(repository, "readCIStatus"); + + List newMetadata = Files.readAllLines(tempDir.resolve("metadata.xml")); + assertThat(newMetadata.stream().map(String::trim)) + .contains( + "", + "false", + "Trainer Red", + "read", + "pull", + "" + ).doesNotContain("readCIStatus"); + } + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithPermissionsToRemove.xml b/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithPermissionsToRemove.xml new file mode 100644 index 0000000000..cd8e9afe3d --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithPermissionsToRemove.xml @@ -0,0 +1,36 @@ + + + + + + 1 + Kanto + Saffron City + git + City + Sabrina + 1738596766353 + 1740736889695 + + false + Trainer Red + read + pull + readCIStatus + + false +