From 89c4a20dd5c4b65a128fc0bb1bf388f02bc4fa80 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 8 Feb 2024 14:18:16 +0100 Subject: [PATCH] Add namespace owner on namespace creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Namespace owner may see the namespace configuration page. Committed-by: René Pfeuffer --- gradle/gradle/namespace_config.yaml | 4 + scm-core/src/main/java/sonia/scm/Manager.java | 15 +++- .../java/sonia/scm/repository/Namespace.java | 3 +- .../src/layout/NamespaceEntries.tsx | 2 +- .../public/locales/de/namespaces.json | 10 ++- .../public/locales/en/namespaces.json | 10 ++- .../containers/NamespaceInformation.tsx | 80 +++++++++++++++++++ .../namespaces/containers/NamespaceRoot.tsx | 42 ++++++---- .../NamespaceToNamespaceDtoMapper.java | 2 +- ...sitoryPermissionCollectionToDtoMapper.java | 4 +- ...issionToRepositoryPermissionDtoMapper.java | 2 +- .../repository/DefaultNamespaceManager.java | 55 +++++++++---- .../NamespacePermissionsUpdateStep.java | 79 ++++++++++++++++++ .../resources/META-INF/scm/permissions.xml | 4 +- .../main/resources/locales/de/plugins.json | 12 ++- .../main/resources/locales/en/plugins.json | 12 ++- .../resources/NamespaceRootResourceTest.java | 14 ++-- .../DefaultNamespaceManagerTest.java | 39 +++++++-- .../NamespacePermissionsUpdateStepTest.java | 56 +++++++++++++ 19 files changed, 384 insertions(+), 61 deletions(-) create mode 100644 gradle/gradle/namespace_config.yaml create mode 100644 scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceInformation.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/update/security/NamespacePermissionsUpdateStep.java create mode 100644 scm-webapp/src/test/java/sonia/scm/update/security/NamespacePermissionsUpdateStepTest.java diff --git a/gradle/gradle/namespace_config.yaml b/gradle/gradle/namespace_config.yaml new file mode 100644 index 0000000000..c7fc512aec --- /dev/null +++ b/gradle/gradle/namespace_config.yaml @@ -0,0 +1,4 @@ +- type: added + description: Namespace information page +- type: changed + description: Namespace configuration permissions diff --git a/scm-core/src/main/java/sonia/scm/Manager.java b/scm-core/src/main/java/sonia/scm/Manager.java index 66a24fbbd1..892c6d59fb 100644 --- a/scm-core/src/main/java/sonia/scm/Manager.java +++ b/scm-core/src/main/java/sonia/scm/Manager.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; import java.util.Collection; @@ -65,12 +65,23 @@ public interface Manager */ Collection getAll(); + /** + * Returns all object of the store unsorted + * + * @param filter to filter the returned objects + * @since 3.1.0 + * @return all object of the store sorted by the given {@link java.util.Comparator} + */ + default Collection getAll(Predicate filter) { + return getAll(filter, null); + } + /** * Returns all object of the store sorted by the given {@link java.util.Comparator} * * * @param filter to filter the returned objects - * @param comparator to sort the returned objects + * @param comparator to sort the returned objects (may be null if no sorting is needed) * @since 1.4 * @return all object of the store sorted by the given {@link java.util.Comparator} */ diff --git a/scm-core/src/main/java/sonia/scm/repository/Namespace.java b/scm-core/src/main/java/sonia/scm/repository/Namespace.java index 7d00dab13d..af4ad386bf 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Namespace.java +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -41,8 +41,7 @@ import static java.util.Collections.unmodifiableCollection; @StaticPermissions( value = "namespace", - globalPermissions = {"permissionRead", "permissionWrite"}, - permissions = {}, + permissions = {"permissionRead", "permissionWrite"}, custom = true, customGlobal = true ) @XmlAccessorType(XmlAccessType.FIELD) diff --git a/scm-ui/ui-components/src/layout/NamespaceEntries.tsx b/scm-ui/ui-components/src/layout/NamespaceEntries.tsx index 9f94b0ba2a..efc93f811d 100644 --- a/scm-ui/ui-components/src/layout/NamespaceEntries.tsx +++ b/scm-ui/ui-components/src/layout/NamespaceEntries.tsx @@ -41,7 +41,7 @@ const DefaultGroupHeader: FC<{ group: RepositoryGroup }> = ({ group }) => {

{group.name}

{" "} - + diff --git a/scm-ui/ui-webapp/public/locales/de/namespaces.json b/scm-ui/ui-webapp/public/locales/de/namespaces.json index c3cfdcb33b..db729b3cfd 100644 --- a/scm-ui/ui-webapp/public/locales/de/namespaces.json +++ b/scm-ui/ui-webapp/public/locales/de/namespaces.json @@ -3,7 +3,15 @@ "menu": { "navigationLabel": "Namespace", "settingsNavLink": "Einstellungen", - "permissionsNavLink": "Berechtigungen" + "permissionsNavLink": "Berechtigungen", + "informationNavLink": "Informationen" + }, + "infoPage": { + "subtitle": "Informationen", + "repository": "Repository", + "type": "Typ", + "contact": "Kontakt", + "lastModified": "Zuletzt geàˆndert" } }, "repositoryOverview": { diff --git a/scm-ui/ui-webapp/public/locales/en/namespaces.json b/scm-ui/ui-webapp/public/locales/en/namespaces.json index a267c03dd7..1feefafcc9 100644 --- a/scm-ui/ui-webapp/public/locales/en/namespaces.json +++ b/scm-ui/ui-webapp/public/locales/en/namespaces.json @@ -3,7 +3,15 @@ "menu": { "navigationLabel": "Namespace", "settingsNavLink": "Settings", - "permissionsNavLink": "Permissions" + "permissionsNavLink": "Permissions", + "informationNavLink": "Information" + }, + "infoPage": { + "subtitle": "Information", + "repository": "Repository", + "type": "Type", + "contact": "Contact", + "lastModified": "Last modified" } }, "repositoryOverview": { diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceInformation.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceInformation.tsx new file mode 100644 index 0000000000..0c5d6f07bb --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceInformation.tsx @@ -0,0 +1,80 @@ +/* + * 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. + */ + +import { Namespace } from "@scm-manager/ui-types"; +import React, { FC } from "react"; +import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-core"; +import { useTranslation } from "react-i18next"; +import { useRepositories } from "@scm-manager/ui-api"; +import { DateFromNow } from "@scm-manager/ui-components"; +import { Link } from "react-router-dom"; + +type Props = { + namespace: Namespace; +}; + +const NamespaceInformation: FC = ({ namespace }) => { + const [t] = useTranslation("namespaces"); + const { data: repositories, error, isLoading } = useRepositories({ namespace: namespace, pageSize: 9999, page: 0 }); + + if (error) { + return ; + } + + if (isLoading) { + return ; + } + + return ( +
+ + + + + + + + + + + + {repositories?._embedded?.repositories.map((repository) => ( + + + + + + + ))} + +
{t("namespaceRoot.infoPage.repository")}{t("namespaceRoot.infoPage.type")}{t("namespaceRoot.infoPage.contact")}{t("namespaceRoot.infoPage.lastModified")}
+ {repository.name} + {repository.type}{repository.contact} + +
+
+ ); +}; + +export default NamespaceInformation; 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 67b68ebe2d..383824c0c0 100644 --- a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -24,11 +24,12 @@ import React, { FC, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; import { CustomQueryFlexWrappedColumns, ErrorPage, Loading, + NavLink, Page, PrimaryContentColumn, SecondaryNavigation, @@ -37,9 +38,10 @@ import { urls, } from "@scm-manager/ui-components"; import Permissions from "../../permissions/containers/Permissions"; -import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import PermissionsNavLink from "./PermissionsNavLink"; import { useNamespace, useNamespaceAndNameContext } from "@scm-manager/ui-api"; +import NamespaceInformation from "./NamespaceInformation"; type Params = { namespaceName: string; @@ -81,7 +83,9 @@ const NamespaceRoot: FC = () => { - + + + @@ -94,23 +98,31 @@ const NamespaceRoot: FC = () => { + name="namespace.navigation.topLevel" props={extensionProps} renderAll={true} /> - - - - name="namespace.setting" - props={extensionProps} - renderAll={true} - /> - + {binder.hasExtension("namespace.setting", extensionProps) || namespace._links.permissions ? ( + + + + name="namespace.setting" + props={extensionProps} + renderAll={true} + /> + + ) : null} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java index 0932fefec1..e3233668a9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java @@ -75,7 +75,7 @@ public abstract class NamespaceToNamespaceDtoMapper extends BaseMapper notFound(entity(Namespace.class, namespace.getNamespace()))); fireEvent(HandlerEventType.BEFORE_MODIFY, namespace, oldNamespace); @@ -80,18 +84,41 @@ public class DefaultNamespaceManager implements NamespaceManager { fireEvent(HandlerEventType.MODIFY, namespace, oldNamespace); } - @Subscribe - public void cleanupDeletedNamespaces(RepositoryEvent repositoryEvent) { - if (namespaceRelevantChange(repositoryEvent)) { - Collection allNamespaces = repositoryManager.getAllNamespaces(); - String oldNamespace = getOldNamespace(repositoryEvent); - if (!allNamespaces.contains(oldNamespace)) { - dao.delete(oldNamespace); - } + @Subscribe(async = false) + public void handleRepositoryEvent(RepositoryEvent repositoryEvent) { + if (repositoryRemovedFromNamespace(repositoryEvent)) { + cleanUpNamespaceIfEmpty(repositoryEvent); + } + if (repositoryCreatedInNamespace(repositoryEvent)) { + initializeIfNeeded(repositoryEvent); } } - public boolean namespaceRelevantChange(RepositoryEvent repositoryEvent) { + private static boolean repositoryCreatedInNamespace(RepositoryEvent repositoryEvent) { + return repositoryEvent.getEventType() == HandlerEventType.CREATE; + } + + private void cleanUpNamespaceIfEmpty(RepositoryEvent repositoryEvent) { + Collection allNamespaces = repositoryManager.getAllNamespaces(); + String oldNamespace = getOldNamespace(repositoryEvent); + if (!allNamespaces.contains(oldNamespace)) { + dao.delete(oldNamespace); + } + } + + private void initializeIfNeeded(RepositoryEvent repositoryEvent) { + Namespace namespace = createNamespaceForName(repositoryEvent.getItem().getNamespace()); + if (repositoryManager.getAll(r -> r.getNamespace().equals(namespace.getNamespace())).size() == 1) { + String creatingUser = SecurityUtils.getSubject().getPrincipal().toString(); + administrationContext.runAsAdmin(() -> { + namespace.setPermissions(singletonList(new RepositoryPermission(creatingUser, "OWNER", false))); + modify(namespace); + } + ); + } + } + + public boolean repositoryRemovedFromNamespace(RepositoryEvent repositoryEvent) { HandlerEventType eventType = repositoryEvent.getEventType(); return eventType == HandlerEventType.DELETE || eventType == HandlerEventType.MODIFY && !repositoryEvent.getItem().getNamespace().equals(repositoryEvent.getOldItem().getNamespace()); @@ -106,7 +133,7 @@ public class DefaultNamespaceManager implements NamespaceManager { } private Namespace createNamespaceForName(String namespace) { - if (NamespacePermissions.permissionRead().isPermitted()) { + if (NamespacePermissions.permissionRead().isPermitted(namespace)) { return dao.get(namespace) .map(Namespace::clone) .orElse(new Namespace(namespace)); diff --git a/scm-webapp/src/main/java/sonia/scm/update/security/NamespacePermissionsUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/security/NamespacePermissionsUpdateStep.java new file mode 100644 index 0000000000..ac6839c4b9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/security/NamespacePermissionsUpdateStep.java @@ -0,0 +1,79 @@ +/* + * 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.update.security; + +import jakarta.inject.Inject; +import sonia.scm.migration.UpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.security.AssignedPermission; +import sonia.scm.store.ConfigurationEntryStore; +import sonia.scm.store.ConfigurationEntryStoreFactory; +import sonia.scm.version.Version; + +import java.util.HashSet; + +@Extension +public class NamespacePermissionsUpdateStep implements UpdateStep { + + private final ConfigurationEntryStoreFactory configurationEntryStoreFactory; + + @Inject + public NamespacePermissionsUpdateStep(ConfigurationEntryStoreFactory configurationEntryStoreFactory) { + this.configurationEntryStoreFactory = configurationEntryStoreFactory; + } + + @Override + public void doUpdate() throws Exception { + ConfigurationEntryStore securityStore = createSecurityStore(); + HashSet toBeRemoved = new HashSet<>(); + HashSet toBeAdded = new HashSet<>(); + securityStore.getAll().forEach((k, v) -> { + if (v.getPermission().getValue().equals("namespace:permissionRead")) { + toBeAdded.add(new AssignedPermission(v.getName(), v.isGroupPermission(), "namespace:permissionRead:*")); + toBeRemoved.add(k); + } + if (v.getPermission().getValue().equals("namespace:permissionRead,permissionWrite")) { + toBeAdded.add(new AssignedPermission(v.getName(), v.isGroupPermission(), "namespace:permissionRead,permissionWrite:*")); + toBeRemoved.add(k); + } + }); + toBeAdded.forEach(securityStore::put); + toBeRemoved.forEach(securityStore::remove); + } + + private ConfigurationEntryStore createSecurityStore() { + return configurationEntryStoreFactory.withType(AssignedPermission.class).withName("security").build(); + } + + @Override + public Version getTargetVersion() { + return Version.parse("3.1.0"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.security.xml"; + } +} 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 79a6a82eb8..b6f4ef688c 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -53,10 +53,10 @@ repository:read,export:* - namespace:permissionRead + namespace:permissionRead:* - namespace:permissionRead,permissionWrite + namespace:permissionRead,permissionWrite:* user:* diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index e8f5c96452..03493c5718 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -117,12 +117,16 @@ }, "namespace": { "permissionRead": { - "displayName": "Berechtigungen auf Namespaces lesen", - "description": "Darf die Berechtigungen auf Namespace-Ebene sehen" + "*": { + "displayName": "Berechtigungen auf Namespaces lesen", + "description": "Darf die Berechtigungen auf Namespace-Ebene sehen" + } }, "permissionRead,permissionWrite": { - "displayName": "Berechtigungen auf Namespaces modifizieren", - "description": "Darf die Berechtigungen auf Namespace-Ebene lesen und bearbeiten" + "*": { + "displayName": "Berechtigungen auf Namespaces modifizieren", + "description": "Darf die Berechtigungen auf Namespace-Ebene lesen und bearbeiten" + } } }, "metrics": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 1d558bd31f..bc0c16b64e 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -117,12 +117,16 @@ }, "namespace": { "permissionRead": { - "displayName": "Read permissions on namespaces", - "description": "May see the permissions set for namespaces" + "*": { + "displayName": "Read permissions on namespaces", + "description": "May see the permissions set for namespaces" + } }, "permissionRead,permissionWrite": { - "displayName": "Modify permissions on namespaces", - "description": "May read and modify the permissions set for namespaces" + "*": { + "displayName": "Modify permissions on namespaces", + "description": "May read and modify the permissions set for namespaces" + } } }, "metrics": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java index 52cd180e96..fca040bd2d 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java @@ -132,8 +132,8 @@ class NamespaceRootResourceTest { @BeforeEach void mockNoPermissions() { lenient().when(subject.isPermitted(anyString())).thenReturn(false); - lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionRead"); - lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionRead:space"); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite:space"); } @Test @@ -202,8 +202,8 @@ class NamespaceRootResourceTest { @BeforeEach void grantReadPermission() { - lenient().when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); - lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(false); + lenient().when(subject.isPermitted("namespace:permissionRead:space")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite:space")).thenReturn(false); lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); } @@ -259,8 +259,10 @@ class NamespaceRootResourceTest { @BeforeEach void grantWritePermission() { - lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(true); - lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite"); + lenient().when(subject.isPermitted("namespace:permissionWrite:space")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite:hitchhiker")).thenReturn(true); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite:space"); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite:hitchhiker"); } @Test 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 9d95dc928a..8e3ae078dc 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -39,16 +39,23 @@ import sonia.scm.HandlerEventType; import sonia.scm.event.ScmEventBus; import sonia.scm.store.InMemoryDataStore; import sonia.scm.store.InMemoryDataStoreFactory; +import sonia.scm.web.security.AdministrationContext; +import sonia.scm.web.security.PrivilegedAction; import java.util.Collection; import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static sonia.scm.HandlerEventType.CREATE; import static sonia.scm.HandlerEventType.DELETE; import static sonia.scm.HandlerEventType.MODIFY; @@ -61,6 +68,8 @@ class DefaultNamespaceManagerTest { @Mock ScmEventBus eventBus; @Mock + AdministrationContext administrationContext; + @Mock Subject subject; Namespace life; @@ -84,7 +93,7 @@ class DefaultNamespaceManagerTest { universe = new Namespace("universe"); rest = new Namespace("rest"); - manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus); + manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus, administrationContext); } @BeforeEach @@ -108,7 +117,7 @@ class DefaultNamespaceManagerTest { void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasDeleted() { when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest")); - manager.cleanupDeletedNamespaces(new RepositoryEvent(DELETE, new Repository("1", "git", "life", "earth"))); + manager.handleRepositoryEvent(new RepositoryEvent(DELETE, new Repository("1", "git", "life", "earth"))); assertThat(dao.get("life")).isEmpty(); } @@ -117,7 +126,7 @@ class DefaultNamespaceManagerTest { void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasRenamed() { when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest", "highway")); - manager.cleanupDeletedNamespaces( + manager.handleRepositoryEvent( new RepositoryModificationEvent( MODIFY, new Repository("1", "git", "highway", "earth"), @@ -126,6 +135,26 @@ class DefaultNamespaceManagerTest { assertThat(dao.get("life")).isEmpty(); } + @Test + void shouldCreateOwnerPermissionWhenFirstRepositoryOfNamespaceWasCreated() { + when(subject.getPrincipal()).thenReturn("trillian"); + when(repositoryManager.getAllNamespaces()).thenReturn(asList("rest", "highway", "universe")); + when(repositoryManager.getAll(any())) + .thenAnswer(invocation -> Stream.of(new Repository("1", "git", "universe", "earth")).filter(invocation.getArgument(0, Predicate.class)).toList()); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(administrationContext).runAsAdmin(any(PrivilegedAction.class)); + manager.handleRepositoryEvent( + new RepositoryModificationEvent( + CREATE, + new Repository("1", "git", "universe", "earth"), + null)); + + assertThat(dao.get("universe")).isNotEmpty(); + assertThat(dao.get("universe").get().getPermissions()).extracting("name").contains("trillian"); + } + @Nested class WithPermissionToReadPermissions { @@ -183,8 +212,8 @@ class DefaultNamespaceManagerTest { @BeforeEach void grantReadPermission() { - when(subject.isPermitted("namespace:permissionRead")).thenReturn(false); - lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + when(subject.isPermitted("namespace:permissionRead:*")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite:*"); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/update/security/NamespacePermissionsUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/security/NamespacePermissionsUpdateStepTest.java new file mode 100644 index 0000000000..1f23501caa --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/update/security/NamespacePermissionsUpdateStepTest.java @@ -0,0 +1,56 @@ +/* + * 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.update.security; + +import org.junit.jupiter.api.Test; +import sonia.scm.security.AssignedPermission; +import sonia.scm.store.ConfigurationEntryStore; +import sonia.scm.store.InMemoryByteConfigurationEntryStoreFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +class NamespacePermissionsUpdateStepTest { + + private final InMemoryByteConfigurationEntryStoreFactory entryStoreFactory = new InMemoryByteConfigurationEntryStoreFactory(); + private final NamespacePermissionsUpdateStep updateStep = new NamespacePermissionsUpdateStep(entryStoreFactory); + + @Test + void shouldUpdatePermissions() throws Exception { + ConfigurationEntryStore securityStore = createSecurityStore(); + securityStore.put(new AssignedPermission("trillian", false, "namespace:permissionRead")); + securityStore.put(new AssignedPermission("dent", true, "namespace:permissionRead,permissionWrite")); + + updateStep.doUpdate(); + + assertThat(securityStore.getAll().values()) + .hasSize(2) + .contains(new AssignedPermission("trillian", false, "namespace:permissionRead:*")) + .contains(new AssignedPermission("dent", true, "namespace:permissionRead,permissionWrite:*")); + } + + private ConfigurationEntryStore createSecurityStore() { + return entryStoreFactory.withType(AssignedPermission.class).withName("security").build(); + } +}