diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f45ea43a..ac2e9932db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335)) ### Fixed - Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328)) +- Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334)) +- Branch not found right after creation ([#1334](https://github.com/scm-manager/scm-manager/pull/1334)) ## [2.5.0] - 2020-09-10 ### Added diff --git a/docs/de/user/repo/assets/repository-overview.png b/docs/de/user/repo/assets/repository-overview.png index 452af113c7..59cf55e4d2 100644 Binary files a/docs/de/user/repo/assets/repository-overview.png and b/docs/de/user/repo/assets/repository-overview.png differ diff --git a/docs/de/user/repo/index.md b/docs/de/user/repo/index.md index e0cb07a603..bfc3100226 100644 --- a/docs/de/user/repo/index.md +++ b/docs/de/user/repo/index.md @@ -26,6 +26,8 @@ Icon | Beschreibung ![Repository Sources](assets/repository-overview-sources.png) | Öffnet die Sources-Übersicht für das Repository ![Repository Einstellungen](assets/repository-overview-settings.png) | Öffnet die Einstellungen für das Repository +Zusätzlich können über das Icon rechts neben den Überschriften für die Namespaces weitere Einstellungen auf Namespace-Ebene vorgenommen werden. + ### Repository erstellen Im SCM-Manager können neue Git, Mercurial & Subersion (SVN) Repositories über ein Formular angelegt werden. Dieses kann über den Button "Repository erstellen" aufgerufen werden. Dabei muss ein gültiger Name eingetragen und der Repository-Typ bestimmt werden. diff --git a/docs/de/user/repo/settings.md b/docs/de/user/repo/settings.md index e7f87b241f..124fcd8f0a 100644 --- a/docs/de/user/repo/settings.md +++ b/docs/de/user/repo/settings.md @@ -12,10 +12,12 @@ Innerhalb der Gefahrenzone unten auf der Seite gibt es mit entsprechenden Rechte ![Repository-Settings-General-Git](assets/repository-settings-general-git.png) ### Berechtigungen -Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen. +Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global, auf Namespace-Ebene und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen. Die Berechtigungen können jeweils für Gruppen und für Benutzer vergeben werden. Dabei gibt es die Möglichkeiten die Berechtigungen über Berechtigungsrollen zu definieren oder jede Berechtigung einzeln zu vergeben. Die Berechtigungsrollen können in der Administrations-Oberfläche definiert werden. +Berechtigungen auf Namespace-Ebene können über die Einstellungen für Namespaces bearbeitet werden. Diese sind über das Einstellungs-Symbol neben den Namespace-Überschriften auf der Repository-Übersicht erreichbar. + ![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png) Für individuelle Berechtigungen kann man über "Erweitert" einen Dialog öffnen, um jede Berechtigung einzeln zu vergeben. diff --git a/docs/en/user/repo/assets/repository-overview.png b/docs/en/user/repo/assets/repository-overview.png index 452af113c7..59cf55e4d2 100644 Binary files a/docs/en/user/repo/assets/repository-overview.png and b/docs/en/user/repo/assets/repository-overview.png differ diff --git a/docs/en/user/repo/index.md b/docs/en/user/repo/index.md index 4a24bbdaaf..c7db350fd1 100644 --- a/docs/en/user/repo/index.md +++ b/docs/en/user/repo/index.md @@ -24,6 +24,8 @@ Icon | Description ![Repository Sources](assets/repository-overview-sources.png) | Opens the sources overview for the repository ![Repository Settings](assets/repository-overview-settings.png) | Opens the settings for the repository +Clicking the icon on the right-hand side of each namespace caption, you can change additional settings for this namespace. + ### Create a Repository In SCM-Manager new Git, Mercurial & Subversion (SVN) repositories can be created via a form that can be accessed via the "Create Repository" button. A valid name and the repository type are mandatory. diff --git a/docs/en/user/repo/settings.md b/docs/en/user/repo/settings.md index 19d82f45d2..52e7da246e 100644 --- a/docs/en/user/repo/settings.md +++ b/docs/en/user/repo/settings.md @@ -12,10 +12,12 @@ In the danger zone at the bottom you may rename the repository or delete it. If ![Repository-Settings-General-Git](assets/repository-settings-general-git.png) ### Permissions -Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable roles or individual settings. Permissions can be granted globally or repository-specific. Global permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific permissions. +Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable roles or individual settings. Permissions can be granted globally, namespace-wide, or repository-specific. Global permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific permissions. Permissions can be granted to groups or users. It is possible to manage each permission individually or to create roles that contain several permissions. Roles can be defined in the administration area. +Namespace-wide permissions can be configured in the namespace settings. These can be accessed via the settings icon on the right-hand side of the namespace heading in the repository overview. + ![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png) To manage permissions individually, an "Advanced" dialog can be opened to manage every single permission. diff --git a/pom.xml b/pom.xml index d84f7d00a7..b4a3ae24a7 100644 --- a/pom.xml +++ b/pom.xml @@ -903,7 +903,7 @@ - 3.5.6 + 3.5.7 2.1 5.6.2 diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java new file mode 100644 index 0000000000..8276d81aad --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + + +import sonia.scm.HandlerEventType; +import sonia.scm.event.AbstractHandlerEvent; +import sonia.scm.event.Event; + +/** + * The NamespaceEvent is fired if a {@link Namespace} object changes. + * + * @since 2.6.0 + */ +@Event +public class NamespaceEvent extends AbstractHandlerEvent { + + public NamespaceEvent(HandlerEventType eventType, Namespace namespace) { + super(eventType, namespace); + } + + public NamespaceEvent(HandlerEventType eventType, Namespace namespace, Namespace oldNamespace) { + super(eventType, namespace, oldNamespace); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java new file mode 100644 index 0000000000..e7bd25de1a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.HandlerEventType; +import sonia.scm.ModificationHandlerEvent; +import sonia.scm.event.Event; + +/** + * Event which is fired whenever a namespace is modified. + * + * @since 2.6.0 + */ +@Event +public final class NamespaceModificationEvent extends NamespaceEvent implements ModificationHandlerEvent { + + private final Namespace itemBeforeModification; + + public NamespaceModificationEvent(HandlerEventType eventType, Namespace item, Namespace itemBeforeModification) { + super(eventType, item, itemBeforeModification); + this.itemBeforeModification = itemBeforeModification; + } + + @Override + public Namespace getItemBeforeModification() { + return itemBeforeModification; + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java index 1bd2bcd9d2..8f91bcb8cf 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java @@ -21,13 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import org.apache.commons.collections.CollectionUtils; import sonia.scm.security.PermissionObject; import javax.xml.bind.annotation.XmlAccessType; @@ -118,8 +119,7 @@ public class RepositoryPermission implements PermissionObject, Serializable final RepositoryPermission other = (RepositoryPermission) obj; return Objects.equal(name, other.name) - && verbs.size() == other.verbs.size() - && verbs.containsAll(other.verbs) + && (verbs == null && other.verbs == null || verbs != null && other.verbs != null && CollectionUtils.isEqualCollection(verbs, other.verbs)) && Objects.equal(role, other.role) && Objects.equal(groupPermission, other.groupPermission); } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BranchCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BranchCommandBuilder.java index 8de6fc82fc..20a1c0363c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/BranchCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/BranchCommandBuilder.java @@ -21,21 +21,51 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.api; +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; +import sonia.scm.repository.BranchCreatedEvent; +import sonia.scm.repository.Repository; import sonia.scm.repository.spi.BranchCommand; -import java.io.IOException; - /** * @since 2.0 */ public final class BranchCommandBuilder { + private static final Logger LOG = LoggerFactory.getLogger(BranchCommandBuilder.class); + + private final Repository repository; + private final BranchCommand command; + private final ScmEventBus eventBus; + private final BranchRequest request = new BranchRequest(); + + public BranchCommandBuilder(Repository repository, BranchCommand command) { + this(repository, command, ScmEventBus.getInstance()); + } + + /** + * Creates a new {@link BranchCommandBuilder}. + * + * @param command type specific command implementation + * + * @deprecated use {@link #BranchCommandBuilder(Repository, BranchCommand)} instead. + */ + @Deprecated public BranchCommandBuilder(BranchCommand command) { + this(null, command, ScmEventBus.getInstance()); + } + + @VisibleForTesting + BranchCommandBuilder(Repository repository, BranchCommand command, ScmEventBus eventBus) { + this.repository = repository; this.command = command; + this.eventBus = eventBus; } /** @@ -53,17 +83,23 @@ public final class BranchCommandBuilder { * Execute the command and create a new branch with the given name. * @param name The name of the new branch. * @return The created branch. - * @throws IOException */ public Branch branch(String name) { request.setNewBranch(name); - return command.branch(request); + Branch branch = command.branch(request); + fireCreatedEvent(branch); + return branch; + } + + private void fireCreatedEvent(Branch branch) { + if (repository != null) { + eventBus.post(new BranchCreatedEvent(repository, branch.getName())); + } else { + LOG.warn("the branch command was created without a repository, so we are not able to fire a BranchCreatedEvent"); + } } public void delete(String branchName) { command.deleteOrClose(branchName); } - - private BranchCommand command; - private BranchRequest request = new BranchRequest(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 43f443b4bb..15f84a4ecd 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -177,7 +177,7 @@ public final class RepositoryService implements Closeable { LOG.debug("create branch command for repository {}", repository.getNamespaceAndName()); - return new BranchCommandBuilder(provider.getBranchCommand()); + return new BranchCommandBuilder(repository, provider.getBranchCommand()); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java index d7074b7f4b..b030d1cb0c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java @@ -298,10 +298,12 @@ public final class RepositoryServiceFactory { /** * Clear caches on repository push. + * We do this synchronously, because there are often workflows which are creating branches and fetch them straight + * after the creation. * * @param event hook event */ - @Subscribe(referenceType = ReferenceType.STRONG) + @Subscribe(async = false, referenceType = ReferenceType.STRONG) public void onEvent(PostReceiveRepositoryHookEvent event) { Repository repository = event.getRepository(); @@ -324,13 +326,6 @@ public final class RepositoryServiceFactory { } } - @Subscribe(async = false) - @SuppressWarnings({"unchecked", "java:S3740", "rawtypes"}) - public void onEvent(BranchCreatedEvent event) { - RepositoryCacheKeyPredicate predicate = new RepositoryCacheKeyPredicate(event.getRepository().getId()); - cacheManager.getCache(BranchesCommandBuilder.CACHE_NAME).removeAll(predicate); - } - @Subscribe public void onEvent(PublicKeyDeletedEvent event) { cacheManager.getCache(LogCommandBuilder.CACHE_NAME).clear(); diff --git a/scm-core/src/test/java/sonia/scm/repository/api/BranchCommandBuilderTest.java b/scm-core/src/test/java/sonia/scm/repository/api/BranchCommandBuilderTest.java new file mode 100644 index 0000000000..ddf51ac3c6 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/BranchCommandBuilderTest.java @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.Branch; +import sonia.scm.repository.BranchCreatedEvent; +import sonia.scm.repository.Repository; +import sonia.scm.repository.spi.BranchCommand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BranchCommandBuilderTest { + + @Mock + private BranchCommand command; + + @Mock + private ScmEventBus eventBus; + + private final Repository repository = new Repository("42", "git", "spaceships", "heart-of-gold"); + + private final String branchName = "feature/infinite_improbability_drive"; + private final Branch branch = Branch.normalBranch(branchName, "42"); + + @Nested + class Creation { + + @BeforeEach + void configureMocks() { + when(command.branch(any())).thenReturn(branch); + } + + @Test + void shouldDelegateCreationToCommand() { + BranchCommandBuilder builder = new BranchCommandBuilder(repository, command, eventBus); + Branch returnedBranch = builder.branch(branchName); + assertThat(branch).isSameAs(returnedBranch); + } + + @Test + void shouldSendBranchCreatedEvent() { + BranchCommandBuilder builder = new BranchCommandBuilder(repository, command, eventBus); + builder.branch(branchName); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BranchCreatedEvent.class); + verify(eventBus).post(captor.capture()); + BranchCreatedEvent event = captor.getValue(); + assertThat(event.getBranchName()).isEqualTo("feature/infinite_improbability_drive"); + } + + @Test + void shouldNotSendEventWithoutRepository() { + BranchCommandBuilder builder = new BranchCommandBuilder(null, command, eventBus); + builder.branch(branchName); + + verify(eventBus, never()).post(any()); + } + + } + + @Nested + class Deletion { + + @Test + void shouldDelegateDeletionToCommand() { + BranchCommandBuilder builder = new BranchCommandBuilder(repository, command, eventBus); + builder.delete(branchName); + verify(command).deleteOrClose(branchName); + } + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java index 43bfb68578..fd2ddf013d 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java @@ -30,7 +30,6 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Ref; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; -import sonia.scm.repository.BranchCreatedEvent; import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.PostReceiveRepositoryHookEvent; @@ -48,9 +47,7 @@ import java.io.IOException; import java.util.List; import java.util.Set; -import static java.util.Collections.emptyList; -import static java.util.Collections.singleton; -import static java.util.Collections.singletonList; +import static java.util.Collections.*; import static sonia.scm.ContextEntry.ContextBuilder.entity; public class GitBranchCommand extends AbstractGitCommand implements BranchCommand { @@ -72,8 +69,6 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); Ref ref = git.branchCreate().setStartPoint(request.getParentBranch()).setName(request.getNewBranch()).call(); eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); - // Clear cache synchronously to avoid branch not found in invalid cache - eventBus.post(new BranchCreatedEvent(repository, request.getNewBranch())); return Branch.normalBranch(request.getNewBranch(), GitUtil.getId(ref.getObjectId())); } catch (GitAPIException | IOException ex) { throw new InternalRepositoryException(repository, "could not create branch " + request.getNewBranch(), ex); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java index 7338c447fb..0df9332cfe 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java @@ -104,7 +104,8 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { @Test public void shouldThrowExceptionWhenDeletingDefaultBranch() { String branchToBeDeleted = "master"; - assertThrows(CannotDeleteDefaultBranchException.class, () -> createCommand().deleteOrClose(branchToBeDeleted)); + GitBranchCommand command = createCommand(); + assertThrows(CannotDeleteDefaultBranchException.class, () -> command.deleteOrClose(branchToBeDeleted)); } private GitBranchCommand createCommand() { @@ -130,7 +131,6 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { List events = captor.getAllValues(); assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class); assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class); - assertThat(events.get(2)).isInstanceOf(BranchCreatedEvent.class); PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0); assertThat(event.getContext().getBranchProvider().getCreatedOrModified()).containsExactly("new_branch"); diff --git a/scm-ui/ui-scripts/package.json b/scm-ui/ui-scripts/package.json index 1848823346..fff1de4a03 100644 --- a/scm-ui/ui-scripts/package.json +++ b/scm-ui/ui-scripts/package.json @@ -14,7 +14,7 @@ "babel-loader": "^8.0.6", "css-loader": "^3.2.0", "file-loader": "^4.2.0", - "mini-css-extract-plugin": "^0.10.0", + "mini-css-extract-plugin": "^0.11.0", "mustache": "^3.1.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "react-refresh": "^0.8.0", diff --git a/scm-ui/ui-webapp/public/locales/de/namespaces.json b/scm-ui/ui-webapp/public/locales/de/namespaces.json new file mode 100644 index 0000000000..7a687de4ff --- /dev/null +++ b/scm-ui/ui-webapp/public/locales/de/namespaces.json @@ -0,0 +1,9 @@ +{ + "namespaceRoot": { + "menu": { + "navigationLabel": "Namespace", + "settingsNavLink": "Einstellungen", + "permissionsNavLink": "Berechtigungen" + } + } +} diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 68e28e5d35..2e19a4f392 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -218,7 +218,8 @@ "renameRepo": { "button": "Repository umbenennen", "subtitle": "Benennt dieses Repository um", - "description": "Es werden keine Weiterleitung auf den neuen Namen eingerichtet.", + "description1": "Es werden keine Weiterleitung auf den neuen Namen eingerichtet.", + "description2": "Berechtigungen aus dem Namespace werden bei einem Wechsel nicht übernommen.", "modal": { "title": "Repository umbenennen", "label": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index d6e003a499..d0d36d1e54 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -218,7 +218,8 @@ "renameRepo": { "button": "Rename Repository", "subtitle": "Renames this repository", - "description": "There will be no redirects to the renamed repository.", + "description1": "There will be no redirects to the renamed repository.", + "description2": "Permissions from the namespace will not be adapted when the namespace is changed.", "modal": { "title": "Rename repository", "label": { diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts index e895053f88..4a917d15e4 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts @@ -73,21 +73,29 @@ it("should group the repositories by their namespace", () => { hitchhikerHeartOfGold, hitchhikerPuzzle42 ]; + const namespaces = { + _embedded: { + namespaces: [{ namespace: "hitchhiker" }, { namespace: "slarti" }, { namespace: "zaphod" }] + } + }; const expected = [ { name: "hitchhiker", + namespace: { namespace: "hitchhiker" }, repositories: [hitchhikerHeartOfGold, hitchhikerPuzzle42, hitchhikerRestand] }, { name: "slarti", + namespace: { namespace: "slarti" }, repositories: [slartiFjords, slartiBlueprintsFjords] }, { name: "zaphod", + namespace: { namespace: "zaphod" }, repositories: [zaphodMarvinFirmware] } ]; - expect(groupByNamespace(repositories)).toEqual(expected); + expect(groupByNamespace(repositories, namespaces)).toEqual(expected); }); diff --git a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx index 3844717487..0b2bda04b9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx @@ -76,7 +76,7 @@ const DangerZone: FC = ({ repository, indexLinks }) => { <>
- {dangerZone.map(entry => entry)} + {dangerZone} ); }; diff --git a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx index 9254774a6d..0860e84156 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx @@ -159,7 +159,9 @@ const RenameRepository: FC = ({ repository, indexLinks }) => {

{t("renameRepo.subtitle")}
- {t("renameRepo.description")} + {t("renameRepo.description1")} +
+ {t("renameRepo.description2")}

} right={ diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx index 52c0ee5b3b..a3cbb89610 100644 --- a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -123,7 +123,6 @@ class NamespaceRoot extends React.Component { ); - // return

{`HALLO ${this.props.namespace}`}

; } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java index 6ec97c282b..bb65ed905b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java @@ -34,7 +34,6 @@ import lombok.extern.slf4j.Slf4j; import sonia.scm.NotFoundException; import sonia.scm.repository.Namespace; import sonia.scm.repository.NamespaceManager; -import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.VndMediaType; @@ -50,7 +49,6 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; import java.net.URI; -import java.util.Collection; import java.util.Optional; import java.util.function.Predicate; @@ -120,7 +118,6 @@ public class NamespacePermissionResource { public Response create(@PathParam("namespace") String namespaceName, @Valid RepositoryPermissionDto permission) { log.info("try to add new permission: {}", permission); Namespace namespace = load(namespaceName); - NamespacePermissions.permissionWrite().check(); checkPermissionAlreadyExists(permission, namespace); namespace.addPermission(dtoToModelMapper.map(permission)); manager.modify(namespace); @@ -164,7 +161,6 @@ public class NamespacePermissionResource { ) public RepositoryPermissionDto get(@PathParam("namespace") String namespaceName, @PathParam("permission-name") String permissionName) { Namespace namespace = load(namespaceName); - NamespacePermissions.permissionRead().check(); return namespace.getPermissions() .stream() @@ -210,7 +206,6 @@ public class NamespacePermissionResource { ) public HalRepresentation getAll(@PathParam("namespace") String namespaceMame) { Namespace namespace = load(namespaceMame); - NamespacePermissions.permissionRead().check(); return repositoryPermissionCollectionToDtoMapper.map(namespace); } @@ -241,7 +236,6 @@ public class NamespacePermissionResource { @Valid RepositoryPermissionDto permission) { log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission); Namespace namespace = load(namespaceName); - NamespacePermissions.permissionWrite().check(); String extractedPermissionName = getPermissionName(permissionName); if (!isPermissionExist(new RepositoryPermissionDto(extractedPermissionName, isGroupPermission(permissionName)), namespace)) { throw notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName)); @@ -289,7 +283,6 @@ public class NamespacePermissionResource { @PathParam("permission-name") String permissionName) { log.info("try to delete the permission with name: {}.", permissionName); Namespace namespace = load(namespaceName); - NamespacePermissions.permissionWrite().check(); namespace.getPermissions() .stream() .filter(filterPermission(permissionName)) diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java index e0d6ec9117..7088fbc28d 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java @@ -24,6 +24,10 @@ package sonia.scm.repository; +import com.github.legman.Subscribe; +import sonia.scm.HandlerEventType; +import sonia.scm.event.ScmEventBus; + import javax.inject.Inject; import java.util.Collection; import java.util.Optional; @@ -36,11 +40,13 @@ public class DefaultNamespaceManager implements NamespaceManager { private final RepositoryManager repositoryManager; private final NamespaceDao dao; + private final ScmEventBus eventBus; @Inject - public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao) { + public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao, ScmEventBus eventBus) { this.repositoryManager = repositoryManager; this.dao = dao; + this.eventBus = eventBus; } @Override @@ -64,15 +70,45 @@ public class DefaultNamespaceManager implements NamespaceManager { @Override public void modify(Namespace namespace) { - if (!get(namespace.getNamespace()).isPresent()) { - throw notFound(entity("Namespace", namespace.getNamespace())); - } + NamespacePermissions.permissionWrite().check(); + Namespace oldNamespace = get(namespace.getNamespace()) + .orElseThrow(() -> notFound(entity(Namespace.class, namespace.getNamespace()))); + fireEvent(HandlerEventType.BEFORE_MODIFY, namespace, oldNamespace); dao.add(namespace); + fireEvent(HandlerEventType.MODIFY, namespace, oldNamespace); + } + + @Subscribe + public void cleanupDeletedNamespaces(RepositoryEvent repositoryEvent) { + HandlerEventType eventType = repositoryEvent.getEventType(); + if (eventType == HandlerEventType.DELETE || eventType == HandlerEventType.MODIFY && !repositoryEvent.getItem().getNamespace().equals(repositoryEvent.getOldItem().getNamespace())) { + Collection allNamespaces = repositoryManager.getAllNamespaces(); + String oldNamespace = getOldNamespace(repositoryEvent); + if (!allNamespaces.contains(oldNamespace)) { + dao.delete(oldNamespace); + } + } + } + + public String getOldNamespace(RepositoryEvent repositoryEvent) { + if (repositoryEvent.getEventType() == HandlerEventType.DELETE) { + return repositoryEvent.getItem().getNamespace(); + } else { + return repositoryEvent.getOldItem().getNamespace(); + } } private Namespace createNamespaceForName(String namespace) { - return dao.get(namespace) - .map(Namespace::clone) - .orElse(new Namespace(namespace)); + if (NamespacePermissions.permissionRead().isPermitted()) { + return dao.get(namespace) + .map(Namespace::clone) + .orElse(new Namespace(namespace)); + } else { + return new Namespace(namespace); + } + } + + protected void fireEvent(HandlerEventType event, Namespace namespace, Namespace oldNamespace) { + eventBus.post(new NamespaceModificationEvent(event, namespace, oldNamespace)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java index a566ba879a..cc90f2c350 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java @@ -46,4 +46,8 @@ public class NamespaceDao { public void add(Namespace namespace) { store.put(namespace.getNamespace(), namespace); } + + public void delete(String namespace) { + store.remove(namespace); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index a198dafb24..c1b5eb9f59 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.github.legman.Subscribe; @@ -35,14 +35,19 @@ import sonia.scm.event.ScmEventBus; import sonia.scm.group.Group; import sonia.scm.group.GroupEvent; import sonia.scm.group.GroupModificationEvent; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceEvent; +import sonia.scm.repository.NamespaceModificationEvent; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryEvent; import sonia.scm.repository.RepositoryModificationEvent; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserEvent; import sonia.scm.user.UserModificationEvent; import javax.inject.Singleton; +import java.util.Collection; /** * Receives all kinds of events, which affects authorization relevant data and fires an @@ -146,23 +151,45 @@ public class AuthorizationChangedEventProducer { } } + @Subscribe + public void onEvent(NamespaceEvent event) { + if (event.getEventType().isPost() && isModificationEvent(event)) { + handleNamespaceModificationEvent((NamespaceModificationEvent) event); + } + } + private void handleRepositoryModificationEvent(RepositoryModificationEvent event) { Repository repository = event.getItem(); - if (isAuthorizationDataModified(repository, event.getItemBeforeModification())) { + if (isAuthorizationDataModified(repository.getPermissions(), event.getItemBeforeModification().getPermissions())) { logger.debug( - "fire authorization changed event, because a relevant field of repository {} has changed", repository.getName() + "fire authorization changed event, because a relevant field of repository {}/{} has changed", repository.getNamespace(), repository.getName() ); fireEventForEveryUser(); } else { logger.debug( - "authorization changed event is not fired, because non relevant field of repository {} has changed", - repository.getName() + "authorization changed event is not fired, because non relevant field of repository {}/{} has changed", + repository.getNamespace(), repository.getName() ); } } - private boolean isAuthorizationDataModified(Repository repository, Repository beforeModification) { - return !(repository.getPermissions().containsAll(beforeModification.getPermissions()) && beforeModification.getPermissions().containsAll(repository.getPermissions())); + private void handleNamespaceModificationEvent(NamespaceModificationEvent event) { + Namespace namespace = event.getItem(); + if (isAuthorizationDataModified(namespace.getPermissions(), event.getItemBeforeModification().getPermissions())) { + logger.debug( + "fire authorization changed event, because a relevant field of namespace {} has changed", namespace.getNamespace() + ); + fireEventForEveryUser(); + } else { + logger.debug( + "authorization changed event is not fired, because non relevant field of namespace {} has changed", + namespace.getNamespace() + ); + } + } + + private boolean isAuthorizationDataModified(Collection newPermissions, Collection permissionsBeforeModification) { + return !(newPermissions.containsAll(permissionsBeforeModification) && permissionsBeforeModification.containsAll(newPermissions)); } private void fireEventForEveryUser() { diff --git a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml index bc50fbd3da..0889341d25 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -44,6 +44,12 @@ repository:create + + namespace:permissionRead + + + namespace:permissionWrite + user:* diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java index 736e1a90d1..80e0e26c8a 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -24,11 +24,14 @@ package sonia.scm.repository; +import com.github.legman.EventBus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.HandlerEventType; +import sonia.scm.event.ScmEventBus; import sonia.scm.store.InMemoryDataStore; import sonia.scm.store.InMemoryDataStoreFactory; @@ -37,7 +40,11 @@ import java.util.Optional; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static sonia.scm.HandlerEventType.DELETE; +import static sonia.scm.HandlerEventType.MODIFY; @ExtendWith(MockitoExtension.class) @@ -45,6 +52,8 @@ class DefaultNamespaceManagerTest { @Mock RepositoryManager repositoryManager; + @Mock + ScmEventBus eventBus; Namespace life; @@ -56,7 +65,7 @@ class DefaultNamespaceManagerTest { @BeforeEach void mockExistingNamespaces() { dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore())); - manager = new DefaultNamespaceManager(repositoryManager, dao); + manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus); when(repositoryManager.getAllNamespaces()).thenReturn(asList("life", "universe", "rest")); @@ -115,5 +124,29 @@ class DefaultNamespaceManagerTest { Namespace newLife = manager.get("life").get(); assertThat(newLife).isEqualTo(modifiedNamespace); + verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent)event).getEventType() == HandlerEventType.BEFORE_MODIFY)); + verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent)event).getEventType() == HandlerEventType.MODIFY)); + } + + @Test + void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasDeleted() { + when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest")); + + manager.cleanupDeletedNamespaces(new RepositoryEvent(DELETE, new Repository("1", "git", "life", "earth"))); + + assertThat(dao.get("life")).isEmpty(); + } + + @Test + void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasRenamed() { + when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest", "highway")); + + manager.cleanupDeletedNamespaces( + new RepositoryModificationEvent( + MODIFY, + new Repository("1", "git", "highway", "earth"), + new Repository("1", "git", "life", "earth"))); + + assertThat(dao.get("life")).isEmpty(); } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java index 30aa0d8091..ae5405de50 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Lists; @@ -31,6 +31,8 @@ import sonia.scm.HandlerEventType; import sonia.scm.group.Group; import sonia.scm.group.GroupEvent; import sonia.scm.group.GroupModificationEvent; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceModificationEvent; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryEvent; import sonia.scm.repository.RepositoryModificationEvent; @@ -251,6 +253,55 @@ public class AuthorizationChangedEventProducerTest { assertUserEventIsFired("trillian"); } + @Test + public void testOnNamespaceModificationEvent() + { + Namespace namespaceModified = new Namespace("hitchhiker"); + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + + Namespace namespace = new Namespace("hitchhiker"); + namespace.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.BEFORE_CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test123", singletonList("read"), false))); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), true)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + namespace.setPermissions(Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false))); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", asList("write", "read"), false)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + } + private static class StoringAuthorizationChangedEventProducer extends AuthorizationChangedEventProducer { private AuthorizationChangedEvent event;