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
This commit is contained in:
Thomas Zerr
2025-03-03 11:16:10 +01:00
parent c1fbc91e4b
commit 6d2ec8a70a
5 changed files with 315 additions and 0 deletions

View File

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

View File

@@ -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<UpdateStepRepositoryMetadataAccess<Path>>() {}).to(new TypeLiteral<MetadataStore>() {});
// bind metrics

View File

@@ -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<Namespace> 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<RepositoryPermission> permissionsToUpdate = permissionHolder
.getPermissions()
.stream()
.filter(permission -> shouldPermissionBeRemoved(permission, permissionName))
.toList();
permissionsToUpdate.forEach(permissionHolder::removePermission);
List<RepositoryPermission> 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);
}
}

View File

@@ -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<Path> 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<Namespace> 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<String> newMetadata = Files.readAllLines(tempDir.resolve("metadata.xml"));
assertThat(newMetadata.stream().map(String::trim))
.contains(
"<permission>",
"<groupPermission>false</groupPermission>",
"<name>Trainer Red</name>",
"<verb>read</verb>",
"<verb>pull</verb>",
"</permission>"
).doesNotContain("<verb>readCIStatus</verb>");
}
}
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
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/.
-->
<repositories>
<properties/>
<id>1</id>
<namespace>Kanto</namespace>
<name>Saffron City</name>
<type>git</type>
<description>City</description>
<contact>Sabrina</contact>
<creationDate>1738596766353</creationDate>
<lastModified>1740736889695</lastModified>
<permission>
<groupPermission>false</groupPermission>
<name>Trainer Red</name>
<verb>read</verb>
<verb>pull</verb>
<verb>readCIStatus</verb>
</permission>
<archived>false</archived>
</repositories>