From 05ee521e504bd662b91b4a8b3f8c949d80fb742b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 14 Sep 2020 18:52:52 +0200 Subject: [PATCH 01/40] Introduce manager interface for namespaces --- .../java/sonia/scm/repository/Namespace.java | 104 +++++++++++++++ .../scm/repository/NamespaceManager.java | 54 ++++++++ .../repository/DefaultNamespaceManager.java | 78 ++++++++++++ .../repository/DefaultRepositoryManager.java | 4 + .../sonia/scm/repository/NamespaceDao.java | 49 ++++++++ .../DefaultNamespaceManagerTest.java | 119 ++++++++++++++++++ 6 files changed, 408 insertions(+) create mode 100644 scm-core/src/main/java/sonia/scm/repository/Namespace.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java create mode 100644 scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/Namespace.java b/scm-core/src/main/java/sonia/scm/repository/Namespace.java new file mode 100644 index 0000000000..0826a4f1ea --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -0,0 +1,104 @@ +/* + * 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 org.apache.commons.lang.builder.EqualsBuilder; +import org.apache.commons.lang.builder.HashCodeBuilder; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import static java.util.Collections.unmodifiableCollection; + +public class Namespace implements Cloneable { + + private String namespace; + private Set permissions = new HashSet<>(); + + public Namespace(String namespace) { + this.namespace = namespace; + } + + /** + * Constructor for JaxB, only. + */ + Namespace() { + } + + public String getNamespace() { + return namespace; + } + + public Collection getPermissions() { + return unmodifiableCollection(permissions); + } + + public void setPermissions(Collection permissions) { + this.permissions.clear(); + this.permissions.addAll(permissions); + } + + public void addPermission(RepositoryPermission newPermission) { + this.permissions.add(newPermission); + } + + public boolean removePermission(RepositoryPermission permission) { + return this.permissions.remove(permission); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof Namespace)) return false; + + Namespace namespace1 = (Namespace) o; + + return new EqualsBuilder() + .append(namespace, namespace1.namespace) + .append(permissions, namespace1.permissions) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(namespace) + .append(permissions) + .toHashCode(); + } + + @Override + public Namespace clone() { + try { + Namespace clone = (Namespace) super.clone(); + clone.permissions = new HashSet<>(permissions); + return clone; + } catch (CloneNotSupportedException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java new file mode 100644 index 0000000000..bdd8faac7d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java @@ -0,0 +1,54 @@ +/* + * 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 java.util.Collection; +import java.util.Optional; + +public interface NamespaceManager { + + /** + * Returns the Namespace with the given name. + * + * @param namespace The name of the requested namespace. + * @return Optional with the namespace for the given name, or an empty Optional if there is no such namespace + * (that is, there is no repository with this namespace). + */ + Optional get(String namespace); + + /** + * Returns a {@link java.util.Collection} of all namespaces. + * + * @return all namespaces + */ + Collection getAll(); + + /** + * Modifies the given namespace. + * + * @param namespace The namespace to be modified. + */ + void modify(Namespace namespace); +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java new file mode 100644 index 0000000000..e0d6ec9117 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java @@ -0,0 +1,78 @@ +/* + * 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 javax.inject.Inject; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class DefaultNamespaceManager implements NamespaceManager { + + private final RepositoryManager repositoryManager; + private final NamespaceDao dao; + + @Inject + public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao) { + this.repositoryManager = repositoryManager; + this.dao = dao; + } + + @Override + public Optional get(String namespace) { + return repositoryManager + .getAllNamespaces() + .stream() + .filter(n -> n.equals(namespace)) + .map(this::createNamespaceForName) + .findFirst(); + } + + @Override + public Collection getAll() { + return repositoryManager + .getAllNamespaces() + .stream() + .map(this::createNamespaceForName) + .collect(Collectors.toList()); + } + + @Override + public void modify(Namespace namespace) { + if (!get(namespace.getNamespace()).isPresent()) { + throw notFound(entity("Namespace", namespace.getNamespace())); + } + dao.add(namespace); + } + + private Namespace createNamespaceForName(String namespace) { + return dao.get(namespace) + .map(Namespace::clone) + .orElse(new Namespace(namespace)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index c75f54c3fb..075fcca82a 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -331,6 +331,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { return getAll(null, start, limit); } + /** + * @deprecated Use {@link NamespaceManager#getAll()} instead. + */ + @Deprecated @Override public Collection getAllNamespaces() { return getAll().stream() diff --git a/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java new file mode 100644 index 0000000000..a566ba879a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java @@ -0,0 +1,49 @@ +/* + * 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.store.DataStore; +import sonia.scm.store.DataStoreFactory; + +import javax.inject.Inject; +import java.util.Optional; + +public class NamespaceDao { + + private final DataStore store; + + @Inject + NamespaceDao(DataStoreFactory storeFactory) { + this.store = storeFactory.withType(Namespace.class).withName("namespaces").build(); + } + + public Optional get(String namespace) { + return store.getOptional(namespace); + } + + public void add(Namespace namespace) { + store.put(namespace.getNamespace(), namespace); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java new file mode 100644 index 0000000000..736e1a90d1 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -0,0 +1,119 @@ +/* + * 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 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.store.InMemoryDataStore; +import sonia.scm.store.InMemoryDataStoreFactory; + +import java.util.Collection; +import java.util.Optional; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +class DefaultNamespaceManagerTest { + + @Mock + RepositoryManager repositoryManager; + + Namespace life; + + NamespaceDao dao; + DefaultNamespaceManager manager; + private Namespace universe; + private Namespace rest; + + @BeforeEach + void mockExistingNamespaces() { + dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore())); + manager = new DefaultNamespaceManager(repositoryManager, dao); + + when(repositoryManager.getAllNamespaces()).thenReturn(asList("life", "universe", "rest")); + + life = new Namespace("life"); + RepositoryPermission lifePermission = new RepositoryPermission("humans", "OWNER", true); + life.addPermission(lifePermission); + dao.add(life); + + universe = new Namespace("universe"); + rest = new Namespace("rest"); + } + + @Test + void shouldCreateEmptyOptionalIfNamespaceDoesNotExist() { + Optional namespace = manager.get("dolphins"); + + assertThat(namespace).isEmpty(); + } + + @Test + void shouldCreateNewNamespaceObjectIfNotInStore() { + Namespace namespace = manager.get("universe").orElse(null); + + assertThat(namespace).isEqualTo(universe); + assertThat(namespace.getPermissions()).isEmpty(); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissions() { + Namespace namespace = manager.get("life").orElse(null); + + assertThat(namespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissionsInGetAll() { + Collection namespaces = manager.getAll(); + + assertThat(namespaces).containsExactly( + life, + universe, + rest + ); + Namespace foundLifeNamespace = namespaces.stream().filter(namespace -> namespace.getNamespace().equals("life")).findFirst().get(); + assertThat( + foundLifeNamespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldModifyExistingNamespaceWithPermissions() { + Namespace modifiedNamespace = manager.get("life").get(); + + modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false))); + manager.modify(modifiedNamespace); + + Namespace newLife = manager.get("life").get(); + + assertThat(newLife).isEqualTo(modifiedNamespace); + } +} From ba628c51ac073299709d5261adf69db4b76266c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Tue, 15 Sep 2020 08:44:09 +0200 Subject: [PATCH 02/40] Introduce namespace permissions --- .../java/sonia/scm/repository/Namespace.java | 20 ++++++++++++++++++- .../main/resources/locales/de/plugins.json | 10 ++++++++++ .../main/resources/locales/en/plugins.json | 10 ++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) 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 0826a4f1ea..d2421c102d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Namespace.java +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -24,16 +24,29 @@ package sonia.scm.repository; +import com.github.sdorra.ssp.PermissionObject; +import com.github.sdorra.ssp.StaticPermissions; import org.apache.commons.lang.builder.EqualsBuilder; import org.apache.commons.lang.builder.HashCodeBuilder; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; import java.util.Collection; import java.util.HashSet; import java.util.Set; import static java.util.Collections.unmodifiableCollection; -public class Namespace implements Cloneable { +@StaticPermissions( + value = "namespace", + globalPermissions = {"permissionRead", "permissionWrite"}, + permissions = {}, + custom = true, customGlobal = true +) +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "namespaces") +public class Namespace implements PermissionObject, Cloneable { private String namespace; private Set permissions = new HashSet<>(); @@ -69,6 +82,11 @@ public class Namespace implements Cloneable { return this.permissions.remove(permission); } + @Override + public String getId() { + return getNamespace(); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index c5ab04d950..8804aa1a16 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -93,6 +93,16 @@ "description": "Darf alle installierten und verfügbaren Plugins lesen und verwalten" } }, + "namespace": { + "permissionRead": { + "displayName": "Berechtigungen auf Namespaces lesen", + "description": "Darf die Berechtigungen auf Namespace-Ebene sehen" + }, + "permissionWrite": { + "displayName": "Berechtigungen auf Namespaces modifizieren", + "description": "Darf die Berechtigungen auf Namespace-Ebene bearbeiten" + } + }, "unknown": "Unbekannte Berechtigung" }, "verbs": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 7338d53267..05e70bb299 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -93,6 +93,16 @@ "description": "May see and manage all installed and available plugins" } }, + "namespace": { + "permissionRead": { + "displayName": "read permissions on namespaces", + "description": "May see the permissions set for namespaces" + }, + "permissionWrite": { + "displayName": "modify permissions on namespaces", + "description": "May modify the permissions set for namespaces" + } + }, "unknown": "Unknown permission" }, "verbs": { From 4c7b500a0e4166f4bd61fb77d1bae71f3288fac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Tue, 15 Sep 2020 15:30:27 +0200 Subject: [PATCH 03/40] Evaluate namespace permissions --- .../DefaultAuthorizationCollector.java | 48 ++++++++------- .../DefaultAuthorizationCollectorTest.java | 60 +++++++++++++++---- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index 6d115db0f3..ddc65a8c0d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.security; import com.github.legman.Subscribe; import com.google.common.annotations.VisibleForTesting; @@ -46,17 +44,19 @@ import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserPermissions; -import sonia.scm.util.Util; import java.util.Collection; +import java.util.Optional; import java.util.Set; -//~--- JDK imports ------------------------------------------------------------ +import static java.util.Collections.emptySet; /** * @@ -85,16 +85,18 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * @param securitySystem * @param repositoryPermissionProvider * @param groupCollector + * @param namespaceDao */ @Inject public DefaultAuthorizationCollector(CacheManager cacheManager, - RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector) + RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector, NamespaceDao namespaceDao) { this.cache = cacheManager.getCache(CACHE_NAME); this.repositoryDAO = repositoryDAO; this.securitySystem = securitySystem; this.repositoryPermissionProvider = repositoryPermissionProvider; this.groupCollector = groupCollector; + this.namespaceDao = namespaceDao; } //~--- methods -------------------------------------------------------------- @@ -186,28 +188,27 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private void collectRepositoryPermissions(Builder builder, Repository repository, User user, Set groups) { - Collection repositoryPermissions = repository.getPermissions(); + Optional namespace = namespaceDao.get(repository.getNamespace()); - if (Util.isNotEmpty(repositoryPermissions)) + boolean hasPermission = false; + for (RepositoryPermission permission : repository.getPermissions()) { - boolean hasPermission = false; - for (RepositoryPermission permission : repositoryPermissions) - { - hasPermission = isUserPermitted(user, groups, permission); - if (hasPermission) { - addRepositoryPermission(builder, repository, user, permission); - } - } - - if (!hasPermission && logger.isTraceEnabled()) - { - logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); } } - else if (logger.isTraceEnabled()) + for (RepositoryPermission permission : namespace.map(Namespace::getPermissions).orElse(emptySet())) { - logger.trace("repository {} has no permission entries", - repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); + } + } + + if (!hasPermission && logger.isTraceEnabled()) + { + logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getNamespaceAndName()); } } @@ -371,4 +372,5 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private final RepositoryPermissionProvider repositoryPermissionProvider; private final GroupCollector groupCollector; + private final NamespaceDao namespaceDao; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index b351459fa8..30a6e42d10 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -21,13 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.subject.PrincipalCollection; @@ -45,6 +44,8 @@ import sonia.scm.SCMContext; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; @@ -53,8 +54,10 @@ import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserTestData; +import static com.google.common.collect.Lists.newArrayList; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; +import static java.util.Optional.of; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; @@ -92,6 +95,9 @@ public class DefaultAuthorizationCollectorTest { @Mock private GroupCollector groupCollector; + @Mock + private NamespaceDao namespaceDao; + private DefaultAuthorizationCollector collector; @Rule @@ -103,7 +109,7 @@ public class DefaultAuthorizationCollectorTest { @Before public void setUp(){ when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache); - collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector); + collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector, namespaceDao); } /** @@ -195,12 +201,44 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + heartOfGold.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, asList("read", "pull", "push"), true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + + // execute and assert + AuthorizationInfo authInfo = collector.collect(); + assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); + assertThat(authInfo.getObjectPermissions(), nullValue()); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); + } + + /** + * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with repository permissions. + */ + @Test + @SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini" + ) + public void testCollectWithNamespacePermissions() { + String group = "heart-of-gold-crew"; + authenticate(UserTestData.createTrillian(), group); + Repository heartOfGold = RepositoryTestData.createHeartOfGold(); + heartOfGold.setId("one"); + Namespace heartOfGoldNamespace = new Namespace(heartOfGold.getNamespace()); + heartOfGoldNamespace.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + + Repository puzzle42 = RepositoryTestData.create42Puzzle(); + puzzle42.setNamespace("guide"); + puzzle42.setId("two"); + Namespace puzzleNamespace = new Namespace(puzzle42.getNamespace()); + puzzleNamespace.setPermissions(newArrayList(new RepositoryPermission(group, asList("read", "pull", "push"), true))); + + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + when(namespaceDao.get(heartOfGold.getNamespace())).thenReturn(of(heartOfGoldNamespace)); + when(namespaceDao.get(puzzle42.getNamespace())).thenReturn(of(puzzleNamespace)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -228,15 +266,15 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList( + heartOfGold.setPermissions(newArrayList( new RepositoryPermission("trillian", "user role", false), new RepositoryPermission("trillian", "system role", false) )); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, "group role", true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -272,7 +310,7 @@ public class DefaultAuthorizationCollectorTest { heartOfGold.setPermissions(singletonList( new RepositoryPermission("trillian", "unknown", false) )); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -290,7 +328,7 @@ public class DefaultAuthorizationCollectorTest { StoredAssignedPermission p1 = new StoredAssignedPermission("one", new AssignedPermission("one", "one:one")); StoredAssignedPermission p2 = new StoredAssignedPermission("two", new AssignedPermission("two", "two:two")); - when(securitySystem.getPermissions(any())).thenReturn(Lists.newArrayList(p1, p2)); + when(securitySystem.getPermissions(any())).thenReturn(newArrayList(p1, p2)); // execute and assert AuthorizationInfo authInfo = collector.collect(); From f9c096f9e772bf02954661dd24808d2fc6c38769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 16 Sep 2020 07:32:16 +0200 Subject: [PATCH 04/40] Add REST endpoint for namespace permissions --- .../NamespacePermissionResource.java | 338 ++++++++++++++++++ .../api/v2/resources/NamespaceResource.java | 9 +- .../NamespaceToNamespaceDtoMapper.java | 20 +- ...sitoryPermissionCollectionToDtoMapper.java | 22 +- ...issionToRepositoryPermissionDtoMapper.java | 29 +- .../scm/api/v2/resources/ResourceLinks.java | 36 ++ .../lifecycle/modules/ScmServletModule.java | 3 + .../resources/NamespaceRootResourceTest.java | 333 +++++++++++++++-- .../api/v2/resources/ResourceLinksMock.java | 1 + 9 files changed, 744 insertions(+), 47 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java 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 new file mode 100644 index 0000000000..6ec97c282b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java @@ -0,0 +1,338 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +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; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +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; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; + +@Slf4j +public class NamespacePermissionResource { + + private RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper; + private RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper; + private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; + private ResourceLinks resourceLinks; + private final NamespaceManager manager; + + @Inject + public NamespacePermissionResource( + RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper, + RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper, + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper, + ResourceLinks resourceLinks, + NamespaceManager manager) { + this.dtoToModelMapper = dtoToModelMapper; + this.modelToDtoMapper = modelToDtoMapper; + this.repositoryPermissionCollectionToDtoMapper = repositoryPermissionCollectionToDtoMapper; + this.resourceLinks = resourceLinks; + this.manager = manager; + } + + /** + * Adds a new namespace permission for the user or group + * + * @param permission permission to add + * @return a web response with the status code 201 and the url to GET the added permission + */ + @POST + @Path("") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Create namespace-specific permission", description = "Adds a new permission to the namespace for the user or group.", tags = {"Namespace", "Permissions"}) + @ApiResponse( + responseCode = "201", + description = "creates", + headers = @Header( + name = "Location", + description = "uri of the created permission", + schema = @Schema(type = "string") + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "409", description = "conflict") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + 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); + String urlPermissionName = modelToDtoMapper.getUrlPermissionName(permission); + return Response.created(URI.create(resourceLinks.namespacePermission().self(namespaceName, urlPermissionName))).build(); + } + + /** + * Get the searched permission with permission name related to a namespace + * + * @param namespaceName the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace or the permission does not exists + */ + @GET + @Path("{permission-name}") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Get single repository-specific permission", description = "Get the searched permission with permission name related to a repository.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public RepositoryPermissionDto get(@PathParam("namespace") String namespaceName, @PathParam("permission-name") String permissionName) { + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionRead().check(); + return + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .map(permission -> modelToDtoMapper.map(permission, namespace)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + } + + /** + * Get all permissions related to a namespace + * + * @param namespaceMame the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace does not exists + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "List of namespace-specific permissions", description = "Get all permissions related to a namespace.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public HalRepresentation getAll(@PathParam("namespace") String namespaceMame) { + Namespace namespace = load(namespaceMame); + NamespacePermissions.permissionRead().check(); + return repositoryPermissionCollectionToDtoMapper.map(namespace); + } + + /** + * Update a permission to the user or group managed by the repository + * ignore the user input for groupPermission and take it from the path parameter (if the group prefix (@) exists it is a group permission) + * + * @param permission permission to modify + * @param permissionName permission to modify + * @return a web response with the status code 204 + */ + @PUT + @Path("{permission-name}") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Update repository-specific permission", description = "Update a permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void update(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName, + @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)); + } + permission.setGroupPermission(isGroupPermission(permissionName)); + if (!extractedPermissionName.equals(permission.getName())) { + checkPermissionAlreadyExists(permission, namespace); + } + + RepositoryPermission existingPermission = namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + RepositoryPermission newPermission = dtoToModelMapper.map(permission); + if (!namespace.removePermission(existingPermission)) { + throw new IllegalStateException(String.format("could not delete modified permission %s from namespace %s", existingPermission, namespaceName)); + } + namespace.addPermission(newPermission); + manager.modify(namespace); + log.info("the permission with name: {} is updated.", permissionName); + } + + /** + * Update a permission to the user or group managed by the repository + * + * @param permissionName permission to delete + * @return a web response with the status code 204 + */ + @DELETE + @Path("{permission-name}") + @Operation(summary = "Delete repository-specific permission", description = "Delete a permission with the given name.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "delete success or nothing to delete") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void delete(@PathParam("namespace") String namespaceName, + @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)) + .findFirst() + .ifPresent(permission -> { + namespace.removePermission(permission); + manager.modify(namespace); + }); + log.info("the permission with name: {} is deleted.", permissionName); + } + + private Predicate filterPermission(String name) { + return permission -> getPermissionName(name).equals(permission.getName()) + && + permission.isGroupPermission() == isGroupPermission(name); + } + + private String getPermissionName(String permissionName) { + return Optional.of(permissionName) + .filter(p -> !isGroupPermission(permissionName)) + .orElse(permissionName.substring(1)); + } + + private boolean isGroupPermission(String permissionName) { + return permissionName.startsWith(GROUP_PREFIX); + } + + private Namespace load(String namespaceMame) { + return manager.get(namespaceMame) + .orElseThrow(() -> notFound(entity("Namespace", namespaceMame))); + } + + private void checkPermissionAlreadyExists(RepositoryPermissionDto permission, Namespace namespace) { + if (isPermissionExist(permission, namespace)) { + throw alreadyExists(entity("Permission", permission.getName()).in(Namespace.class, namespace.getNamespace())); + } + } + + private boolean isPermissionExist(RepositoryPermissionDto permission, Namespace namespace) { + return namespace.getPermissions() + .stream() + .anyMatch(p -> p.getName().equals(permission.getName()) && p.isGroupPermission() == permission.isGroupPermission()); + } +} + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java index 8c7fdc0a05..1f0c828ca7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java @@ -32,6 +32,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -44,11 +45,13 @@ public class NamespaceResource { private final RepositoryManager manager; private final NamespaceToNamespaceDtoMapper namespaceMapper; + private final Provider namespacePermissionResource; @Inject - public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper) { + public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper, Provider namespacePermissionResource) { this.manager = manager; this.namespaceMapper = namespaceMapper; + this.namespacePermissionResource = namespacePermissionResource; } /** @@ -97,4 +100,8 @@ public class NamespaceResource { .orElseThrow(() -> notFound(entity("Namespace", namespace))); } + @Path("permissions") + public NamespacePermissionResource permissions() { + return namespacePermissionResource.get(); + } } 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 7a464299ea..5067f95c0f 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 @@ -24,6 +24,9 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Links; +import sonia.scm.repository.NamespacePermissions; + import javax.inject.Inject; import static de.otto.edison.hal.Link.link; @@ -39,12 +42,15 @@ class NamespaceToNamespaceDtoMapper { } NamespaceDto map(String namespace) { - return new NamespaceDto( - namespace, - linkingTo() - .self(links.namespace().self(namespace)) - .single(link("repositories", links.repositoryCollection().forNamespace(namespace))) - .build() - ); + Links.Builder linkingTo = linkingTo(); + linkingTo + .self(links.namespace().self(namespace)) + .single(link("repositories", links.repositoryCollection().forNamespace(namespace))); + + if (NamespacePermissions.permissionRead().isPermitted()) { + linkingTo + .single(link("permissions", links.namespacePermission().all(namespace))); + } + return new NamespaceDto(namespace, linkingTo.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java index 765ffe8168..db285c369a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java @@ -21,13 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -57,6 +59,14 @@ public class RepositoryPermissionCollectionToDtoMapper { return new HalRepresentation(createLinks(repository), embedDtos(repositoryPermissionDtoList)); } + public HalRepresentation map(Namespace namespace) { + List repositoryPermissionDtoList = namespace.getPermissions() + .stream() + .map(permission -> repositoryPermissionToRepositoryPermissionDtoMapper.map(permission, namespace)) + .collect(toList()); + return new HalRepresentation(createLinks(namespace), embedDtos(repositoryPermissionDtoList)); + } + private Links createLinks(Repository repository) { RepositoryPermissions.permissionRead(repository).check(); Links.Builder linksBuilder = linkingTo() @@ -67,6 +77,16 @@ public class RepositoryPermissionCollectionToDtoMapper { return linksBuilder.build(); } + private Links createLinks(Namespace namespace) { + NamespacePermissions.permissionRead().check(); + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(resourceLinks.namespacePermission().all(namespace.getNamespace())).build()); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("create", resourceLinks.namespacePermission().create(namespace.getNamespace()))); + } + return linksBuilder.build(); + } + private Embedded embedDtos(List repositoryPermissionDtoList) { return embeddedBuilder() .with("permissions", repositoryPermissionDtoList) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java index 11fd658c33..37e3b097f9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.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.api.v2.resources; import de.otto.edison.hal.Links; @@ -31,6 +31,8 @@ import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -51,18 +53,19 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Repository repository); + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Namespace namespace); @BeforeMapping void validatePermissions(@Context Repository repository) { RepositoryPermissions.permissionRead(repository).check(); } - /** - * Add the self, update and delete links. - * - * @param target the mapped dto - * @param repository the repository - */ + @BeforeMapping + void validatePermissions(@Context Namespace namespace) { + NamespacePermissions.permissionRead().check(); + } + @AfterMapping void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) { String permissionName = getUrlPermissionName(target); @@ -75,6 +78,18 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { target.add(linksBuilder.build()); } + @AfterMapping + void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Namespace namespace) { + String permissionName = getUrlPermissionName(target); + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.namespacePermission().self(namespace.getNamespace(), permissionName)); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("update", resourceLinks.namespacePermission().update(namespace.getNamespace(), permissionName))); + linksBuilder.single(link("delete", resourceLinks.namespacePermission().delete(namespace.getNamespace(), permissionName))); + } + target.add(linksBuilder.build()); + } + public String getUrlPermissionName(RepositoryPermissionDto repositoryPermissionDto) { return Optional.of(repositoryPermissionDto.getName()) .filter(p -> !repositoryPermissionDto.isGroupPermission()) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3dd2180ede..99a2058249 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -916,4 +916,40 @@ class ResourceLinks { return namespaceLinkBuilder.method("getNamespaceResource").parameters().method("get").parameters(namespace).href(); } } + + public NamespacePermissionLinks namespacePermission() { + return new NamespacePermissionLinks(scmPathInfoStore.get()); + } + + static class NamespacePermissionLinks { + private final LinkBuilder permissionLinkBuilder; + + NamespacePermissionLinks(ScmPathInfo pathInfo) { + permissionLinkBuilder = new LinkBuilder(pathInfo, NamespaceRootResource.class, NamespaceResource.class, NamespacePermissionResource.class); + } + + String all(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("getAll").parameters().href(); + } + + String create(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("create").parameters().href(); + } + + String self(String namespace, String permissionName) { + return getLink(namespace, permissionName, "get"); + } + + String update(String namespace, String permissionName) { + return getLink(namespace, permissionName, "update"); + } + + String delete(String namespace, String permissionName) { + return getLink(namespace, permissionName, "delete"); + } + + private String getLink(String namespace, String permissionName, String methodName) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method(methodName).parameters(permissionName).href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index ec7499aa02..776c196190 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -64,10 +64,12 @@ import sonia.scm.net.ahc.XmlContentTransformer; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; +import sonia.scm.repository.DefaultNamespaceManager; import sonia.scm.repository.DefaultRepositoryManager; import sonia.scm.repository.DefaultRepositoryProvider; import sonia.scm.repository.DefaultRepositoryRoleManager; import sonia.scm.repository.HealthCheckContextListener; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.NamespaceStrategyProvider; import sonia.scm.repository.Repository; @@ -191,6 +193,7 @@ class ScmServletModule extends ServletModule { bindDecorated(GroupManager.class, DefaultGroupManager.class, GroupManagerProvider.class); bind(GroupDisplayManager.class, DefaultGroupDisplayManager.class); + bind(NamespaceManager.class, DefaultNamespaceManager.class); bind(GroupCollector.class, DefaultGroupCollector.class); bind(CGIExecutorFactory.class, DefaultCGIExecutorFactory.class); 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 669cc70544..d322b627bb 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 @@ -24,83 +24,354 @@ package sonia.scm.api.v2.resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.RestDispatcher; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import static com.google.inject.util.Providers.of; import static java.util.Arrays.asList; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class NamespaceRootResourceTest { @Mock RepositoryManager repositoryManager; + @Mock + NamespaceManager namespaceManager; + @Mock + Subject subject; RestDispatcher dispatcher = new RestDispatcher(); MockHttpResponse response = new MockHttpResponse(); ResourceLinks links = ResourceLinksMock.createMock(URI.create("/")); + @InjectMocks + RepositoryPermissionToRepositoryPermissionDtoMapperImpl repositoryPermissionToRepositoryPermissionDtoMapper; + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @BeforeEach void setUpResources() { NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links); NamespaceCollectionToDtoMapper namespaceCollectionToDtoMapper = new NamespaceCollectionToDtoMapper(namespaceMapper, links); + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(repositoryPermissionToRepositoryPermissionDtoMapper, links); + RepositoryPermissionDtoToRepositoryPermissionMapperImpl dtoToModelMapper = new RepositoryPermissionDtoToRepositoryPermissionMapperImpl(); + NamespaceCollectionResource namespaceCollectionResource = new NamespaceCollectionResource(repositoryManager, namespaceCollectionToDtoMapper); - NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper); + NamespacePermissionResource namespacePermissionResource = new NamespacePermissionResource(dtoToModelMapper, repositoryPermissionToRepositoryPermissionDtoMapper, repositoryPermissionCollectionToDtoMapper, links, namespaceManager); + NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper, of(namespacePermissionResource)); dispatcher.addSingletonResource(new NamespaceRootResource(of(namespaceCollectionResource), of(namespaceResource))); } - @Test - void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); - - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - - dispatcher.invoke(request, response); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") - .contains("\"_embedded\"") - .contains("\"namespace\":\"hitchhiker\"") - .contains("\"namespace\":\"space\""); + @BeforeEach + void mockExistingNamespaces() { + lenient().when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + Namespace hitchhikerNamespace = new Namespace("hitchhiker"); + hitchhikerNamespace.setPermissions(singleton(new RepositoryPermission("humans", "READ", true))); + Namespace spaceNamespace = new Namespace("space"); + lenient().when(namespaceManager.getAll()).thenReturn(asList(hitchhikerNamespace, spaceNamespace)); + lenient().when(namespaceManager.get("hitchhiker")).thenReturn(Optional.of(hitchhikerNamespace)); + lenient().when(namespaceManager.get("space")).thenReturn(Optional.of(spaceNamespace)); } - @Test - void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithoutSpecialPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + @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"); + } - dispatcher.invoke(request, response); + @Test + void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"namespace\":\"space\"") - .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") + .contains("\"_embedded\"") + .contains("\"namespace\":\"hitchhiker\"") + .contains("\"namespace\":\"space\""); + } + + @Test + void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"namespace\":\"space\"") + .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}") + .doesNotContain("permissions"); + } + + @Test + void shouldHandleUnknownNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotReturnPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } } - @Test - void shouldHandleUnknownNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithReadPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + @BeforeEach + void grantReadPermission() { + lenient().when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldContainPermissionLinkWhenPermitted() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); - assertThat(response.getStatus()).isEqualTo(404); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"permissions\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldReturnPermissions() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("create"); + } + + @Test + void shouldReturnSinglePermission() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("update") + .doesNotContain("delete"); + } + + @Test + void shouldHandleMissingNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "no_such_namespace/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void shouldNotDeletePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Nested + class WithWritePermission { + + @BeforeEach + void grantWritePermission() { + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(true); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite"); + } + + @Test + void shouldContainCreateLink() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"create\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldContainModificationLinks() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"update\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"") + .contains("\"delete\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\""); + } + + @Test + void shouldCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(201); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("dent"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isFalse(); + return true; + }) + ); + assertThat(response.getOutputHeaders().get("Location")) + .containsExactly(URI.create("/v2/namespaces/space/permissions/dent")); + } + + @Test + void shouldUpdatePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("humans"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isTrue(); + return true; + }) + ); + } + + @Test + void shouldHandleNotExistingPermissionOnUpdate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldHandleExistingPermissionOnCreate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + verify(namespaceManager, never()).modify(any()); + } + + @Test + void shouldDeleteExistingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).isEmpty(); + return true; + }) + ); + } + + @Test + void shouldHandleRedundantDeleteIdempotent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager, never()).modify(any()); + } + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 38a1055211..f2d6e85710 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -78,6 +78,7 @@ public class ResourceLinksMock { lenient().when(resourceLinks.annotate()).thenReturn(new ResourceLinks.AnnotateLinks(pathInfo)); lenient().when(resourceLinks.namespace()).thenReturn(new ResourceLinks.NamespaceLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); + lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); return resourceLinks; } From 6b9079fd68d1cdadb85b5cec8977960f76649a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 07:49:37 +0200 Subject: [PATCH 05/40] Add UI for namespace permissions --- .../public/locales/en/namespaces.json | 9 ++ scm-ui/ui-webapp/src/containers/Main.tsx | 2 + scm-ui/ui-webapp/src/repos/modules/repos.ts | 96 +++++++++++- .../namespaces/containers/NamespaceRoot.tsx | 146 ++++++++++++++++++ .../containers/PermissionsNavLink.tsx | 47 ++++++ .../permissions/containers/Permissions.tsx | 21 ++- .../containers/SinglePermission.tsx | 6 +- .../repos/permissions/modules/permissions.ts | 145 +++++++++-------- 8 files changed, 386 insertions(+), 86 deletions(-) create mode 100644 scm-ui/ui-webapp/public/locales/en/namespaces.json create mode 100644 scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx create mode 100644 scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx diff --git a/scm-ui/ui-webapp/public/locales/en/namespaces.json b/scm-ui/ui-webapp/public/locales/en/namespaces.json new file mode 100644 index 0000000000..a4b99c39ee --- /dev/null +++ b/scm-ui/ui-webapp/public/locales/en/namespaces.json @@ -0,0 +1,9 @@ +{ + "namespaceRoot": { + "menu": { + "navigationLabel": "Namespace", + "settingsNavLink": "Settings", + "permissionsNavLink": "Permissions" + } + } +} diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 48e6f37a76..e0af6ee867 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -46,6 +46,7 @@ import CreateGroup from "../groups/containers/CreateGroup"; import Admin from "../admin/containers/Admin"; import Profile from "./Profile"; +import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot"; type Props = { me: Me; @@ -80,6 +81,7 @@ class Main extends React.Component { + diff --git a/scm-ui/ui-webapp/src/repos/modules/repos.ts b/scm-ui/ui-webapp/src/repos/modules/repos.ts index a4dc9d2e8c..a38dcbb76f 100644 --- a/scm-ui/ui-webapp/src/repos/modules/repos.ts +++ b/scm-ui/ui-webapp/src/repos/modules/repos.ts @@ -25,7 +25,7 @@ import { apiClient } from "@scm-manager/ui-components"; import * as types from "../../modules/types"; import { - Action, + Action, Namespace, NamespaceCollection, Repository, RepositoryCollection, @@ -66,6 +66,11 @@ export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`; +export const FETCH_NAMESPACE = "scm/repos/FETCH_NAMESPACE"; +export const FETCH_NAMESPACE_PENDING = `${FETCH_NAMESPACE}_${types.PENDING_SUFFIX}`; +export const FETCH_NAMESPACE_SUCCESS = `${FETCH_NAMESPACE}_${types.SUCCESS_SUFFIX}`; +export const FETCH_NAMESPACE_FAILURE = `${FETCH_NAMESPACE}_${types.FAILURE_SUFFIX}`; + export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2"; export const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy"; @@ -388,6 +393,50 @@ export function deleteRepoFailure(repository: Repository, error: Error): Action }; } +export function fetchNamespace(link: string, namespaceName: string) { + return function(dispatch: any) { + dispatch(fetchNamespacePending(namespaceName)); + return apiClient + .get(link) + .then(response => response.json()) + .then(namespace => { + dispatch(fetchNamespaceSuccess(namespace)); + }) + .catch(err => { + dispatch(fetchNamespaceFailure(namespaceName, err)); + }); + }; +} + +export function fetchNamespacePending(namespaceName: string): Action { + return { + type: FETCH_NAMESPACE_PENDING, + payload: { + namespaceName + }, + itemId: namespaceName + }; +} + +export function fetchNamespaceSuccess(namespace: Namespace): Action { + return { + type: FETCH_NAMESPACE_SUCCESS, + payload: namespace, + itemId: namespace.namespace + }; +} + +export function fetchNamespaceFailure(namespaceName: string, error: Error): Action { + return { + type: FETCH_NAMESPACE_FAILURE, + payload: { + namespaceName, + error + }, + itemId: namespaceName + }; +} + // reducer function createIdentifier(repository: Repository) { @@ -425,6 +474,17 @@ const reducerByNames = (state: object, repository: Repository) => { }; }; +const reducerForNamespace = (state: object, namespace: Namespace) => { + const identifier = namespace.namespace; + return { + ...state, + namespacesByNames: { + ...state.namespacesByNames, + [identifier]: namespace + } + }; +}; + const reducerForNamespaces = (state: object, namespaces: NamespaceCollection) => { return { ...state, @@ -449,6 +509,8 @@ export default function reducer( return reducerForNamespaces(state, action.payload); case FETCH_REPO_SUCCESS: return reducerByNames(state, action.payload); + case FETCH_NAMESPACE_SUCCESS: + return reducerForNamespace(state, action.payload); default: return state; } @@ -497,10 +559,17 @@ export function getFetchNamespacesFailure(state: object) { return getFailure(state, FETCH_NAMESPACES); } -export function getNamespace(state: object, namespace: string) { - if (state.namespaces) { - return state.namespaces[namespace]; - } +export function isFetchNamespacePending(state: object) { + return isPending(state, FETCH_NAMESPACE); +} + +export function getFetchNamespaceFailure(state: object) { + return getFailure(state, FETCH_NAMESPACE); +} + +export function fetchNamespaceByName(link: string, namespaceName: string) { + const namespaceUrl = link.endsWith("/") ? link : link + "/"; + return fetchNamespace(`${namespaceUrl}${namespaceName}`, namespaceName); } export function isFetchRepoPending(state: object, namespace: string, name: string) { @@ -511,6 +580,12 @@ export function getFetchRepoFailure(state: object, namespace: string, name: stri return getFailure(state, FETCH_REPO, namespace + "/" + name); } +export function getNamespace(state: object, namespaceName: string) { + if (state.repos && state.repos.namespacesByNames) { + return state.repos.namespacesByNames[namespaceName]; + } +} + export function isAbleToCreateRepos(state: object) { return !!(state.repos && state.repos.list && state.repos.list._links && state.repos.list._links.create); } @@ -539,7 +614,12 @@ export function getDeleteRepoFailure(state: object, namespace: string, name: str return getFailure(state, DELETE_REPO, namespace + "/" + name); } -export function getPermissionsLink(state: object, namespace: string, name: string) { - const repo = getRepository(state, namespace, name); - return repo && repo._links ? repo._links.permissions.href : undefined; +export function getPermissionsLink(state: object, namespaceName: string, repoName?: string) { + if (repoName) { + const repo = getRepository(state, namespaceName, repoName); + return repo && repo._links ? repo._links.permissions.href : undefined; + } else { + const namespace = getNamespace(state, namespaceName); + return namespace && namespace._links ? namespace._links.permissions.href : undefined; + } } diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx new file mode 100644 index 0000000000..52c0ee5b3b --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -0,0 +1,146 @@ +/* + * 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 React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { fetchNamespaceByName, getNamespace, isFetchNamespacePending } from "../../modules/repos"; +import { getNamespacesLink } from "../../../modules/indexResource"; +import { Namespace } from "@scm-manager/ui-types"; +import { + CustomQueryFlexWrappedColumns, + ErrorPage, + Loading, + Page, PrimaryContentColumn, + SecondaryNavigation, + SecondaryNavigationColumn, + StateMenuContextProvider, + SubNavigation +} from "@scm-manager/ui-components"; +import Permissions from "../../permissions/containers/Permissions"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import PermissionsNavLink from "./PermissionsNavLink"; + +type Props = RouteComponentProps & + WithTranslation & { + loading: boolean; + namespaceName: string; + namespacesLink: string; + namespace: Namespace; + + // dispatch functions + fetchNamespace: (link: string, namespace: string) => void; + }; + +class NamespaceRoot extends React.Component { + componentDidMount() { + const { namespacesLink, namespaceName, fetchNamespace } = this.props; + fetchNamespace(namespacesLink, namespaceName); + } + + stripEndingSlash = (url: string) => { + if (url.endsWith("/")) { + return url.substring(0, url.length - 1); + } + return url; + }; + + matchedUrl = () => { + return this.stripEndingSlash(this.props.match.url); + }; + + render() { + const { loading, error, namespaceName, namespace, t } = this.props; + const url = this.matchedUrl(); + + const extensionProps = { + namespace, + url + }; + + if (error) { + return ( + + ); + } + + if (!namespace || loading) { + return ; + } + + return ( + + + + + + + { + return ; + }} + /> + + + + + + + + + + + + + + + + ); + // return

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

; + } +} + +const mapStateToProps = (state: any, ownProps: Props) => { + const { namespaceName } = ownProps.match.params; + const namespacesLink = getNamespacesLink(state); + const namespace = getNamespace(state, namespaceName); + const loading = isFetchNamespacePending(state); + return { namespaceName, namespacesLink, loading, namespace }; +}; + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchNamespace: (link: string, namespaceName: string) => { + dispatch(fetchNamespaceByName(link, namespaceName)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("namespaces")(NamespaceRoot)); diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx new file mode 100644 index 0000000000..af3094a990 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx @@ -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. + */ +import React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { Namespace } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = WithTranslation & { + permissionUrl: string; + namespace: Namespace; +}; + +class PermissionsNavLink extends React.Component { + hasPermissionsLink = () => { + return this.props.namespace._links.permissions; + }; + render() { + if (!this.hasPermissionsLink()) { + return null; + } + const { permissionUrl, t } = this.props; + return ; + } +} + +export default withTranslation("namespaces")(PermissionsNavLink); diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx index cb0b285aa4..3ab269db50 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx @@ -57,13 +57,12 @@ import { getRepositoryVerbsLink, getUserAutoCompleteLink } from "../../../modules/indexResource"; - type Props = WithTranslation & { availablePermissions: boolean; availableRepositoryRoles: RepositoryRole[]; availableVerbs: string[]; namespace: string; - repoName: string; + repoName?: string; loading: boolean; error: Error; permissions: PermissionCollection; @@ -77,17 +76,17 @@ type Props = WithTranslation & { // dispatch functions fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void; - fetchPermissions: (link: string, namespace: string, repoName: string) => void; + fetchPermissions: (link: string, namespace: string, repoName?: string) => void; createPermission: ( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) => void; - createPermissionReset: (p1: string, p2: string) => void; - modifyPermissionReset: (p1: string, p2: string) => void; - deletePermissionReset: (p1: string, p2: string) => void; + createPermissionReset: (namespace: string, repoName?: string) => void; + modifyPermissionReset: (namespace: string, repoName?: string) => void; + deletePermissionReset: (namespace: string, repoName?: string) => void; // context props match: any; @@ -241,7 +240,7 @@ const mapStateToProps = (state: any, ownProps: Props) => { const mapDispatchToProps = (dispatch: any) => { return { - fetchPermissions: (link: string, namespace: string, repoName: string) => { + fetchPermissions: (link: string, namespace: string, repoName?: string) => { dispatch(fetchPermissions(link, namespace, repoName)); }, fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => { @@ -256,13 +255,13 @@ const mapDispatchToProps = (dispatch: any) => { ) => { dispatch(createPermission(link, permission, namespace, repoName, callback)); }, - createPermissionReset: (namespace: string, repoName: string) => { + createPermissionReset: (namespace: string, repoName?: string) => { dispatch(createPermissionReset(namespace, repoName)); }, - modifyPermissionReset: (namespace: string, repoName: string) => { + modifyPermissionReset: (namespace: string, repoName?: string) => { dispatch(modifyPermissionReset(namespace, repoName)); }, - deletePermissionReset: (namespace: string, repoName: string) => { + deletePermissionReset: (namespace: string, repoName?: string) => { dispatch(deletePermissionReset(namespace, repoName)); } }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx index 3be203f7dc..48337446d4 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx @@ -43,14 +43,14 @@ type Props = WithTranslation & { availableRepositoryRoles: RepositoryRole[]; availableRepositoryVerbs: string[]; submitForm: (p: Permission) => void; - modifyPermission: (permission: Permission, namespace: string, name: string) => void; + modifyPermission: (permission: Permission, namespace: string, name?: string) => void; permission: Permission; namespace: string; - repoName: string; + repoName?: string; match: any; history: History; loading: boolean; - deletePermission: (permission: Permission, namespace: string, name: string) => void; + deletePermission: (permission: Permission, namespace: string, name?: string) => void; deleteLoading: boolean; }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts index 55a446ab80..34852c17de 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts +++ b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts @@ -131,7 +131,7 @@ export function fetchAvailableFailure(error: Error): Action { // fetch permissions -export function fetchPermissions(link: string, namespace: string, repoName: string) { +export function fetchPermissions(link: string, namespace: string, repoName?: string) { return function(dispatch: any) { dispatch(fetchPermissionsPending(namespace, repoName)); return apiClient @@ -146,26 +146,26 @@ export function fetchPermissions(link: string, namespace: string, repoName: stri }; } -export function fetchPermissionsPending(namespace: string, repoName: string): Action { +export function fetchPermissionsPending(namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_PENDING, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName: string): Action { +export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_SUCCESS, payload: permissions, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsFailure(namespace: string, repoName: string, error: Error): Action { +export function fetchPermissionsFailure(namespace: string, repoName?: string, error: Error): Action { return { type: FETCH_PERMISSIONS_FAILURE, payload: { @@ -173,13 +173,13 @@ export function fetchPermissionsFailure(namespace: string, repoName: string, err repoName, error }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // modify permission -export function modifyPermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function modifyPermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(modifyPermissionPending(permission, namespace, repoName)); return apiClient @@ -196,7 +196,7 @@ export function modifyPermission(permission: Permission, namespace: string, repo }; } -export function modifyPermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_PENDING, payload: permission, @@ -204,12 +204,12 @@ export function modifyPermissionPending(permission: Permission, namespace: strin }; } -export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -219,7 +219,7 @@ export function modifyPermissionFailure( permission: Permission, error: Error, namespace: string, - repoName: string + repoName?: string ): Action { return { type: MODIFY_PERMISSION_FAILURE, @@ -240,14 +240,14 @@ function newPermissions(oldPermissions: PermissionCollection, newPermission: Per } } -export function modifyPermissionReset(namespace: string, repoName: string) { +export function modifyPermissionReset(namespace: string, repoName?: string) { return { type: MODIFY_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -256,7 +256,7 @@ export function createPermission( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) { return function(dispatch: Dispatch) { @@ -281,48 +281,48 @@ export function createPermission( export function createPermissionPending( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_PENDING, payload: permission, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } export function createPermissionSuccess( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionFailure(error: Error, namespace: string, repoName: string): Action { +export function createPermissionFailure(error: Error, namespace: string, repoName?: string): Action { return { type: CREATE_PERMISSION_FAILURE, payload: error, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionReset(namespace: string, repoName: string) { +export function createPermissionReset(namespace: string, repoName?: string) { return { type: CREATE_PERMISSION_RESET, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // delete permission -export function deletePermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function deletePermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(deletePermissionPending(permission, namespace, repoName)); return apiClient @@ -339,7 +339,7 @@ export function deletePermission(permission: Permission, namespace: string, repo }; } -export function deletePermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_PENDING, payload: permission, @@ -347,12 +347,12 @@ export function deletePermissionPending(permission: Permission, namespace: strin }; } -export function deletePermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -361,7 +361,7 @@ export function deletePermissionSuccess(permission: Permission, namespace: strin export function deletePermissionFailure( permission: Permission, namespace: string, - repoName: string, + repoName?: string, error: Error ): Action { return { @@ -374,14 +374,14 @@ export function deletePermissionFailure( }; } -export function deletePermissionReset(namespace: string, repoName: string) { +export function deletePermissionReset(namespace: string, repoName?: string) { return { type: DELETE_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -398,9 +398,9 @@ function deletePermissionFromState(oldPermissions: PermissionCollection, permiss return newPermission; } -function createItemId(permission: Permission, namespace: string, repoName: string) { +function createItemId(permission: Permission, namespace: string, repoName?: string) { const groupPermission = permission.groupPermission ? "@" : ""; - return namespace + "/" + repoName + "/" + groupPermission + permission.name; + return createPermissionStateKey(namespace, repoName) + "/" + groupPermission + permission.name; } // reducer @@ -427,7 +427,7 @@ export default function reducer( createPermission: !!action.payload._links.create } }; - case MODIFY_PERMISSION_SUCCESS: + case MODIFY_PERMISSION_SUCCESS: { const positionOfPermission = action.payload.position; const newPermission = newPermissions(state[action.payload.position].entries, action.payload.permission); return { @@ -437,7 +437,8 @@ export default function reducer( entries: newPermission } }; - case CREATE_PERMISSION_SUCCESS: + } + case CREATE_PERMISSION_SUCCESS: { // return state; const position = action.payload.position; const permissions = state[action.payload.position].entries; @@ -449,9 +450,10 @@ export default function reducer( entries: permissions } }; - case DELETE_PERMISSION_SUCCESS: + } + case DELETE_PERMISSION_SUCCESS: { const permissionPosition = action.payload.position; - const new_Permissions = deletePermissionFromState( + const newPermissions = deletePermissionFromState( state[action.payload.position].entries, action.payload.permission ); @@ -459,9 +461,10 @@ export default function reducer( ...state, [permissionPosition]: { ...state[permissionPosition], - entries: new_Permissions + entries: newPermissions } }; + } default: return state; } @@ -490,9 +493,9 @@ function available(state: object) { return {}; } -export function getPermissionsOfRepo(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) { - return state.permissions[namespace + "/" + repoName].entries; +export function getPermissionsOfRepo(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) { + return state.permissions[createPermissionStateKey(namespace, repoName)].entries; } } @@ -500,52 +503,62 @@ export function isFetchAvailablePermissionsPending(state: object) { return isPending(state, FETCH_AVAILABLE, "available"); } -export function isFetchPermissionsPending(state: object, namespace: string, repoName: string) { - return isPending(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function isFetchPermissionsPending(state: object, namespace: string, repoName?: string) { + return isPending(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } export function getFetchAvailablePermissionsFailure(state: object) { return getFailure(state, FETCH_AVAILABLE, "available"); } -export function getFetchPermissionsFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function getFetchPermissionsFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } -export function isModifyPermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { - return isPending(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function isModifyPermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { + return isPending(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function getModifyPermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { - return getFailure(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function getModifyPermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { + return getFailure(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function hasCreatePermission(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) - return state.permissions[namespace + "/" + repoName].createPermission; +export function hasCreatePermission(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) + return state.permissions[createPermissionStateKey(namespace, repoName)].createPermission; else return null; } -export function isCreatePermissionPending(state: object, namespace: string, repoName: string) { - return isPending(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function isCreatePermissionPending(state: object, namespace: string, repoName?: string) { + return isPending(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function getCreatePermissionFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function getCreatePermissionFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function isDeletePermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { +export function isDeletePermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { return isPending(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { +export function getDeletePermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { return getFailure(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionsFailure(state: object, namespace: string, repoName: string) { +export function getDeletePermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -556,10 +569,10 @@ export function getDeletePermissionsFailure(state: object, namespace: string, re return null; } -export function getModifyPermissionsFailure(state: object, namespace: string, repoName: string) { +export function getModifyPermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -570,6 +583,10 @@ export function getModifyPermissionsFailure(state: object, namespace: string, re return null; } +function createPermissionStateKey(namespace: string, repoName?: string) { + return namespace + (repoName ? "/" + repoName : ""); +} + export function findVerbsForRole(availableRepositoryRoles: RepositoryRole[], roleName: string) { const matchingRole = availableRepositoryRoles.find(role => roleName === role.name); if (matchingRole) { From 2342b8009b1efdec5699c51c818c909384b72211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 08:21:31 +0200 Subject: [PATCH 06/40] Add settings icon to namespace group header --- scm-ui/ui-components/src/CardColumnGroup.tsx | 2 +- scm-ui/ui-types/src/Repositories.ts | 1 + .../components/list/RepositoryGroupEntry.tsx | 17 ++++++++++++++++- .../repos/components/list/RepositoryList.tsx | 7 ++++--- .../repos/components/list/groupByNamespace.ts | 13 +++++++++++-- .../ui-webapp/src/repos/containers/Overview.tsx | 4 ++-- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/scm-ui/ui-components/src/CardColumnGroup.tsx b/scm-ui/ui-components/src/CardColumnGroup.tsx index c5c07055f2..d9a769a98c 100644 --- a/scm-ui/ui-components/src/CardColumnGroup.tsx +++ b/scm-ui/ui-components/src/CardColumnGroup.tsx @@ -27,7 +27,7 @@ import classNames from "classnames"; import styled from "styled-components"; type Props = { - name: string; + name: ReactNode; url?: string; elements: ReactNode[]; }; diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 8a7a4fee97..b585bffb65 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -58,5 +58,6 @@ export type NamespaceCollection = { export type RepositoryGroup = { name: string; + namespace: Namespace; repositories: Repository[]; }; diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx index 146b007cfc..cdf13dde44 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx @@ -22,8 +22,10 @@ * SOFTWARE. */ import React from "react"; +import { Link } from "react-router-dom"; import { CardColumnGroup, RepositoryEntry } from "@scm-manager/ui-components"; import { RepositoryGroup } from "@scm-manager/ui-types"; +import { Icon } from "@scm-manager/ui-components"; type Props = { group: RepositoryGroup; @@ -32,10 +34,23 @@ type Props = { class RepositoryGroupEntry extends React.Component { render() { const { group } = this.props; + const settingsLink = group.namespace?._links?.permissions && ( + + + + ); + const namespaceHeader = ( + <> + + {group.name} + {" "} + {settingsLink} + + ); const entries = group.repositories.map((repository, index) => { return ; }); - return ; + return ; } } diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index 2c76c48e45..fc9e539ab2 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -23,20 +23,21 @@ */ import React from "react"; -import { Repository } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository } from "@scm-manager/ui-types"; import groupByNamespace from "./groupByNamespace"; import RepositoryGroupEntry from "./RepositoryGroupEntry"; type Props = { repositories: Repository[]; + namespaces: NamespaceCollection; }; class RepositoryList extends React.Component { render() { - const { repositories } = this.props; + const { repositories, namespaces } = this.props; - const groups = groupByNamespace(repositories); + const groups = groupByNamespace(repositories, namespaces); return (
{groups.map(group => { diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts index 6e4cd90177..7887690b37 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts @@ -22,17 +22,22 @@ * SOFTWARE. */ -import { Repository, RepositoryGroup } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository, RepositoryGroup } from "@scm-manager/ui-types"; -export default function groupByNamespace(repositories: Repository[]): RepositoryGroup[] { +export default function groupByNamespace( + repositories: Repository[], + namespaces: NamespaceCollection +): RepositoryGroup[] { const groups = {}; for (const repository of repositories) { const groupName = repository.namespace; let group = groups[groupName]; if (!group) { + const namespace = findNamespace(namespaces, groupName); group = { name: groupName, + namespace: namespace, repositories: [] }; groups[groupName] = group; @@ -58,3 +63,7 @@ function sortByName(a, b) { } return 0; } + +function findNamespace(namespaces: NamespaceCollection, namespaceToFind: string) { + return namespaces._embedded.namespaces.filter(namespace => namespace.namespace === namespaceToFind)[0]; +} diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 28343e57fe..0dae2f03d9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -133,12 +133,12 @@ class Overview extends React.Component { } renderRepositoryList() { - const { collection, page, location, t } = this.props; + const { collection, page, location, namespaces, t } = this.props; if (collection._embedded && collection._embedded.repositories.length > 0) { return ( <> - + ); From b4583a2b7c34b38c8d8f8738f225d57cc1b3b863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 14 Sep 2020 18:52:52 +0200 Subject: [PATCH 07/40] Introduce manager interface for namespaces --- .../java/sonia/scm/repository/Namespace.java | 104 +++++++++++++++ .../scm/repository/NamespaceManager.java | 54 ++++++++ .../repository/DefaultNamespaceManager.java | 78 ++++++++++++ .../repository/DefaultRepositoryManager.java | 4 + .../sonia/scm/repository/NamespaceDao.java | 49 ++++++++ .../DefaultNamespaceManagerTest.java | 119 ++++++++++++++++++ 6 files changed, 408 insertions(+) create mode 100644 scm-core/src/main/java/sonia/scm/repository/Namespace.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java create mode 100644 scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/Namespace.java b/scm-core/src/main/java/sonia/scm/repository/Namespace.java new file mode 100644 index 0000000000..0826a4f1ea --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -0,0 +1,104 @@ +/* + * 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 org.apache.commons.lang.builder.EqualsBuilder; +import org.apache.commons.lang.builder.HashCodeBuilder; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import static java.util.Collections.unmodifiableCollection; + +public class Namespace implements Cloneable { + + private String namespace; + private Set permissions = new HashSet<>(); + + public Namespace(String namespace) { + this.namespace = namespace; + } + + /** + * Constructor for JaxB, only. + */ + Namespace() { + } + + public String getNamespace() { + return namespace; + } + + public Collection getPermissions() { + return unmodifiableCollection(permissions); + } + + public void setPermissions(Collection permissions) { + this.permissions.clear(); + this.permissions.addAll(permissions); + } + + public void addPermission(RepositoryPermission newPermission) { + this.permissions.add(newPermission); + } + + public boolean removePermission(RepositoryPermission permission) { + return this.permissions.remove(permission); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof Namespace)) return false; + + Namespace namespace1 = (Namespace) o; + + return new EqualsBuilder() + .append(namespace, namespace1.namespace) + .append(permissions, namespace1.permissions) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(namespace) + .append(permissions) + .toHashCode(); + } + + @Override + public Namespace clone() { + try { + Namespace clone = (Namespace) super.clone(); + clone.permissions = new HashSet<>(permissions); + return clone; + } catch (CloneNotSupportedException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java new file mode 100644 index 0000000000..bdd8faac7d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java @@ -0,0 +1,54 @@ +/* + * 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 java.util.Collection; +import java.util.Optional; + +public interface NamespaceManager { + + /** + * Returns the Namespace with the given name. + * + * @param namespace The name of the requested namespace. + * @return Optional with the namespace for the given name, or an empty Optional if there is no such namespace + * (that is, there is no repository with this namespace). + */ + Optional get(String namespace); + + /** + * Returns a {@link java.util.Collection} of all namespaces. + * + * @return all namespaces + */ + Collection getAll(); + + /** + * Modifies the given namespace. + * + * @param namespace The namespace to be modified. + */ + void modify(Namespace namespace); +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java new file mode 100644 index 0000000000..e0d6ec9117 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java @@ -0,0 +1,78 @@ +/* + * 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 javax.inject.Inject; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class DefaultNamespaceManager implements NamespaceManager { + + private final RepositoryManager repositoryManager; + private final NamespaceDao dao; + + @Inject + public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao) { + this.repositoryManager = repositoryManager; + this.dao = dao; + } + + @Override + public Optional get(String namespace) { + return repositoryManager + .getAllNamespaces() + .stream() + .filter(n -> n.equals(namespace)) + .map(this::createNamespaceForName) + .findFirst(); + } + + @Override + public Collection getAll() { + return repositoryManager + .getAllNamespaces() + .stream() + .map(this::createNamespaceForName) + .collect(Collectors.toList()); + } + + @Override + public void modify(Namespace namespace) { + if (!get(namespace.getNamespace()).isPresent()) { + throw notFound(entity("Namespace", namespace.getNamespace())); + } + dao.add(namespace); + } + + private Namespace createNamespaceForName(String namespace) { + return dao.get(namespace) + .map(Namespace::clone) + .orElse(new Namespace(namespace)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index c75f54c3fb..075fcca82a 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -331,6 +331,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { return getAll(null, start, limit); } + /** + * @deprecated Use {@link NamespaceManager#getAll()} instead. + */ + @Deprecated @Override public Collection getAllNamespaces() { return getAll().stream() diff --git a/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java new file mode 100644 index 0000000000..a566ba879a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java @@ -0,0 +1,49 @@ +/* + * 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.store.DataStore; +import sonia.scm.store.DataStoreFactory; + +import javax.inject.Inject; +import java.util.Optional; + +public class NamespaceDao { + + private final DataStore store; + + @Inject + NamespaceDao(DataStoreFactory storeFactory) { + this.store = storeFactory.withType(Namespace.class).withName("namespaces").build(); + } + + public Optional get(String namespace) { + return store.getOptional(namespace); + } + + public void add(Namespace namespace) { + store.put(namespace.getNamespace(), namespace); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java new file mode 100644 index 0000000000..736e1a90d1 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -0,0 +1,119 @@ +/* + * 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 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.store.InMemoryDataStore; +import sonia.scm.store.InMemoryDataStoreFactory; + +import java.util.Collection; +import java.util.Optional; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +class DefaultNamespaceManagerTest { + + @Mock + RepositoryManager repositoryManager; + + Namespace life; + + NamespaceDao dao; + DefaultNamespaceManager manager; + private Namespace universe; + private Namespace rest; + + @BeforeEach + void mockExistingNamespaces() { + dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore())); + manager = new DefaultNamespaceManager(repositoryManager, dao); + + when(repositoryManager.getAllNamespaces()).thenReturn(asList("life", "universe", "rest")); + + life = new Namespace("life"); + RepositoryPermission lifePermission = new RepositoryPermission("humans", "OWNER", true); + life.addPermission(lifePermission); + dao.add(life); + + universe = new Namespace("universe"); + rest = new Namespace("rest"); + } + + @Test + void shouldCreateEmptyOptionalIfNamespaceDoesNotExist() { + Optional namespace = manager.get("dolphins"); + + assertThat(namespace).isEmpty(); + } + + @Test + void shouldCreateNewNamespaceObjectIfNotInStore() { + Namespace namespace = manager.get("universe").orElse(null); + + assertThat(namespace).isEqualTo(universe); + assertThat(namespace.getPermissions()).isEmpty(); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissions() { + Namespace namespace = manager.get("life").orElse(null); + + assertThat(namespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissionsInGetAll() { + Collection namespaces = manager.getAll(); + + assertThat(namespaces).containsExactly( + life, + universe, + rest + ); + Namespace foundLifeNamespace = namespaces.stream().filter(namespace -> namespace.getNamespace().equals("life")).findFirst().get(); + assertThat( + foundLifeNamespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldModifyExistingNamespaceWithPermissions() { + Namespace modifiedNamespace = manager.get("life").get(); + + modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false))); + manager.modify(modifiedNamespace); + + Namespace newLife = manager.get("life").get(); + + assertThat(newLife).isEqualTo(modifiedNamespace); + } +} From d6effce0f44a348f375879c69c21aa5e3a1d4397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Tue, 15 Sep 2020 08:44:09 +0200 Subject: [PATCH 08/40] Introduce namespace permissions --- .../java/sonia/scm/repository/Namespace.java | 20 ++++++++++++++++++- .../resources/META-INF/scm/permissions.xml | 6 ++++++ .../main/resources/locales/de/plugins.json | 10 ++++++++++ .../main/resources/locales/en/plugins.json | 10 ++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) 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 0826a4f1ea..d2421c102d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Namespace.java +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -24,16 +24,29 @@ package sonia.scm.repository; +import com.github.sdorra.ssp.PermissionObject; +import com.github.sdorra.ssp.StaticPermissions; import org.apache.commons.lang.builder.EqualsBuilder; import org.apache.commons.lang.builder.HashCodeBuilder; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; import java.util.Collection; import java.util.HashSet; import java.util.Set; import static java.util.Collections.unmodifiableCollection; -public class Namespace implements Cloneable { +@StaticPermissions( + value = "namespace", + globalPermissions = {"permissionRead", "permissionWrite"}, + permissions = {}, + custom = true, customGlobal = true +) +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "namespaces") +public class Namespace implements PermissionObject, Cloneable { private String namespace; private Set permissions = new HashSet<>(); @@ -69,6 +82,11 @@ public class Namespace implements Cloneable { return this.permissions.remove(permission); } + @Override + public String getId() { + return getNamespace(); + } + @Override public boolean equals(Object o) { if (this == o) return true; 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/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index c5ab04d950..8804aa1a16 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -93,6 +93,16 @@ "description": "Darf alle installierten und verfügbaren Plugins lesen und verwalten" } }, + "namespace": { + "permissionRead": { + "displayName": "Berechtigungen auf Namespaces lesen", + "description": "Darf die Berechtigungen auf Namespace-Ebene sehen" + }, + "permissionWrite": { + "displayName": "Berechtigungen auf Namespaces modifizieren", + "description": "Darf die Berechtigungen auf Namespace-Ebene bearbeiten" + } + }, "unknown": "Unbekannte Berechtigung" }, "verbs": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 7338d53267..05e70bb299 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -93,6 +93,16 @@ "description": "May see and manage all installed and available plugins" } }, + "namespace": { + "permissionRead": { + "displayName": "read permissions on namespaces", + "description": "May see the permissions set for namespaces" + }, + "permissionWrite": { + "displayName": "modify permissions on namespaces", + "description": "May modify the permissions set for namespaces" + } + }, "unknown": "Unknown permission" }, "verbs": { From 603fffc64b5848fb084631e3bf36d3940542beed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Tue, 15 Sep 2020 15:30:27 +0200 Subject: [PATCH 09/40] Evaluate namespace permissions --- .../DefaultAuthorizationCollector.java | 48 ++++++++------- .../DefaultAuthorizationCollectorTest.java | 60 +++++++++++++++---- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index 6d115db0f3..ddc65a8c0d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.security; import com.github.legman.Subscribe; import com.google.common.annotations.VisibleForTesting; @@ -46,17 +44,19 @@ import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserPermissions; -import sonia.scm.util.Util; import java.util.Collection; +import java.util.Optional; import java.util.Set; -//~--- JDK imports ------------------------------------------------------------ +import static java.util.Collections.emptySet; /** * @@ -85,16 +85,18 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * @param securitySystem * @param repositoryPermissionProvider * @param groupCollector + * @param namespaceDao */ @Inject public DefaultAuthorizationCollector(CacheManager cacheManager, - RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector) + RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector, NamespaceDao namespaceDao) { this.cache = cacheManager.getCache(CACHE_NAME); this.repositoryDAO = repositoryDAO; this.securitySystem = securitySystem; this.repositoryPermissionProvider = repositoryPermissionProvider; this.groupCollector = groupCollector; + this.namespaceDao = namespaceDao; } //~--- methods -------------------------------------------------------------- @@ -186,28 +188,27 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private void collectRepositoryPermissions(Builder builder, Repository repository, User user, Set groups) { - Collection repositoryPermissions = repository.getPermissions(); + Optional namespace = namespaceDao.get(repository.getNamespace()); - if (Util.isNotEmpty(repositoryPermissions)) + boolean hasPermission = false; + for (RepositoryPermission permission : repository.getPermissions()) { - boolean hasPermission = false; - for (RepositoryPermission permission : repositoryPermissions) - { - hasPermission = isUserPermitted(user, groups, permission); - if (hasPermission) { - addRepositoryPermission(builder, repository, user, permission); - } - } - - if (!hasPermission && logger.isTraceEnabled()) - { - logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); } } - else if (logger.isTraceEnabled()) + for (RepositoryPermission permission : namespace.map(Namespace::getPermissions).orElse(emptySet())) { - logger.trace("repository {} has no permission entries", - repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); + } + } + + if (!hasPermission && logger.isTraceEnabled()) + { + logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getNamespaceAndName()); } } @@ -371,4 +372,5 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private final RepositoryPermissionProvider repositoryPermissionProvider; private final GroupCollector groupCollector; + private final NamespaceDao namespaceDao; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index b351459fa8..30a6e42d10 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -21,13 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.subject.PrincipalCollection; @@ -45,6 +44,8 @@ import sonia.scm.SCMContext; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; @@ -53,8 +54,10 @@ import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserTestData; +import static com.google.common.collect.Lists.newArrayList; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; +import static java.util.Optional.of; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; @@ -92,6 +95,9 @@ public class DefaultAuthorizationCollectorTest { @Mock private GroupCollector groupCollector; + @Mock + private NamespaceDao namespaceDao; + private DefaultAuthorizationCollector collector; @Rule @@ -103,7 +109,7 @@ public class DefaultAuthorizationCollectorTest { @Before public void setUp(){ when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache); - collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector); + collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector, namespaceDao); } /** @@ -195,12 +201,44 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + heartOfGold.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, asList("read", "pull", "push"), true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + + // execute and assert + AuthorizationInfo authInfo = collector.collect(); + assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); + assertThat(authInfo.getObjectPermissions(), nullValue()); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); + } + + /** + * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with repository permissions. + */ + @Test + @SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini" + ) + public void testCollectWithNamespacePermissions() { + String group = "heart-of-gold-crew"; + authenticate(UserTestData.createTrillian(), group); + Repository heartOfGold = RepositoryTestData.createHeartOfGold(); + heartOfGold.setId("one"); + Namespace heartOfGoldNamespace = new Namespace(heartOfGold.getNamespace()); + heartOfGoldNamespace.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + + Repository puzzle42 = RepositoryTestData.create42Puzzle(); + puzzle42.setNamespace("guide"); + puzzle42.setId("two"); + Namespace puzzleNamespace = new Namespace(puzzle42.getNamespace()); + puzzleNamespace.setPermissions(newArrayList(new RepositoryPermission(group, asList("read", "pull", "push"), true))); + + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + when(namespaceDao.get(heartOfGold.getNamespace())).thenReturn(of(heartOfGoldNamespace)); + when(namespaceDao.get(puzzle42.getNamespace())).thenReturn(of(puzzleNamespace)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -228,15 +266,15 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList( + heartOfGold.setPermissions(newArrayList( new RepositoryPermission("trillian", "user role", false), new RepositoryPermission("trillian", "system role", false) )); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, "group role", true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -272,7 +310,7 @@ public class DefaultAuthorizationCollectorTest { heartOfGold.setPermissions(singletonList( new RepositoryPermission("trillian", "unknown", false) )); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -290,7 +328,7 @@ public class DefaultAuthorizationCollectorTest { StoredAssignedPermission p1 = new StoredAssignedPermission("one", new AssignedPermission("one", "one:one")); StoredAssignedPermission p2 = new StoredAssignedPermission("two", new AssignedPermission("two", "two:two")); - when(securitySystem.getPermissions(any())).thenReturn(Lists.newArrayList(p1, p2)); + when(securitySystem.getPermissions(any())).thenReturn(newArrayList(p1, p2)); // execute and assert AuthorizationInfo authInfo = collector.collect(); From 2000730c8d62cbbef28c79c59f5f04b4e9bd4086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 16 Sep 2020 07:32:16 +0200 Subject: [PATCH 10/40] Add REST endpoint for namespace permissions --- .../NamespacePermissionResource.java | 338 ++++++++++++++++++ .../api/v2/resources/NamespaceResource.java | 9 +- .../NamespaceToNamespaceDtoMapper.java | 20 +- ...sitoryPermissionCollectionToDtoMapper.java | 22 +- ...issionToRepositoryPermissionDtoMapper.java | 29 +- .../scm/api/v2/resources/ResourceLinks.java | 36 ++ .../lifecycle/modules/ScmServletModule.java | 3 + .../resources/NamespaceRootResourceTest.java | 333 +++++++++++++++-- .../api/v2/resources/ResourceLinksMock.java | 1 + 9 files changed, 744 insertions(+), 47 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java 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 new file mode 100644 index 0000000000..6ec97c282b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java @@ -0,0 +1,338 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +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; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +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; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; + +@Slf4j +public class NamespacePermissionResource { + + private RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper; + private RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper; + private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; + private ResourceLinks resourceLinks; + private final NamespaceManager manager; + + @Inject + public NamespacePermissionResource( + RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper, + RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper, + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper, + ResourceLinks resourceLinks, + NamespaceManager manager) { + this.dtoToModelMapper = dtoToModelMapper; + this.modelToDtoMapper = modelToDtoMapper; + this.repositoryPermissionCollectionToDtoMapper = repositoryPermissionCollectionToDtoMapper; + this.resourceLinks = resourceLinks; + this.manager = manager; + } + + /** + * Adds a new namespace permission for the user or group + * + * @param permission permission to add + * @return a web response with the status code 201 and the url to GET the added permission + */ + @POST + @Path("") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Create namespace-specific permission", description = "Adds a new permission to the namespace for the user or group.", tags = {"Namespace", "Permissions"}) + @ApiResponse( + responseCode = "201", + description = "creates", + headers = @Header( + name = "Location", + description = "uri of the created permission", + schema = @Schema(type = "string") + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "409", description = "conflict") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + 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); + String urlPermissionName = modelToDtoMapper.getUrlPermissionName(permission); + return Response.created(URI.create(resourceLinks.namespacePermission().self(namespaceName, urlPermissionName))).build(); + } + + /** + * Get the searched permission with permission name related to a namespace + * + * @param namespaceName the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace or the permission does not exists + */ + @GET + @Path("{permission-name}") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Get single repository-specific permission", description = "Get the searched permission with permission name related to a repository.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public RepositoryPermissionDto get(@PathParam("namespace") String namespaceName, @PathParam("permission-name") String permissionName) { + Namespace namespace = load(namespaceName); + NamespacePermissions.permissionRead().check(); + return + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .map(permission -> modelToDtoMapper.map(permission, namespace)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + } + + /** + * Get all permissions related to a namespace + * + * @param namespaceMame the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace does not exists + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "List of namespace-specific permissions", description = "Get all permissions related to a namespace.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public HalRepresentation getAll(@PathParam("namespace") String namespaceMame) { + Namespace namespace = load(namespaceMame); + NamespacePermissions.permissionRead().check(); + return repositoryPermissionCollectionToDtoMapper.map(namespace); + } + + /** + * Update a permission to the user or group managed by the repository + * ignore the user input for groupPermission and take it from the path parameter (if the group prefix (@) exists it is a group permission) + * + * @param permission permission to modify + * @param permissionName permission to modify + * @return a web response with the status code 204 + */ + @PUT + @Path("{permission-name}") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Update repository-specific permission", description = "Update a permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void update(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName, + @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)); + } + permission.setGroupPermission(isGroupPermission(permissionName)); + if (!extractedPermissionName.equals(permission.getName())) { + checkPermissionAlreadyExists(permission, namespace); + } + + RepositoryPermission existingPermission = namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + RepositoryPermission newPermission = dtoToModelMapper.map(permission); + if (!namespace.removePermission(existingPermission)) { + throw new IllegalStateException(String.format("could not delete modified permission %s from namespace %s", existingPermission, namespaceName)); + } + namespace.addPermission(newPermission); + manager.modify(namespace); + log.info("the permission with name: {} is updated.", permissionName); + } + + /** + * Update a permission to the user or group managed by the repository + * + * @param permissionName permission to delete + * @return a web response with the status code 204 + */ + @DELETE + @Path("{permission-name}") + @Operation(summary = "Delete repository-specific permission", description = "Delete a permission with the given name.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "delete success or nothing to delete") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void delete(@PathParam("namespace") String namespaceName, + @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)) + .findFirst() + .ifPresent(permission -> { + namespace.removePermission(permission); + manager.modify(namespace); + }); + log.info("the permission with name: {} is deleted.", permissionName); + } + + private Predicate filterPermission(String name) { + return permission -> getPermissionName(name).equals(permission.getName()) + && + permission.isGroupPermission() == isGroupPermission(name); + } + + private String getPermissionName(String permissionName) { + return Optional.of(permissionName) + .filter(p -> !isGroupPermission(permissionName)) + .orElse(permissionName.substring(1)); + } + + private boolean isGroupPermission(String permissionName) { + return permissionName.startsWith(GROUP_PREFIX); + } + + private Namespace load(String namespaceMame) { + return manager.get(namespaceMame) + .orElseThrow(() -> notFound(entity("Namespace", namespaceMame))); + } + + private void checkPermissionAlreadyExists(RepositoryPermissionDto permission, Namespace namespace) { + if (isPermissionExist(permission, namespace)) { + throw alreadyExists(entity("Permission", permission.getName()).in(Namespace.class, namespace.getNamespace())); + } + } + + private boolean isPermissionExist(RepositoryPermissionDto permission, Namespace namespace) { + return namespace.getPermissions() + .stream() + .anyMatch(p -> p.getName().equals(permission.getName()) && p.isGroupPermission() == permission.isGroupPermission()); + } +} + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java index 8c7fdc0a05..1f0c828ca7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java @@ -32,6 +32,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -44,11 +45,13 @@ public class NamespaceResource { private final RepositoryManager manager; private final NamespaceToNamespaceDtoMapper namespaceMapper; + private final Provider namespacePermissionResource; @Inject - public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper) { + public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper, Provider namespacePermissionResource) { this.manager = manager; this.namespaceMapper = namespaceMapper; + this.namespacePermissionResource = namespacePermissionResource; } /** @@ -97,4 +100,8 @@ public class NamespaceResource { .orElseThrow(() -> notFound(entity("Namespace", namespace))); } + @Path("permissions") + public NamespacePermissionResource permissions() { + return namespacePermissionResource.get(); + } } 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 7a464299ea..5067f95c0f 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 @@ -24,6 +24,9 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Links; +import sonia.scm.repository.NamespacePermissions; + import javax.inject.Inject; import static de.otto.edison.hal.Link.link; @@ -39,12 +42,15 @@ class NamespaceToNamespaceDtoMapper { } NamespaceDto map(String namespace) { - return new NamespaceDto( - namespace, - linkingTo() - .self(links.namespace().self(namespace)) - .single(link("repositories", links.repositoryCollection().forNamespace(namespace))) - .build() - ); + Links.Builder linkingTo = linkingTo(); + linkingTo + .self(links.namespace().self(namespace)) + .single(link("repositories", links.repositoryCollection().forNamespace(namespace))); + + if (NamespacePermissions.permissionRead().isPermitted()) { + linkingTo + .single(link("permissions", links.namespacePermission().all(namespace))); + } + return new NamespaceDto(namespace, linkingTo.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java index 765ffe8168..db285c369a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java @@ -21,13 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -57,6 +59,14 @@ public class RepositoryPermissionCollectionToDtoMapper { return new HalRepresentation(createLinks(repository), embedDtos(repositoryPermissionDtoList)); } + public HalRepresentation map(Namespace namespace) { + List repositoryPermissionDtoList = namespace.getPermissions() + .stream() + .map(permission -> repositoryPermissionToRepositoryPermissionDtoMapper.map(permission, namespace)) + .collect(toList()); + return new HalRepresentation(createLinks(namespace), embedDtos(repositoryPermissionDtoList)); + } + private Links createLinks(Repository repository) { RepositoryPermissions.permissionRead(repository).check(); Links.Builder linksBuilder = linkingTo() @@ -67,6 +77,16 @@ public class RepositoryPermissionCollectionToDtoMapper { return linksBuilder.build(); } + private Links createLinks(Namespace namespace) { + NamespacePermissions.permissionRead().check(); + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(resourceLinks.namespacePermission().all(namespace.getNamespace())).build()); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("create", resourceLinks.namespacePermission().create(namespace.getNamespace()))); + } + return linksBuilder.build(); + } + private Embedded embedDtos(List repositoryPermissionDtoList) { return embeddedBuilder() .with("permissions", repositoryPermissionDtoList) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java index 11fd658c33..37e3b097f9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.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.api.v2.resources; import de.otto.edison.hal.Links; @@ -31,6 +31,8 @@ import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -51,18 +53,19 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Repository repository); + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Namespace namespace); @BeforeMapping void validatePermissions(@Context Repository repository) { RepositoryPermissions.permissionRead(repository).check(); } - /** - * Add the self, update and delete links. - * - * @param target the mapped dto - * @param repository the repository - */ + @BeforeMapping + void validatePermissions(@Context Namespace namespace) { + NamespacePermissions.permissionRead().check(); + } + @AfterMapping void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) { String permissionName = getUrlPermissionName(target); @@ -75,6 +78,18 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { target.add(linksBuilder.build()); } + @AfterMapping + void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Namespace namespace) { + String permissionName = getUrlPermissionName(target); + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.namespacePermission().self(namespace.getNamespace(), permissionName)); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("update", resourceLinks.namespacePermission().update(namespace.getNamespace(), permissionName))); + linksBuilder.single(link("delete", resourceLinks.namespacePermission().delete(namespace.getNamespace(), permissionName))); + } + target.add(linksBuilder.build()); + } + public String getUrlPermissionName(RepositoryPermissionDto repositoryPermissionDto) { return Optional.of(repositoryPermissionDto.getName()) .filter(p -> !repositoryPermissionDto.isGroupPermission()) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3dd2180ede..99a2058249 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -916,4 +916,40 @@ class ResourceLinks { return namespaceLinkBuilder.method("getNamespaceResource").parameters().method("get").parameters(namespace).href(); } } + + public NamespacePermissionLinks namespacePermission() { + return new NamespacePermissionLinks(scmPathInfoStore.get()); + } + + static class NamespacePermissionLinks { + private final LinkBuilder permissionLinkBuilder; + + NamespacePermissionLinks(ScmPathInfo pathInfo) { + permissionLinkBuilder = new LinkBuilder(pathInfo, NamespaceRootResource.class, NamespaceResource.class, NamespacePermissionResource.class); + } + + String all(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("getAll").parameters().href(); + } + + String create(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("create").parameters().href(); + } + + String self(String namespace, String permissionName) { + return getLink(namespace, permissionName, "get"); + } + + String update(String namespace, String permissionName) { + return getLink(namespace, permissionName, "update"); + } + + String delete(String namespace, String permissionName) { + return getLink(namespace, permissionName, "delete"); + } + + private String getLink(String namespace, String permissionName, String methodName) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method(methodName).parameters(permissionName).href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index ec7499aa02..776c196190 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -64,10 +64,12 @@ import sonia.scm.net.ahc.XmlContentTransformer; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; +import sonia.scm.repository.DefaultNamespaceManager; import sonia.scm.repository.DefaultRepositoryManager; import sonia.scm.repository.DefaultRepositoryProvider; import sonia.scm.repository.DefaultRepositoryRoleManager; import sonia.scm.repository.HealthCheckContextListener; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.NamespaceStrategyProvider; import sonia.scm.repository.Repository; @@ -191,6 +193,7 @@ class ScmServletModule extends ServletModule { bindDecorated(GroupManager.class, DefaultGroupManager.class, GroupManagerProvider.class); bind(GroupDisplayManager.class, DefaultGroupDisplayManager.class); + bind(NamespaceManager.class, DefaultNamespaceManager.class); bind(GroupCollector.class, DefaultGroupCollector.class); bind(CGIExecutorFactory.class, DefaultCGIExecutorFactory.class); 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 669cc70544..d322b627bb 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 @@ -24,83 +24,354 @@ package sonia.scm.api.v2.resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.RestDispatcher; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import static com.google.inject.util.Providers.of; import static java.util.Arrays.asList; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class NamespaceRootResourceTest { @Mock RepositoryManager repositoryManager; + @Mock + NamespaceManager namespaceManager; + @Mock + Subject subject; RestDispatcher dispatcher = new RestDispatcher(); MockHttpResponse response = new MockHttpResponse(); ResourceLinks links = ResourceLinksMock.createMock(URI.create("/")); + @InjectMocks + RepositoryPermissionToRepositoryPermissionDtoMapperImpl repositoryPermissionToRepositoryPermissionDtoMapper; + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @BeforeEach void setUpResources() { NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links); NamespaceCollectionToDtoMapper namespaceCollectionToDtoMapper = new NamespaceCollectionToDtoMapper(namespaceMapper, links); + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(repositoryPermissionToRepositoryPermissionDtoMapper, links); + RepositoryPermissionDtoToRepositoryPermissionMapperImpl dtoToModelMapper = new RepositoryPermissionDtoToRepositoryPermissionMapperImpl(); + NamespaceCollectionResource namespaceCollectionResource = new NamespaceCollectionResource(repositoryManager, namespaceCollectionToDtoMapper); - NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper); + NamespacePermissionResource namespacePermissionResource = new NamespacePermissionResource(dtoToModelMapper, repositoryPermissionToRepositoryPermissionDtoMapper, repositoryPermissionCollectionToDtoMapper, links, namespaceManager); + NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper, of(namespacePermissionResource)); dispatcher.addSingletonResource(new NamespaceRootResource(of(namespaceCollectionResource), of(namespaceResource))); } - @Test - void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); - - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - - dispatcher.invoke(request, response); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") - .contains("\"_embedded\"") - .contains("\"namespace\":\"hitchhiker\"") - .contains("\"namespace\":\"space\""); + @BeforeEach + void mockExistingNamespaces() { + lenient().when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + Namespace hitchhikerNamespace = new Namespace("hitchhiker"); + hitchhikerNamespace.setPermissions(singleton(new RepositoryPermission("humans", "READ", true))); + Namespace spaceNamespace = new Namespace("space"); + lenient().when(namespaceManager.getAll()).thenReturn(asList(hitchhikerNamespace, spaceNamespace)); + lenient().when(namespaceManager.get("hitchhiker")).thenReturn(Optional.of(hitchhikerNamespace)); + lenient().when(namespaceManager.get("space")).thenReturn(Optional.of(spaceNamespace)); } - @Test - void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithoutSpecialPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + @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"); + } - dispatcher.invoke(request, response); + @Test + void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"namespace\":\"space\"") - .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") + .contains("\"_embedded\"") + .contains("\"namespace\":\"hitchhiker\"") + .contains("\"namespace\":\"space\""); + } + + @Test + void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"namespace\":\"space\"") + .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}") + .doesNotContain("permissions"); + } + + @Test + void shouldHandleUnknownNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotReturnPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } } - @Test - void shouldHandleUnknownNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithReadPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + @BeforeEach + void grantReadPermission() { + lenient().when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldContainPermissionLinkWhenPermitted() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); - assertThat(response.getStatus()).isEqualTo(404); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"permissions\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldReturnPermissions() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("create"); + } + + @Test + void shouldReturnSinglePermission() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("update") + .doesNotContain("delete"); + } + + @Test + void shouldHandleMissingNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "no_such_namespace/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void shouldNotDeletePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Nested + class WithWritePermission { + + @BeforeEach + void grantWritePermission() { + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(true); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite"); + } + + @Test + void shouldContainCreateLink() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"create\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldContainModificationLinks() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"update\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"") + .contains("\"delete\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\""); + } + + @Test + void shouldCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(201); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("dent"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isFalse(); + return true; + }) + ); + assertThat(response.getOutputHeaders().get("Location")) + .containsExactly(URI.create("/v2/namespaces/space/permissions/dent")); + } + + @Test + void shouldUpdatePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("humans"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isTrue(); + return true; + }) + ); + } + + @Test + void shouldHandleNotExistingPermissionOnUpdate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldHandleExistingPermissionOnCreate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + verify(namespaceManager, never()).modify(any()); + } + + @Test + void shouldDeleteExistingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).isEmpty(); + return true; + }) + ); + } + + @Test + void shouldHandleRedundantDeleteIdempotent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager, never()).modify(any()); + } + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 38a1055211..f2d6e85710 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -78,6 +78,7 @@ public class ResourceLinksMock { lenient().when(resourceLinks.annotate()).thenReturn(new ResourceLinks.AnnotateLinks(pathInfo)); lenient().when(resourceLinks.namespace()).thenReturn(new ResourceLinks.NamespaceLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); + lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); return resourceLinks; } From cccbf247f25352a920dfc2de93c5ea2f1585fa30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 07:49:37 +0200 Subject: [PATCH 11/40] Add UI for namespace permissions --- .../public/locales/de/namespaces.json | 9 ++ .../public/locales/en/namespaces.json | 9 ++ scm-ui/ui-webapp/src/containers/Main.tsx | 2 + scm-ui/ui-webapp/src/repos/modules/repos.ts | 96 +++++++++++- .../namespaces/containers/NamespaceRoot.tsx | 145 ++++++++++++++++++ .../containers/PermissionsNavLink.tsx | 47 ++++++ .../permissions/containers/Permissions.tsx | 21 ++- .../containers/SinglePermission.tsx | 6 +- .../repos/permissions/modules/permissions.ts | 145 ++++++++++-------- 9 files changed, 394 insertions(+), 86 deletions(-) create mode 100644 scm-ui/ui-webapp/public/locales/de/namespaces.json create mode 100644 scm-ui/ui-webapp/public/locales/en/namespaces.json create mode 100644 scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx create mode 100644 scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx 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/en/namespaces.json b/scm-ui/ui-webapp/public/locales/en/namespaces.json new file mode 100644 index 0000000000..a4b99c39ee --- /dev/null +++ b/scm-ui/ui-webapp/public/locales/en/namespaces.json @@ -0,0 +1,9 @@ +{ + "namespaceRoot": { + "menu": { + "navigationLabel": "Namespace", + "settingsNavLink": "Settings", + "permissionsNavLink": "Permissions" + } + } +} diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 48e6f37a76..e0af6ee867 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -46,6 +46,7 @@ import CreateGroup from "../groups/containers/CreateGroup"; import Admin from "../admin/containers/Admin"; import Profile from "./Profile"; +import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot"; type Props = { me: Me; @@ -80,6 +81,7 @@ class Main extends React.Component { + diff --git a/scm-ui/ui-webapp/src/repos/modules/repos.ts b/scm-ui/ui-webapp/src/repos/modules/repos.ts index a4dc9d2e8c..a38dcbb76f 100644 --- a/scm-ui/ui-webapp/src/repos/modules/repos.ts +++ b/scm-ui/ui-webapp/src/repos/modules/repos.ts @@ -25,7 +25,7 @@ import { apiClient } from "@scm-manager/ui-components"; import * as types from "../../modules/types"; import { - Action, + Action, Namespace, NamespaceCollection, Repository, RepositoryCollection, @@ -66,6 +66,11 @@ export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`; +export const FETCH_NAMESPACE = "scm/repos/FETCH_NAMESPACE"; +export const FETCH_NAMESPACE_PENDING = `${FETCH_NAMESPACE}_${types.PENDING_SUFFIX}`; +export const FETCH_NAMESPACE_SUCCESS = `${FETCH_NAMESPACE}_${types.SUCCESS_SUFFIX}`; +export const FETCH_NAMESPACE_FAILURE = `${FETCH_NAMESPACE}_${types.FAILURE_SUFFIX}`; + export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2"; export const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy"; @@ -388,6 +393,50 @@ export function deleteRepoFailure(repository: Repository, error: Error): Action }; } +export function fetchNamespace(link: string, namespaceName: string) { + return function(dispatch: any) { + dispatch(fetchNamespacePending(namespaceName)); + return apiClient + .get(link) + .then(response => response.json()) + .then(namespace => { + dispatch(fetchNamespaceSuccess(namespace)); + }) + .catch(err => { + dispatch(fetchNamespaceFailure(namespaceName, err)); + }); + }; +} + +export function fetchNamespacePending(namespaceName: string): Action { + return { + type: FETCH_NAMESPACE_PENDING, + payload: { + namespaceName + }, + itemId: namespaceName + }; +} + +export function fetchNamespaceSuccess(namespace: Namespace): Action { + return { + type: FETCH_NAMESPACE_SUCCESS, + payload: namespace, + itemId: namespace.namespace + }; +} + +export function fetchNamespaceFailure(namespaceName: string, error: Error): Action { + return { + type: FETCH_NAMESPACE_FAILURE, + payload: { + namespaceName, + error + }, + itemId: namespaceName + }; +} + // reducer function createIdentifier(repository: Repository) { @@ -425,6 +474,17 @@ const reducerByNames = (state: object, repository: Repository) => { }; }; +const reducerForNamespace = (state: object, namespace: Namespace) => { + const identifier = namespace.namespace; + return { + ...state, + namespacesByNames: { + ...state.namespacesByNames, + [identifier]: namespace + } + }; +}; + const reducerForNamespaces = (state: object, namespaces: NamespaceCollection) => { return { ...state, @@ -449,6 +509,8 @@ export default function reducer( return reducerForNamespaces(state, action.payload); case FETCH_REPO_SUCCESS: return reducerByNames(state, action.payload); + case FETCH_NAMESPACE_SUCCESS: + return reducerForNamespace(state, action.payload); default: return state; } @@ -497,10 +559,17 @@ export function getFetchNamespacesFailure(state: object) { return getFailure(state, FETCH_NAMESPACES); } -export function getNamespace(state: object, namespace: string) { - if (state.namespaces) { - return state.namespaces[namespace]; - } +export function isFetchNamespacePending(state: object) { + return isPending(state, FETCH_NAMESPACE); +} + +export function getFetchNamespaceFailure(state: object) { + return getFailure(state, FETCH_NAMESPACE); +} + +export function fetchNamespaceByName(link: string, namespaceName: string) { + const namespaceUrl = link.endsWith("/") ? link : link + "/"; + return fetchNamespace(`${namespaceUrl}${namespaceName}`, namespaceName); } export function isFetchRepoPending(state: object, namespace: string, name: string) { @@ -511,6 +580,12 @@ export function getFetchRepoFailure(state: object, namespace: string, name: stri return getFailure(state, FETCH_REPO, namespace + "/" + name); } +export function getNamespace(state: object, namespaceName: string) { + if (state.repos && state.repos.namespacesByNames) { + return state.repos.namespacesByNames[namespaceName]; + } +} + export function isAbleToCreateRepos(state: object) { return !!(state.repos && state.repos.list && state.repos.list._links && state.repos.list._links.create); } @@ -539,7 +614,12 @@ export function getDeleteRepoFailure(state: object, namespace: string, name: str return getFailure(state, DELETE_REPO, namespace + "/" + name); } -export function getPermissionsLink(state: object, namespace: string, name: string) { - const repo = getRepository(state, namespace, name); - return repo && repo._links ? repo._links.permissions.href : undefined; +export function getPermissionsLink(state: object, namespaceName: string, repoName?: string) { + if (repoName) { + const repo = getRepository(state, namespaceName, repoName); + return repo && repo._links ? repo._links.permissions.href : undefined; + } else { + const namespace = getNamespace(state, namespaceName); + return namespace && namespace._links ? namespace._links.permissions.href : undefined; + } } diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx new file mode 100644 index 0000000000..a3cbb89610 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -0,0 +1,145 @@ +/* + * 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 React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { fetchNamespaceByName, getNamespace, isFetchNamespacePending } from "../../modules/repos"; +import { getNamespacesLink } from "../../../modules/indexResource"; +import { Namespace } from "@scm-manager/ui-types"; +import { + CustomQueryFlexWrappedColumns, + ErrorPage, + Loading, + Page, PrimaryContentColumn, + SecondaryNavigation, + SecondaryNavigationColumn, + StateMenuContextProvider, + SubNavigation +} from "@scm-manager/ui-components"; +import Permissions from "../../permissions/containers/Permissions"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import PermissionsNavLink from "./PermissionsNavLink"; + +type Props = RouteComponentProps & + WithTranslation & { + loading: boolean; + namespaceName: string; + namespacesLink: string; + namespace: Namespace; + + // dispatch functions + fetchNamespace: (link: string, namespace: string) => void; + }; + +class NamespaceRoot extends React.Component { + componentDidMount() { + const { namespacesLink, namespaceName, fetchNamespace } = this.props; + fetchNamespace(namespacesLink, namespaceName); + } + + stripEndingSlash = (url: string) => { + if (url.endsWith("/")) { + return url.substring(0, url.length - 1); + } + return url; + }; + + matchedUrl = () => { + return this.stripEndingSlash(this.props.match.url); + }; + + render() { + const { loading, error, namespaceName, namespace, t } = this.props; + const url = this.matchedUrl(); + + const extensionProps = { + namespace, + url + }; + + if (error) { + return ( + + ); + } + + if (!namespace || loading) { + return ; + } + + return ( + + + + + + + { + return ; + }} + /> + + + + + + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state: any, ownProps: Props) => { + const { namespaceName } = ownProps.match.params; + const namespacesLink = getNamespacesLink(state); + const namespace = getNamespace(state, namespaceName); + const loading = isFetchNamespacePending(state); + return { namespaceName, namespacesLink, loading, namespace }; +}; + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchNamespace: (link: string, namespaceName: string) => { + dispatch(fetchNamespaceByName(link, namespaceName)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("namespaces")(NamespaceRoot)); diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx new file mode 100644 index 0000000000..af3094a990 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx @@ -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. + */ +import React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { Namespace } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = WithTranslation & { + permissionUrl: string; + namespace: Namespace; +}; + +class PermissionsNavLink extends React.Component { + hasPermissionsLink = () => { + return this.props.namespace._links.permissions; + }; + render() { + if (!this.hasPermissionsLink()) { + return null; + } + const { permissionUrl, t } = this.props; + return ; + } +} + +export default withTranslation("namespaces")(PermissionsNavLink); diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx index cb0b285aa4..3ab269db50 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx @@ -57,13 +57,12 @@ import { getRepositoryVerbsLink, getUserAutoCompleteLink } from "../../../modules/indexResource"; - type Props = WithTranslation & { availablePermissions: boolean; availableRepositoryRoles: RepositoryRole[]; availableVerbs: string[]; namespace: string; - repoName: string; + repoName?: string; loading: boolean; error: Error; permissions: PermissionCollection; @@ -77,17 +76,17 @@ type Props = WithTranslation & { // dispatch functions fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void; - fetchPermissions: (link: string, namespace: string, repoName: string) => void; + fetchPermissions: (link: string, namespace: string, repoName?: string) => void; createPermission: ( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) => void; - createPermissionReset: (p1: string, p2: string) => void; - modifyPermissionReset: (p1: string, p2: string) => void; - deletePermissionReset: (p1: string, p2: string) => void; + createPermissionReset: (namespace: string, repoName?: string) => void; + modifyPermissionReset: (namespace: string, repoName?: string) => void; + deletePermissionReset: (namespace: string, repoName?: string) => void; // context props match: any; @@ -241,7 +240,7 @@ const mapStateToProps = (state: any, ownProps: Props) => { const mapDispatchToProps = (dispatch: any) => { return { - fetchPermissions: (link: string, namespace: string, repoName: string) => { + fetchPermissions: (link: string, namespace: string, repoName?: string) => { dispatch(fetchPermissions(link, namespace, repoName)); }, fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => { @@ -256,13 +255,13 @@ const mapDispatchToProps = (dispatch: any) => { ) => { dispatch(createPermission(link, permission, namespace, repoName, callback)); }, - createPermissionReset: (namespace: string, repoName: string) => { + createPermissionReset: (namespace: string, repoName?: string) => { dispatch(createPermissionReset(namespace, repoName)); }, - modifyPermissionReset: (namespace: string, repoName: string) => { + modifyPermissionReset: (namespace: string, repoName?: string) => { dispatch(modifyPermissionReset(namespace, repoName)); }, - deletePermissionReset: (namespace: string, repoName: string) => { + deletePermissionReset: (namespace: string, repoName?: string) => { dispatch(deletePermissionReset(namespace, repoName)); } }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx index 3be203f7dc..48337446d4 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx @@ -43,14 +43,14 @@ type Props = WithTranslation & { availableRepositoryRoles: RepositoryRole[]; availableRepositoryVerbs: string[]; submitForm: (p: Permission) => void; - modifyPermission: (permission: Permission, namespace: string, name: string) => void; + modifyPermission: (permission: Permission, namespace: string, name?: string) => void; permission: Permission; namespace: string; - repoName: string; + repoName?: string; match: any; history: History; loading: boolean; - deletePermission: (permission: Permission, namespace: string, name: string) => void; + deletePermission: (permission: Permission, namespace: string, name?: string) => void; deleteLoading: boolean; }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts index 55a446ab80..34852c17de 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts +++ b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts @@ -131,7 +131,7 @@ export function fetchAvailableFailure(error: Error): Action { // fetch permissions -export function fetchPermissions(link: string, namespace: string, repoName: string) { +export function fetchPermissions(link: string, namespace: string, repoName?: string) { return function(dispatch: any) { dispatch(fetchPermissionsPending(namespace, repoName)); return apiClient @@ -146,26 +146,26 @@ export function fetchPermissions(link: string, namespace: string, repoName: stri }; } -export function fetchPermissionsPending(namespace: string, repoName: string): Action { +export function fetchPermissionsPending(namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_PENDING, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName: string): Action { +export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_SUCCESS, payload: permissions, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsFailure(namespace: string, repoName: string, error: Error): Action { +export function fetchPermissionsFailure(namespace: string, repoName?: string, error: Error): Action { return { type: FETCH_PERMISSIONS_FAILURE, payload: { @@ -173,13 +173,13 @@ export function fetchPermissionsFailure(namespace: string, repoName: string, err repoName, error }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // modify permission -export function modifyPermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function modifyPermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(modifyPermissionPending(permission, namespace, repoName)); return apiClient @@ -196,7 +196,7 @@ export function modifyPermission(permission: Permission, namespace: string, repo }; } -export function modifyPermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_PENDING, payload: permission, @@ -204,12 +204,12 @@ export function modifyPermissionPending(permission: Permission, namespace: strin }; } -export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -219,7 +219,7 @@ export function modifyPermissionFailure( permission: Permission, error: Error, namespace: string, - repoName: string + repoName?: string ): Action { return { type: MODIFY_PERMISSION_FAILURE, @@ -240,14 +240,14 @@ function newPermissions(oldPermissions: PermissionCollection, newPermission: Per } } -export function modifyPermissionReset(namespace: string, repoName: string) { +export function modifyPermissionReset(namespace: string, repoName?: string) { return { type: MODIFY_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -256,7 +256,7 @@ export function createPermission( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) { return function(dispatch: Dispatch) { @@ -281,48 +281,48 @@ export function createPermission( export function createPermissionPending( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_PENDING, payload: permission, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } export function createPermissionSuccess( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionFailure(error: Error, namespace: string, repoName: string): Action { +export function createPermissionFailure(error: Error, namespace: string, repoName?: string): Action { return { type: CREATE_PERMISSION_FAILURE, payload: error, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionReset(namespace: string, repoName: string) { +export function createPermissionReset(namespace: string, repoName?: string) { return { type: CREATE_PERMISSION_RESET, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // delete permission -export function deletePermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function deletePermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(deletePermissionPending(permission, namespace, repoName)); return apiClient @@ -339,7 +339,7 @@ export function deletePermission(permission: Permission, namespace: string, repo }; } -export function deletePermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_PENDING, payload: permission, @@ -347,12 +347,12 @@ export function deletePermissionPending(permission: Permission, namespace: strin }; } -export function deletePermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -361,7 +361,7 @@ export function deletePermissionSuccess(permission: Permission, namespace: strin export function deletePermissionFailure( permission: Permission, namespace: string, - repoName: string, + repoName?: string, error: Error ): Action { return { @@ -374,14 +374,14 @@ export function deletePermissionFailure( }; } -export function deletePermissionReset(namespace: string, repoName: string) { +export function deletePermissionReset(namespace: string, repoName?: string) { return { type: DELETE_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -398,9 +398,9 @@ function deletePermissionFromState(oldPermissions: PermissionCollection, permiss return newPermission; } -function createItemId(permission: Permission, namespace: string, repoName: string) { +function createItemId(permission: Permission, namespace: string, repoName?: string) { const groupPermission = permission.groupPermission ? "@" : ""; - return namespace + "/" + repoName + "/" + groupPermission + permission.name; + return createPermissionStateKey(namespace, repoName) + "/" + groupPermission + permission.name; } // reducer @@ -427,7 +427,7 @@ export default function reducer( createPermission: !!action.payload._links.create } }; - case MODIFY_PERMISSION_SUCCESS: + case MODIFY_PERMISSION_SUCCESS: { const positionOfPermission = action.payload.position; const newPermission = newPermissions(state[action.payload.position].entries, action.payload.permission); return { @@ -437,7 +437,8 @@ export default function reducer( entries: newPermission } }; - case CREATE_PERMISSION_SUCCESS: + } + case CREATE_PERMISSION_SUCCESS: { // return state; const position = action.payload.position; const permissions = state[action.payload.position].entries; @@ -449,9 +450,10 @@ export default function reducer( entries: permissions } }; - case DELETE_PERMISSION_SUCCESS: + } + case DELETE_PERMISSION_SUCCESS: { const permissionPosition = action.payload.position; - const new_Permissions = deletePermissionFromState( + const newPermissions = deletePermissionFromState( state[action.payload.position].entries, action.payload.permission ); @@ -459,9 +461,10 @@ export default function reducer( ...state, [permissionPosition]: { ...state[permissionPosition], - entries: new_Permissions + entries: newPermissions } }; + } default: return state; } @@ -490,9 +493,9 @@ function available(state: object) { return {}; } -export function getPermissionsOfRepo(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) { - return state.permissions[namespace + "/" + repoName].entries; +export function getPermissionsOfRepo(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) { + return state.permissions[createPermissionStateKey(namespace, repoName)].entries; } } @@ -500,52 +503,62 @@ export function isFetchAvailablePermissionsPending(state: object) { return isPending(state, FETCH_AVAILABLE, "available"); } -export function isFetchPermissionsPending(state: object, namespace: string, repoName: string) { - return isPending(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function isFetchPermissionsPending(state: object, namespace: string, repoName?: string) { + return isPending(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } export function getFetchAvailablePermissionsFailure(state: object) { return getFailure(state, FETCH_AVAILABLE, "available"); } -export function getFetchPermissionsFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function getFetchPermissionsFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } -export function isModifyPermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { - return isPending(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function isModifyPermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { + return isPending(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function getModifyPermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { - return getFailure(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function getModifyPermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { + return getFailure(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function hasCreatePermission(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) - return state.permissions[namespace + "/" + repoName].createPermission; +export function hasCreatePermission(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) + return state.permissions[createPermissionStateKey(namespace, repoName)].createPermission; else return null; } -export function isCreatePermissionPending(state: object, namespace: string, repoName: string) { - return isPending(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function isCreatePermissionPending(state: object, namespace: string, repoName?: string) { + return isPending(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function getCreatePermissionFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function getCreatePermissionFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function isDeletePermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { +export function isDeletePermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { return isPending(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { +export function getDeletePermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { return getFailure(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionsFailure(state: object, namespace: string, repoName: string) { +export function getDeletePermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -556,10 +569,10 @@ export function getDeletePermissionsFailure(state: object, namespace: string, re return null; } -export function getModifyPermissionsFailure(state: object, namespace: string, repoName: string) { +export function getModifyPermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -570,6 +583,10 @@ export function getModifyPermissionsFailure(state: object, namespace: string, re return null; } +function createPermissionStateKey(namespace: string, repoName?: string) { + return namespace + (repoName ? "/" + repoName : ""); +} + export function findVerbsForRole(availableRepositoryRoles: RepositoryRole[], roleName: string) { const matchingRole = availableRepositoryRoles.find(role => roleName === role.name); if (matchingRole) { From 7777dd46eaacb1a5336539ce19fc250c4ad59c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 08:21:31 +0200 Subject: [PATCH 12/40] Add settings icon to namespace group header --- scm-ui/ui-components/src/CardColumnGroup.tsx | 2 +- scm-ui/ui-types/src/Repositories.ts | 1 + .../components/list/RepositoryGroupEntry.tsx | 17 ++++++++++++++++- .../repos/components/list/RepositoryList.tsx | 7 ++++--- .../components/list/groupByNamespace.test.ts | 10 +++++++++- .../repos/components/list/groupByNamespace.ts | 13 +++++++++++-- .../ui-webapp/src/repos/containers/Overview.tsx | 4 ++-- 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/scm-ui/ui-components/src/CardColumnGroup.tsx b/scm-ui/ui-components/src/CardColumnGroup.tsx index c5c07055f2..d9a769a98c 100644 --- a/scm-ui/ui-components/src/CardColumnGroup.tsx +++ b/scm-ui/ui-components/src/CardColumnGroup.tsx @@ -27,7 +27,7 @@ import classNames from "classnames"; import styled from "styled-components"; type Props = { - name: string; + name: ReactNode; url?: string; elements: ReactNode[]; }; diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 8a7a4fee97..b585bffb65 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -58,5 +58,6 @@ export type NamespaceCollection = { export type RepositoryGroup = { name: string; + namespace: Namespace; repositories: Repository[]; }; diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx index 146b007cfc..cdf13dde44 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx @@ -22,8 +22,10 @@ * SOFTWARE. */ import React from "react"; +import { Link } from "react-router-dom"; import { CardColumnGroup, RepositoryEntry } from "@scm-manager/ui-components"; import { RepositoryGroup } from "@scm-manager/ui-types"; +import { Icon } from "@scm-manager/ui-components"; type Props = { group: RepositoryGroup; @@ -32,10 +34,23 @@ type Props = { class RepositoryGroupEntry extends React.Component { render() { const { group } = this.props; + const settingsLink = group.namespace?._links?.permissions && ( + + + + ); + const namespaceHeader = ( + <> + + {group.name} + {" "} + {settingsLink} + + ); const entries = group.repositories.map((repository, index) => { return ; }); - return ; + return ; } } diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index 2c76c48e45..fc9e539ab2 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -23,20 +23,21 @@ */ import React from "react"; -import { Repository } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository } from "@scm-manager/ui-types"; import groupByNamespace from "./groupByNamespace"; import RepositoryGroupEntry from "./RepositoryGroupEntry"; type Props = { repositories: Repository[]; + namespaces: NamespaceCollection; }; class RepositoryList extends React.Component { render() { - const { repositories } = this.props; + const { repositories, namespaces } = this.props; - const groups = groupByNamespace(repositories); + const groups = groupByNamespace(repositories, namespaces); return (
{groups.map(group => { 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/components/list/groupByNamespace.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts index 6e4cd90177..7887690b37 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts @@ -22,17 +22,22 @@ * SOFTWARE. */ -import { Repository, RepositoryGroup } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository, RepositoryGroup } from "@scm-manager/ui-types"; -export default function groupByNamespace(repositories: Repository[]): RepositoryGroup[] { +export default function groupByNamespace( + repositories: Repository[], + namespaces: NamespaceCollection +): RepositoryGroup[] { const groups = {}; for (const repository of repositories) { const groupName = repository.namespace; let group = groups[groupName]; if (!group) { + const namespace = findNamespace(namespaces, groupName); group = { name: groupName, + namespace: namespace, repositories: [] }; groups[groupName] = group; @@ -58,3 +63,7 @@ function sortByName(a, b) { } return 0; } + +function findNamespace(namespaces: NamespaceCollection, namespaceToFind: string) { + return namespaces._embedded.namespaces.filter(namespace => namespace.namespace === namespaceToFind)[0]; +} diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 28343e57fe..0dae2f03d9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -133,12 +133,12 @@ class Overview extends React.Component { } renderRepositoryList() { - const { collection, page, location, t } = this.props; + const { collection, page, location, namespaces, t } = this.props; if (collection._embedded && collection._embedded.repositories.length > 0) { return ( <> - + ); From 7247641e33833a72eeee4e965de09ef99f95ac70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 15:31:47 +0200 Subject: [PATCH 13/40] Invalidate authorization cache when namespace permissions are changed --- .../sonia/scm/repository/NamespaceEvent.java | 47 ++++++++++++++++ .../NamespaceModificationEvent.java | 51 ++++++++++++++++++ .../repository/DefaultNamespaceManager.java | 16 +++++- .../AuthorizationChangedEventProducer.java | 43 ++++++++++++--- .../DefaultNamespaceManagerTest.java | 11 +++- ...AuthorizationChangedEventProducerTest.java | 53 ++++++++++++++++++- 6 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java 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-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java index e0d6ec9117..aa3abd4211 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.EventBus; +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 EventBus eventBus; @Inject - public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao) { + public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao, EventBus eventBus) { this.repositoryManager = repositoryManager; this.dao = dao; + this.eventBus = eventBus; } @Override @@ -64,10 +70,14 @@ public class DefaultNamespaceManager implements NamespaceManager { @Override public void modify(Namespace namespace) { + Namespace oldNamespace = get(namespace.getNamespace()) + .orElseThrow(() -> notFound(entity(Namespace.class, namespace.getNamespace()))); + fireEvent(HandlerEventType.BEFORE_MODIFY, namespace, oldNamespace); if (!get(namespace.getNamespace()).isPresent()) { throw notFound(entity("Namespace", namespace.getNamespace())); } dao.add(namespace); + fireEvent(HandlerEventType.MODIFY, namespace, oldNamespace); } private Namespace createNamespaceForName(String namespace) { @@ -75,4 +85,8 @@ public class DefaultNamespaceManager implements NamespaceManager { .map(Namespace::clone) .orElse(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/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index a198dafb24..2b3b1b0fcc 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,47 @@ public class AuthorizationChangedEventProducer { } } + @Subscribe + public void onEvent(NamespaceEvent event) { + if (event.getEventType().isPost()) { + if (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/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java index 736e1a90d1..81c0b22cdb 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,13 @@ 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.store.InMemoryDataStore; import sonia.scm.store.InMemoryDataStoreFactory; @@ -37,6 +39,9 @@ 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.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,6 +50,8 @@ class DefaultNamespaceManagerTest { @Mock RepositoryManager repositoryManager; + @Mock + EventBus eventBus; Namespace life; @@ -56,7 +63,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 +122,7 @@ 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)); } } 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; From e4d18bc8374addae2cbe919052eecc13305fc8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 16:02:42 +0200 Subject: [PATCH 14/40] Cleanup permissions for namespace when it is removed --- .../repository/DefaultNamespaceManager.java | 22 +++++++++++++++- .../sonia/scm/repository/NamespaceDao.java | 4 +++ .../DefaultNamespaceManagerTest.java | 25 ++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) 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 aa3abd4211..6bf1306002 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java @@ -25,8 +25,8 @@ package sonia.scm.repository; import com.github.legman.EventBus; +import com.github.legman.Subscribe; import sonia.scm.HandlerEventType; -import sonia.scm.event.ScmEventBus; import javax.inject.Inject; import java.util.Collection; @@ -80,6 +80,26 @@ public class DefaultNamespaceManager implements NamespaceManager { 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) 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/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java index 81c0b22cdb..6bc6381278 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -40,9 +40,10 @@ 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.times; 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) @@ -125,4 +126,26 @@ class DefaultNamespaceManagerTest { 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(); + } } From f80040cf342751b2db928239e80b768deea04da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 17 Sep 2020 16:24:20 +0200 Subject: [PATCH 15/40] User documentation for namespace permissions --- .../user/repo/assets/repository-overview.png | Bin 203967 -> 191803 bytes docs/de/user/repo/index.md | 2 ++ docs/de/user/repo/settings.md | 4 +++- .../user/repo/assets/repository-overview.png | Bin 203967 -> 191803 bytes docs/en/user/repo/index.md | 2 ++ docs/en/user/repo/settings.md | 4 +++- scm-ui/ui-webapp/public/locales/de/repos.json | 3 ++- scm-ui/ui-webapp/public/locales/en/repos.json | 3 ++- .../src/repos/containers/DangerZone.tsx | 2 +- .../src/repos/containers/RenameRepository.tsx | 4 +++- 10 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/de/user/repo/assets/repository-overview.png b/docs/de/user/repo/assets/repository-overview.png index 452af113c7f2b43dffa0d24ac84b384390436af3..59cf55e4d2f95350df493802ff065e7535869788 100644 GIT binary patch literal 191803 zcmV)0K+eC3P)ZgXgFbngSdJ^%m!Ds)9ybVG7w zVRUJ4ZXi@?ZDjyNZy+%@AX8;--RD$gW>sb7SJkQG@Q44iM+D~p@8$0tgiB=^H1!fmVp&65 zT4nhCTJa_>^vUtQWw`7&akcPOc(;zBAO@y-79MK&G(C{YRPb5mYOTGkGDZ0XI@h7> zml|y!uTh;3U^M{48dx0xZUtZfFaksDFGuDq={J!-FI^O5MZ1B9-ebI@Ar~2)4n0Tp zlseAFx!|QqSJI;Z;1pYHv4btX5^XMbE&svE-xCCxOW$|EZU<~XC3q3qtoag@ zUZlfhINnnwVVwXdanxo)e`Z{6c%dhM*WZ&Uq|-^lS~_(;Dv2lx?bJ~U;U z)vfZ@;*poP+NYY_1{%YPU6bl~%zAKD^5+~ddSH7K@H+qwa3cVMo9mPUocz6a?qq$~ zkf}7mq1bBz-cL4q^s~LVumRTVNk7mgja%UCW^N0*bwC^kgDgx8LbYw)1M3yAS=n`Q z3wRty^)vu@L5{o+a)F80R5lsD5`Tds{i*Ynig>8yGrA+-Rpm3|az3Av+>s~ocCD>O zUZlC=Yy5!B>!7o<;>IzSWI>(t_u=E;|0Ar^ubR`>)rWNOqtbPNKLf@y;0yq+Pmam3 zngYf6-L0m?UL3R(U`EtF|KBo>5N<~$&}vg}aI>V&A{ycU61le0>os<A9~w&o!6eoO6*Wd$4>ZI4`zTS2`g5UcfjV`_-yQK_RjZau(Ja9VfD*(zvM z0+;9r)o^KiXZpw2%SM_~KR4$s;Wu52I^0(RAR^bmlqeZTV0$_RKdYdl{bu@I7}{X1 zqpmOTONN=VYM29J`-#IK-qUbVzAUoF=txxxJt6Q-$3LN#oF(O6WSa(`n!Kd)f>UQn zdO_=V>gXyR(xWHD@%8Mxn}QF25!l`WFoqm}gFN_T3?kl<_Je0->3r<8-v#f&ebu&b zUPzh5I|-^G{pKhRpriQ&O>&F6>_>2nFVQ&%=DrvDijFOfYf?g=jAte;6@8H|k-FAI ztS7%Coz}QlfsUg@m+=YdH|KMg^BDPYZd#Dp-Ui@-aR3IA5UkqdS|D2SlO#w!ea#1q zDQLu*6Un@?002Pw6+@Now*H9RRymxeHQdohBBE~iLUkXfodzY70HY6H7 zvJzL1YV)H87P+S5C-GNpk6~{1B+_biDAba8>f%DBY%iF`(UTyFRN{3d9;m~YetTed z3hV&j)|B{JT?AG{UprtP$Z@V4L7RIPndiKTH#AywY~v;3Obo6lrqlQG@1BIZ5X&uJ zNKzas^F)`J>E*#niu8xszKu+V0bm5y#{hP~+3hfvWG7F85bVWZKH{4gU^r3spG6mG z$=Za**jHU$8VqQf-^!$@$`;?n#e%3bPVss}k6MB5ikXSc|FiKn;NrR}LrZ*!L-=l* zzLt_?(n5F(oR_Cy@}Cu&Rz4%L)fyOA(+)HTzz_1=PX4jQj!SmL;KxS)o%Wd)M+Eju zeL!gunYeU3*{2wDI2ZcZ&3a&11Aa3l@!XQ+*#QFPXoEKEnRl8f#{l#0Aqls%2%X)F zvMSBZg)|Qi6`6=M9czc=+kNhV%wyUIN1Gt)+~LMt{*JIG=ot^#77~^ERUipz^J2I) z>88xdM5iNpSHsr@hm>{27^TE8_l0A7_lRHb!y;I>ix{9#qCrVGkIb3I5Vp7jxtB?m z$_VUErl9d88hEl(&xOZu<8D1*cK*yslZFD@SMghu0>wl?u6*wn=;4PtowWo^wsS&q zG=u<4RQ4o65kVrW?S`oVcP!Oi20}!`6Gb)Ps~D{$QL|qm$e^3&Ue8!+eaY!a{n`xL zNiZtW+9%Om-SL8Xo=IhDFmf3PA87|t(tq)HA{^+^M+A+K`l%wjh4boV9y!j4m&&sz zV+2e*Z*Krw%sSYufK}X`XKjb#3`G8=_Qm-@6Ej&GYr3|=LnaG@AQwEq$vRA-C+ROC zz>oTp&Zi75>kCtGOsm%hM9{xgsOd|e2ZpIU4;Uz_N{!0smCc3~HuBwNK7 zcHdw(Ej~8yvzmY9Tz$Mfrd@2uoA4A5-Cam3IycMO>S(&0HW6gR1wwm3W{#r`8|LI4 z+`JZ%rb5)IK=!zEL@3SMk(H5vCb(<&Bd_y~@YxCN3$jP&ms!>|Ig9@dx**b&_)fBK zL~}2K3;unSmlb(jR*MUk_`mz`M`?;a906_2Te2X;2xGY7%5NyX($>+>v7clE-K+|L3-iIAU#cHGU^;6p-h zGbyQIgK4EI7{v?}PrFcPQ4oE}*HT3%70r|>D>t~daC0YGqtgOwpeGr`7JsoFIRRFV zZrQInXJB$A5@kQBV=eFpl!i@2D+NWYzVtouE!1g2tBgfiN_dK-0cLrx8X1MQ)b^3| z7j3(gY@tY#kzh0&O1YB)vRKX%Oe8#nMm720FtT)40Iw=0qWpX*?Eo7~Tof6N<vbL_UVxm>LVg83Jo2)XNm{ zE&(Uww9rQbw;27>eOxqT`)O&j8}s{R4doAL5^K-+Tc!yOIrYpFbXuaomZ)kh`);Xzh(>K zZZ0=1w(NlE4^0e(^YM!0VVKL!1vv$z{C2eRN3b6&Hzn^@1JR*e{-f>nbKUhZ zaJHLlnU66<%irt(sR?o1HXCHS|0Eshhup{EWvSjvJCYgg%$~+;d|?qY9ll7JA*|h_ z_LB5<#;{Vi@FyLsU#m`aW#EDL&pydJi>d7K@qzusOG>7sX*vL#qiH@!gg)!UNzIOl zi*8GSoO|i0Ju#d+%JIyVOUV2VZi7b@yQ``1uFa>SA4ppUS|xsoffJd7nNJdJJX_qM zFlzROpaqb})>c`+%Vh9#1c%vyw-ojnNWYtsdMCFgV-#1kh80Wf6&1t3=mbT<_lH;1 z$ysO!y=0&*d>mpT@KsK0`5Ks-;qpL+ClV4FPmHqCcLM728grs8?JZV-m9bZ^6JDxG zA`AHWpVj%*k|)&HDx6$WKfwVBUenV3WpK8Go2icXEAf_aY6YR^Fk z0C`&+yC>AC&Fz{FswLA}U1^q?2E87T5?d6i$#}A|y}2~`09gXO(qO8Knr*NIo(-tB zBicEGtatY_b?80X(QD_N?{GQcnVkc|USk^1VGZqBucjAPn0+kPp7n)rHXtz11-^sEu%pHT5&dS} zZa+`r19KY-U!y@c+31F>W#6;NT|r#C&k$$H3#kmpyTlI|`Xy<(J2PJK=IrGP}aSD^0b!_0%jlZRGHM04