diff --git a/scm-core/src/main/java/sonia/scm/migration/UpdateStep.java b/scm-core/src/main/java/sonia/scm/migration/UpdateStep.java
index eaa6d8d549..ed5f60a630 100644
--- a/scm-core/src/main/java/sonia/scm/migration/UpdateStep.java
+++ b/scm-core/src/main/java/sonia/scm/migration/UpdateStep.java
@@ -3,11 +3,80 @@ package sonia.scm.migration;
import sonia.scm.plugin.ExtensionPoint;
import sonia.scm.version.Version;
+/**
+ * This is the main interface for data migration/update. Using this interface, SCM-Manager provides the possibility to
+ * change data structures between versions for a given type of data.
+ *
The data type can be an arbitrary string, but it is considered a best practice to use a qualified name, for
+ * example
+ *
+ * com.example.myPlugin.configuration for data in plugins, or
+ * com.cloudogu.scm.repository for core data structures.
+ *
+ *
+ * The version is unrelated to other versions and therefore can be chosen freely, so that a data type can be updated
+ * without in various ways independent of other data types or the official version of the plugin or the core.
+ * A coordination between different data types and their versions is only necessary, when update steps of different data
+ * types rely on each other. If a update step of data type A has to run before another step for data type
+ * B , the version number of the second step has to be greater in regards to {@link Version#compareTo(Version)}.
+ *
+ * The algorithm looks something like this:
+ * Whenever the SCM-Manager starts,
+ *
+ * it creates a so called bootstrap guice context , that contains
+ *
+ * a {@link sonia.scm.security.KeyGenerator},
+ * the {@link sonia.scm.repository.RepositoryLocationResolver},
+ * the {@link sonia.scm.io.FileSystem},
+ * the {@link sonia.scm.security.CipherHandler},
+ * a {@link sonia.scm.store.ConfigurationStoreFactory},
+ * a {@link sonia.scm.store.ConfigurationEntryStoreFactory},
+ * a {@link sonia.scm.store.DataStoreFactory},
+ * a {@link sonia.scm.store.BlobStoreFactory}, and
+ * the {@link sonia.scm.plugin.PluginLoader}.
+ *
+ * Mind, that there are no DAOs, Managers or the like available at this time!
+ *
+ * It then checks whether there are instances of this interface that have not run before, that is either
+ *
+ * their version number given by {@link #getTargetVersion()} is bigger than the last recorded target version of an
+ * executed update step for the data type given by {@link #getAffectedDataType()}, or
+ *
+ * there is no version number known for the given data type.
+ *
+ *
+ * These are the relevant update steps.
+ *
+ * These relevant update steps are then sorted ascending by their target version given by
+ * {@link #getTargetVersion()}.
+ *
+ * Finally, these sorted steps are executed one after another calling {@link #doUpdate()} of each step, updating the
+ * version for the data type accordingly.
+ *
+ * If all works well, SCM-Manager then creates the runtime guice context by loading all further modules.
+ * If any of the update steps fails, the whole process is interrupted and SCM-Manager will not start up and will
+ * not record the version number of this update step.
+ *
+ *
+ *
+ */
@ExtensionPoint
public interface UpdateStep {
+ /**
+ * Implement this to update the data to the new version. If any {@link Exception} is thrown, SCM-Manager will not
+ * start up.
+ */
void doUpdate() throws Exception;
+ /**
+ * Declares the new version of the data type given by {@link #getAffectedDataType()}. A update step will only be
+ * executed, when this version is bigger than the last recorded version for its data type according to
+ * {@link Version#compareTo(Version)}
+ */
Version getTargetVersion();
+ /**
+ * Declares the data type this update step will take care of. This should be a qualified name, like
+ * com.example.myPlugin.configuration.
+ */
String getAffectedDataType();
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryRoleManager.java b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryRoleManager.java
new file mode 100644
index 0000000000..1db0065e8b
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryRoleManager.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository;
+
+import sonia.scm.HandlerEventType;
+import sonia.scm.event.ScmEventBus;
+
+/**
+ * Abstract base class for {@link RepositoryRoleManager} implementations. This class
+ * implements the listener methods of the {@link RepositoryRoleManager} interface.
+ */
+public abstract class AbstractRepositoryRoleManager implements RepositoryRoleManager {
+
+ /**
+ * Send a {@link RepositoryRoleEvent} to the {@link ScmEventBus}.
+ *
+ * @param event type of change event
+ * @param repositoryRole repositoryRole that has changed
+ * @param oldRepositoryRole old repositoryRole
+ */
+ protected void fireEvent(HandlerEventType event, RepositoryRole repositoryRole, RepositoryRole oldRepositoryRole)
+ {
+ fireEvent(new RepositoryRoleModificationEvent(event, repositoryRole, oldRepositoryRole));
+ }
+
+ /**
+ * Creates a new {@link RepositoryRoleEvent} and calls {@link #fireEvent(RepositoryRoleEvent)}.
+ *
+ * @param repositoryRole repositoryRole that has changed
+ * @param event type of change event
+ */
+ protected void fireEvent(HandlerEventType event, RepositoryRole repositoryRole)
+ {
+ fireEvent(new RepositoryRoleEvent(event, repositoryRole));
+ }
+
+ /**
+ * Send a {@link RepositoryRoleEvent} to the {@link ScmEventBus}.
+ *
+ * @param event repositoryRole event
+ * @since 1.48
+ */
+ protected void fireEvent(RepositoryRoleEvent event)
+ {
+ ScmEventBus.getInstance().post(event);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RemoveDeletedRepositoryRole.java b/scm-core/src/main/java/sonia/scm/repository/RemoveDeletedRepositoryRole.java
new file mode 100644
index 0000000000..25564dca83
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RemoveDeletedRepositoryRole.java
@@ -0,0 +1,48 @@
+package sonia.scm.repository;
+
+import com.github.legman.Subscribe;
+import sonia.scm.EagerSingleton;
+import sonia.scm.plugin.Extension;
+
+import javax.inject.Inject;
+
+import java.util.Optional;
+
+import static sonia.scm.HandlerEventType.DELETE;
+
+@EagerSingleton
+@Extension
+public class RemoveDeletedRepositoryRole {
+
+ private final RepositoryManager repositoryManager;
+
+ @Inject
+ public RemoveDeletedRepositoryRole(RepositoryManager repositoryManager) {
+ this.repositoryManager = repositoryManager;
+ }
+
+ @Subscribe
+ void handle(RepositoryRoleEvent event) {
+ if (event.getEventType() == DELETE) {
+ repositoryManager.getAll()
+ .forEach(repository -> check(repository, event.getItem()));
+ }
+ }
+
+ private void check(Repository repository, RepositoryRole role) {
+ findPermission(repository, role)
+ .ifPresent(permission -> removeFromPermissions(repository, permission));
+ }
+
+ private Optional findPermission(Repository repository, RepositoryRole item) {
+ return repository.getPermissions()
+ .stream()
+ .filter(repositoryPermission -> item.getName().equals(repositoryPermission.getRole()))
+ .findFirst();
+ }
+
+ private void removeFromPermissions(Repository repository, RepositoryPermission permission) {
+ repository.removePermission(permission);
+ repositoryManager.modify(repository);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java
index 79236ab679..3099c1f74b 100644
--- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java
@@ -50,6 +50,7 @@ import java.util.LinkedHashSet;
import java.util.Set;
import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
import static java.util.Collections.unmodifiableSet;
//~--- JDK imports ------------------------------------------------------------
@@ -73,6 +74,7 @@ public class RepositoryPermission implements PermissionObject, Serializable
private String name;
@XmlElement(name = "verb")
private Set verbs;
+ private String role;
/**
* This constructor exists for mapstruct and JAXB, only -- do not use this in "normal" code .
@@ -87,6 +89,15 @@ public class RepositoryPermission implements PermissionObject, Serializable
{
this.name = name;
this.verbs = new LinkedHashSet<>(verbs);
+ this.role = null;
+ this.groupPermission = groupPermission;
+ }
+
+ public RepositoryPermission(String name, String role, boolean groupPermission)
+ {
+ this.name = name;
+ this.verbs = emptySet();
+ this.role = role;
this.groupPermission = groupPermission;
}
@@ -116,8 +127,9 @@ public class RepositoryPermission implements PermissionObject, Serializable
final RepositoryPermission other = (RepositoryPermission) obj;
return Objects.equal(name, other.name)
- && verbs.containsAll(other.verbs)
&& verbs.size() == other.verbs.size()
+ && verbs.containsAll(other.verbs)
+ && Objects.equal(role, other.role)
&& Objects.equal(groupPermission, other.groupPermission);
}
@@ -132,7 +144,7 @@ public class RepositoryPermission implements PermissionObject, Serializable
{
// Normally we do not have a log of repository permissions having the same size of verbs, but different content.
// Therefore we do not use the verbs themselves for the hash code but only the number of verbs.
- return Objects.hashCode(name, verbs == null? -1: verbs.size(), groupPermission);
+ return Objects.hashCode(name, verbs == null? -1: verbs.size(), role, groupPermission);
}
@@ -142,6 +154,7 @@ public class RepositoryPermission implements PermissionObject, Serializable
//J-
return MoreObjects.toStringHelper(this)
.add("name", name)
+ .add("role", role)
.add("verbs", verbs)
.add("groupPermission", groupPermission)
.toString();
@@ -173,6 +186,16 @@ public class RepositoryPermission implements PermissionObject, Serializable
return verbs == null ? emptyList() : Collections.unmodifiableSet(verbs);
}
+ /**
+ * Returns the role of the permission.
+ *
+ *
+ * @return role of the permission
+ */
+ public String getRole() {
+ return role;
+ }
+
/**
* Returns true if the permission is a permission which affects a group.
*
@@ -192,7 +215,8 @@ public class RepositoryPermission implements PermissionObject, Serializable
* @throws IllegalStateException when modified after the value has been set once.
*
* @deprecated Do not use this for "normal" code.
- * Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
+ * Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)}
+ * or {@link RepositoryPermission#RepositoryPermission(String, String, boolean)} instead.
*/
@Deprecated
public void setGroupPermission(boolean groupPermission)
@@ -208,7 +232,8 @@ public class RepositoryPermission implements PermissionObject, Serializable
* @throws IllegalStateException when modified after the value has been set once.
*
* @deprecated Do not use this for "normal" code.
- * Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
+ * Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)}
+ * or {@link RepositoryPermission#RepositoryPermission(String, String, boolean)} instead.
*/
@Deprecated
public void setName(String name)
@@ -219,6 +244,22 @@ public class RepositoryPermission implements PermissionObject, Serializable
this.name = name;
}
+ /**
+ * Use this for creation only. This will throw an {@link IllegalStateException} when modified.
+ * @throws IllegalStateException when modified after the value has been set once.
+ *
+ * @deprecated Do not use this for "normal" code.
+ * Use {@link RepositoryPermission#RepositoryPermission(String, String, boolean)} instead.
+ */
+ @Deprecated
+ public void setRole(String role)
+ {
+ if (this.role != null) {
+ throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
+ }
+ this.role = role;
+ }
+
/**
* Use this for creation only. This will throw an {@link IllegalStateException} when modified.
* @throws IllegalStateException when modified after the value has been set once.
@@ -232,6 +273,6 @@ public class RepositoryPermission implements PermissionObject, Serializable
if (this.verbs != null) {
throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
}
- this.verbs = unmodifiableSet(new LinkedHashSet<>(verbs));
+ this.verbs = verbs == null? emptySet(): unmodifiableSet(new LinkedHashSet<>(verbs));
}
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryRole.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryRole.java
new file mode 100644
index 0000000000..1f8f638cb8
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryRole.java
@@ -0,0 +1,230 @@
+/*
+ Copyright (c) 2010, Sebastian Sdorra
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ 3. Neither the name of SCM-Manager; nor the names of its
+ contributors may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ http://bitbucket.org/sdorra/scm-manager
+
+ */
+
+
+
+package sonia.scm.repository;
+
+import com.github.sdorra.ssp.PermissionObject;
+import com.github.sdorra.ssp.StaticPermissions;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import sonia.scm.ModelObject;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
+import static java.util.Collections.unmodifiableSet;
+
+/**
+ * Custom role with specific permissions related to {@link Repository}.
+ * This object should be immutable, but could not be due to mapstruct.
+ */
+@StaticPermissions(value = "repositoryRole", permissions = {}, globalPermissions = {"read", "modify"})
+@XmlRootElement(name = "roles")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class RepositoryRole implements ModelObject, PermissionObject {
+
+ private static final long serialVersionUID = -723588336073192740L;
+
+ private static final String REPOSITORY_MODIFIED_EXCEPTION_TEXT = "roles must not be modified";
+
+ private String name;
+ @XmlElement(name = "verb")
+ private Set verbs;
+
+ private Long creationDate;
+ private Long lastModified;
+ private String type;
+
+ /**
+ * This constructor exists for mapstruct and JAXB, only -- do not use this in "normal" code .
+ *
+ * @deprecated Do not use this for "normal" code.
+ * Use {@link RepositoryRole#RepositoryRole(String, Collection, String)} instead.
+ */
+ @Deprecated
+ public RepositoryRole() {}
+
+ public RepositoryRole(String name, Collection verbs, String type) {
+ this.name = name;
+ this.verbs = new LinkedHashSet<>(verbs);
+ this.type = type;
+ }
+
+ /**
+ * Returns true if the {@link RepositoryRole} is the same as the obj argument.
+ *
+ *
+ * @param obj the reference object with which to compare
+ *
+ * @return true if the {@link RepositoryRole} is the same as the obj argument
+ */
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+
+ final RepositoryRole other = (RepositoryRole) obj;
+
+ return Objects.equal(name, other.name)
+ && verbs.size() == other.verbs.size()
+ && verbs.containsAll(other.verbs);
+ }
+
+ /**
+ * Returns the hash code value for the {@link RepositoryRole}.
+ *
+ *
+ * @return the hash code value for the {@link RepositoryRole}
+ */
+ @Override
+ public int hashCode()
+ {
+ // Normally we do not have a log of repository permissions having the same size of verbs, but different content.
+ // Therefore we do not use the verbs themselves for the hash code but only the number of verbs.
+ return Objects.hashCode(name, verbs == null? -1: verbs.size());
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("name", name)
+ .add("verbs", verbs)
+ .toString();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Returns the verb of the role.
+ */
+ public Collection getVerbs() {
+ return verbs == null ? emptyList() : Collections.unmodifiableSet(verbs);
+ }
+
+ @Override
+ public String getId() {
+ return name;
+ }
+
+ @Override
+ public void setLastModified(Long timestamp) {
+ this.lastModified = timestamp;
+ }
+
+ @Override
+ public Long getCreationDate() {
+ return creationDate;
+ }
+
+ @Override
+ public void setCreationDate(Long timestamp) {
+ this.creationDate = timestamp;
+ }
+
+ @Override
+ public Long getLastModified() {
+ return lastModified;
+ }
+
+ @Override
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ if (this.type != null) {
+ throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
+ }
+ this.type = type;
+ }
+
+ @Override
+ public boolean isValid() {
+ return !Strings.isNullOrEmpty(name) && !verbs.isEmpty();
+ }
+
+ /**
+ * Use this for creation only. This will throw an {@link IllegalStateException} when modified.
+ * @throws IllegalStateException when modified after the value has been set once.
+ *
+ * @deprecated Do not use this for "normal" code.
+ * Use {@link RepositoryRole#RepositoryRole(String, Collection, String)} instead.
+ */
+ @Deprecated
+ public void setName(String name) {
+ if (this.name != null) {
+ throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
+ }
+ this.name = name;
+ }
+
+ /**
+ * Use this for creation only. This will throw an {@link IllegalStateException} when modified.
+ * @throws IllegalStateException when modified after the value has been set once.
+ *
+ * @deprecated Do not use this for "normal" code.
+ * Use {@link RepositoryRole#RepositoryRole(String, Collection, String)} instead.
+ */
+ @Deprecated
+ public void setVerbs(Collection verbs) {
+ if (this.verbs != null) {
+ throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
+ }
+ this.verbs = verbs == null? emptySet(): unmodifiableSet(new LinkedHashSet<>(verbs));
+ }
+
+ @Override
+ public RepositoryRole clone() {
+ try {
+ return (RepositoryRole) super.clone();
+ } catch (CloneNotSupportedException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleDAO.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleDAO.java
new file mode 100644
index 0000000000..3d7e53b3a2
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleDAO.java
@@ -0,0 +1,10 @@
+package sonia.scm.repository;
+
+import sonia.scm.GenericDAO;
+
+import java.util.List;
+
+public interface RepositoryRoleDAO extends GenericDAO {
+ @Override
+ List getAll();
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleEvent.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleEvent.java
new file mode 100644
index 0000000000..fcd21bfbcd
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleEvent.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository;
+
+import sonia.scm.HandlerEventType;
+import sonia.scm.event.AbstractHandlerEvent;
+import sonia.scm.event.Event;
+
+/**
+ * The RepositoryRoleEvent is fired if a repository role object changes.
+ * @since 2.0
+ */
+@Event
+public class RepositoryRoleEvent extends AbstractHandlerEvent {
+
+ /**
+ * Constructs a new repositoryRole event.
+ *
+ *
+ * @param eventType event type
+ * @param repositoryRole changed repositoryRole
+ */
+ public RepositoryRoleEvent(HandlerEventType eventType, RepositoryRole repositoryRole) {
+ super(eventType, repositoryRole);
+ }
+
+ /**
+ * Constructs a new repositoryRole event.
+ *
+ *
+ * @param eventType type of the event
+ * @param repositoryRole changed repositoryRole
+ * @param oldRepositoryRole old repositoryRole
+ */
+ public RepositoryRoleEvent(HandlerEventType eventType, RepositoryRole repositoryRole, RepositoryRole oldRepositoryRole) {
+ super(eventType, repositoryRole, oldRepositoryRole);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleManager.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleManager.java
new file mode 100644
index 0000000000..c7e1971110
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleManager.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository;
+
+import sonia.scm.Manager;
+import sonia.scm.search.Searchable;
+
+/**
+ * The central class for managing {@link RepositoryRole} objects.
+ * This class is a singleton and is available via injection.
+ */
+public interface RepositoryRoleManager extends Manager {
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleModificationEvent.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleModificationEvent.java
new file mode 100644
index 0000000000..eabeb26b2e
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryRoleModificationEvent.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2014, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+package sonia.scm.repository;
+
+import sonia.scm.HandlerEventType;
+import sonia.scm.ModificationHandlerEvent;
+import sonia.scm.event.Event;
+
+/**
+ * Event which is fired whenever a repository role is modified.
+ *
+ * @since 2.0
+ */
+@Event
+public class RepositoryRoleModificationEvent extends RepositoryRoleEvent implements ModificationHandlerEvent
+{
+
+ private final RepositoryRole itemBeforeModification;
+
+ /**
+ * Constructs a new {@link RepositoryRoleModificationEvent}.
+ *
+ * @param eventType type of event
+ * @param item changed repository role
+ * @param itemBeforeModification changed repository role before it was modified
+ */
+ public RepositoryRoleModificationEvent(HandlerEventType eventType, RepositoryRole item, RepositoryRole itemBeforeModification)
+ {
+ super(eventType, item);
+ this.itemBeforeModification = itemBeforeModification;
+ }
+
+ @Override
+ public RepositoryRole getItemBeforeModification()
+ {
+ return itemBeforeModification;
+ }
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
index 4dcb2f1d96..c9696b0641 100644
--- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
+++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
@@ -34,7 +34,7 @@ public class VndMediaType {
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
public static final String BRANCH_COLLECTION = PREFIX + "branchCollection" + SUFFIX;
public static final String CONFIG = PREFIX + "config" + SUFFIX;
- public static final String REPOSITORY_PERMISSION_COLLECTION = PREFIX + "repositoryPermissionCollection" + SUFFIX;
+ public static final String REPOSITORY_VERB_COLLECTION = PREFIX + "repositoryVerbCollection" + SUFFIX;
public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX;
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX;
@@ -53,6 +53,9 @@ public class VndMediaType {
public static final String SOURCE = PREFIX + "source" + SUFFIX;
public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX;
+ public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX;
+ public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX;
+
private VndMediaType() {
}
diff --git a/scm-core/src/test/java/sonia/scm/repository/RemoveDeletedRepositoryRoleTest.java b/scm-core/src/test/java/sonia/scm/repository/RemoveDeletedRepositoryRoleTest.java
new file mode 100644
index 0000000000..f2f88f9ea7
--- /dev/null
+++ b/scm-core/src/test/java/sonia/scm/repository/RemoveDeletedRepositoryRoleTest.java
@@ -0,0 +1,91 @@
+package sonia.scm.repository;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import sonia.scm.HandlerEventType;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.quality.Strictness.LENIENT;
+import static sonia.scm.HandlerEventType.DELETE;
+
+@ExtendWith(MockitoExtension.class)
+class RemoveDeletedRepositoryRoleTest {
+
+ static final Repository REPOSITORY = createRepositoryWithRoles("with", "deleted", "kept");
+
+ @Mock
+ RepositoryManager manager;
+ @Captor
+ ArgumentCaptor modifyCaptor;
+
+ private RemoveDeletedRepositoryRole removeDeletedRepositoryRole;
+
+ @BeforeEach
+ void init() {
+ removeDeletedRepositoryRole = new RemoveDeletedRepositoryRole(manager);
+ doNothing().when(manager).modify(modifyCaptor.capture());
+ }
+
+ @Test
+ void shouldRemoveDeletedPermission() {
+ when(manager.getAll()).thenReturn(Collections.singletonList(REPOSITORY));
+
+ removeDeletedRepositoryRole.handle(new RepositoryRoleEvent(DELETE, createRole("deleted")));
+
+ verify(manager).modify(any());
+ Assertions.assertThat(modifyCaptor.getValue().getPermissions())
+ .containsExactly(createPermission("kept"));
+ }
+
+ @Test
+ @MockitoSettings(strictness = LENIENT)
+ void shouldDoNothingForEventsWithUnusedRole() {
+ when(manager.getAll()).thenReturn(Collections.singletonList(REPOSITORY));
+
+ removeDeletedRepositoryRole.handle(new RepositoryRoleEvent(DELETE, createRole("unused")));
+
+ verify(manager, never()).modify(any());
+ }
+
+ @Test
+ @MockitoSettings(strictness = LENIENT)
+ void shouldDoNothingForEventsOtherThanDelete() {
+ when(manager.getAll()).thenReturn(Collections.singletonList(REPOSITORY));
+
+ Arrays.stream(HandlerEventType.values())
+ .filter(type -> type != DELETE)
+ .forEach(
+ type -> removeDeletedRepositoryRole.handle(new RepositoryRoleEvent(type, createRole("deleted")))
+ );
+
+ verify(manager, never()).modify(any());
+ }
+
+ private RepositoryRole createRole(String name) {
+ return new RepositoryRole(name, Collections.singleton("x"), "x");
+ }
+
+ static Repository createRepositoryWithRoles(String name, String... roles) {
+ Repository repository = new Repository("x", "git", "space", name);
+ Arrays.stream(roles).forEach(role -> repository.addPermission(createPermission(role)));
+ return repository;
+ }
+
+ private static RepositoryPermission createPermission(String role) {
+ return new RepositoryPermission("user", role, false);
+ }
+}
diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDAO.java
new file mode 100644
index 0000000000..0eb860a134
--- /dev/null
+++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDAO.java
@@ -0,0 +1,42 @@
+package sonia.scm.repository.xml;
+
+import com.google.inject.Inject;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleDAO;
+import sonia.scm.store.ConfigurationStoreFactory;
+import sonia.scm.xml.AbstractXmlDAO;
+
+import javax.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class XmlRepositoryRoleDAO extends AbstractXmlDAO
+ implements RepositoryRoleDAO {
+
+ public static final String STORE_NAME = "repositoryRoles";
+
+ @Inject
+ public XmlRepositoryRoleDAO(ConfigurationStoreFactory storeFactory) {
+ super(storeFactory
+ .withType(XmlRepositoryRoleDatabase.class)
+ .withName(STORE_NAME)
+ .build());
+ }
+
+ @Override
+ protected RepositoryRole clone(RepositoryRole role)
+ {
+ return role.clone();
+ }
+
+ @Override
+ protected XmlRepositoryRoleDatabase createNewDatabase()
+ {
+ return new XmlRepositoryRoleDatabase();
+ }
+
+ @Override
+ public List getAll() {
+ return (List) super.getAll();
+ }
+}
diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java
new file mode 100644
index 0000000000..c7665316e2
--- /dev/null
+++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java
@@ -0,0 +1,77 @@
+package sonia.scm.repository.xml;
+
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.xml.XmlDatabase;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@XmlRootElement(name = "user-db")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class XmlRepositoryRoleDatabase implements XmlDatabase {
+
+ private Long creationTime;
+ private Long lastModified;
+
+ @XmlJavaTypeAdapter(XmlRepositoryRoleMapAdapter.class)
+ @XmlElement(name = "roles")
+ private Map roleMap = new LinkedHashMap<>();
+
+ public XmlRepositoryRoleDatabase() {
+ long c = System.currentTimeMillis();
+
+ creationTime = c;
+ lastModified = c;
+ }
+
+ @Override
+ public void add(RepositoryRole role) {
+ roleMap.put(role.getName(), role);
+ }
+
+ @Override
+ public boolean contains(String name) {
+ return roleMap.containsKey(name);
+ }
+
+ @Override
+ public RepositoryRole remove(String name) {
+ return roleMap.remove(name);
+ }
+
+ @Override
+ public Collection values() {
+ return roleMap.values();
+ }
+
+ @Override
+ public RepositoryRole get(String name) {
+ return roleMap.get(name);
+ }
+
+ @Override
+ public long getCreationTime() {
+ return creationTime;
+ }
+
+ @Override
+ public long getLastModified() {
+ return lastModified;
+ }
+
+ @Override
+ public void setCreationTime(long creationTime) {
+ this.creationTime = creationTime;
+ }
+
+ @Override
+ public void setLastModified(long lastModified) {
+ this.lastModified = lastModified;
+ }
+}
diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleList.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleList.java
new file mode 100644
index 0000000000..7910d4300a
--- /dev/null
+++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleList.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository.xml;
+
+import sonia.scm.repository.RepositoryRole;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.Map;
+
+@XmlRootElement(name = "roles")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class XmlRepositoryRoleList implements Iterable {
+
+ public XmlRepositoryRoleList() {}
+
+ public XmlRepositoryRoleList(Map roleMap) {
+ this.roles = new LinkedList(roleMap.values());
+ }
+
+ @Override
+ public Iterator iterator()
+ {
+ return roles.iterator();
+ }
+
+ public LinkedList getRoles()
+ {
+ return roles;
+ }
+
+ public void setRoles(LinkedList roles)
+ {
+ this.roles = roles;
+ }
+
+ @XmlElement(name = "role")
+ private LinkedList roles;
+}
diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleMapAdapter.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleMapAdapter.java
new file mode 100644
index 0000000000..959eff331a
--- /dev/null
+++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleMapAdapter.java
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository.xml;
+
+import sonia.scm.repository.RepositoryRole;
+
+import javax.xml.bind.annotation.adapters.XmlAdapter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class XmlRepositoryRoleMapAdapter
+ extends XmlAdapter> {
+
+ @Override
+ public XmlRepositoryRoleList marshal(Map roleMap) {
+ return new XmlRepositoryRoleList(roleMap);
+ }
+
+ @Override
+ public Map unmarshal(XmlRepositoryRoleList roles) {
+ Map roleMap = new LinkedHashMap<>();
+
+ for (RepositoryRole role : roles) {
+ roleMap.put(role.getName(), role);
+ }
+
+ return roleMap;
+ }
+}
diff --git a/scm-it/src/test/java/sonia/scm/it/RoleITCase.java b/scm-it/src/test/java/sonia/scm/it/RoleITCase.java
new file mode 100644
index 0000000000..331a251d23
--- /dev/null
+++ b/scm-it/src/test/java/sonia/scm/it/RoleITCase.java
@@ -0,0 +1,77 @@
+package sonia.scm.it;
+
+import org.apache.http.HttpStatus;
+import org.junit.Before;
+import org.junit.Test;
+import sonia.scm.it.utils.ScmRequests;
+import sonia.scm.it.utils.TestData;
+import sonia.scm.web.VndMediaType;
+
+import static org.junit.Assert.assertNotNull;
+import static sonia.scm.it.PermissionsITCase.USER_PASS;
+import static sonia.scm.it.utils.RestUtil.ADMIN_PASSWORD;
+import static sonia.scm.it.utils.RestUtil.ADMIN_USERNAME;
+import static sonia.scm.it.utils.RestUtil.given;
+import static sonia.scm.it.utils.TestData.USER_SCM_ADMIN;
+import static sonia.scm.it.utils.TestData.callRepository;
+
+public class RoleITCase {
+
+ private static final String USER = "user";
+ public static final String ROLE_NAME = "permission-role";
+
+ @Before
+ public void init() {
+ TestData.createDefault();
+ TestData.createNotAdminUser(USER, USER_PASS);
+ }
+
+ @Test
+ public void userShouldSeePermissionsAfterAddingRoleToUser() {
+ callRepository(USER, USER_PASS, "git", HttpStatus.SC_FORBIDDEN);
+
+ String repositoryRolesUrl = new ScmRequests()
+ .requestIndexResource(ADMIN_USERNAME, ADMIN_PASSWORD)
+ .getUrl("repositoryRoles");
+
+ given()
+ .when()
+ .delete(repositoryRolesUrl + ROLE_NAME)
+ .then()
+ .statusCode(HttpStatus.SC_NO_CONTENT);
+
+ given(VndMediaType.REPOSITORY_ROLE)
+ .when()
+ .content("{" +
+ "\"name\": \"" + ROLE_NAME + "\"," +
+ "\"verbs\": [\"read\",\"permissionRead\"]" +
+ "}")
+ .post(repositoryRolesUrl)
+ .then()
+ .statusCode(HttpStatus.SC_CREATED);
+
+ String permissionUrl = given(VndMediaType.REPOSITORY, USER_SCM_ADMIN, USER_SCM_ADMIN)
+ .when()
+ .get(TestData.getDefaultRepositoryUrl("git"))
+ .then()
+ .statusCode(HttpStatus.SC_OK)
+ .extract()
+ .body().jsonPath().getString("_links.permissions.href");
+
+ given(VndMediaType.REPOSITORY_PERMISSION)
+ .when()
+ .content("{\n" +
+ "\t\"role\": \"" + ROLE_NAME + "\",\n" +
+ "\t\"name\": \"" + USER + "\",\n" +
+ "\t\"groupPermission\": false\n" +
+ "\t\n" +
+ "}")
+ .post(permissionUrl)
+ .then()
+ .statusCode(HttpStatus.SC_CREATED);
+
+ assertNotNull(callRepository(USER, USER_PASS, "git", HttpStatus.SC_OK)
+ .extract()
+ .body().jsonPath().getString("_links.permissions.href"));
+ }
+}
diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java
index 2164617772..69b9940f70 100644
--- a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java
+++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java
@@ -201,7 +201,12 @@ public class ScmRequests {
return super.assertPropertyPathDoesNotExists(LINK_USERS);
}
-
+ public String getUrl(String linkName) {
+ return response
+ .then()
+ .extract()
+ .path("_links." + linkName + ".href");
+ }
}
public class RepositoryResponse extends ModelResponse, PREV> {
diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java
index 23228d686b..20de47ffa4 100644
--- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java
+++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java
@@ -106,14 +106,31 @@ public class TestData {
;
}
- public static void createUserPermission(String name, Collection permissionType, String repositoryType) {
+ public static void createUserPermission(String username, Collection verbs, String repositoryType) {
String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType);
- LOG.info("create permission with name {} and type: {} using the endpoint: {}", name, permissionType, defaultPermissionUrl);
+ LOG.info("create permission with name {} and verbs {} using the endpoint: {}", username, verbs, defaultPermissionUrl);
given(VndMediaType.REPOSITORY_PERMISSION)
.when()
.content("{\n" +
- "\t\"verbs\": " + permissionType.stream().collect(Collectors.joining("\",\"", "[\"", "\"]")) + ",\n" +
- "\t\"name\": \"" + name + "\",\n" +
+ "\t\"verbs\": " + verbs.stream().collect(Collectors.joining("\",\"", "[\"", "\"]")) + ",\n" +
+ "\t\"name\": \"" + username + "\",\n" +
+ "\t\"groupPermission\": false\n" +
+ "\t\n" +
+ "}")
+ .post(defaultPermissionUrl)
+ .then()
+ .statusCode(HttpStatus.SC_CREATED)
+ ;
+ }
+
+ public static void createUserPermission(String username, String roleName, String repositoryType) {
+ String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType);
+ LOG.info("create permission with name {} and role {} using the endpoint: {}", username, roleName, defaultPermissionUrl);
+ given(VndMediaType.REPOSITORY_PERMISSION)
+ .when()
+ .content("{\n" +
+ "\t\"role\": " + roleName + ",\n" +
+ "\t\"name\": \"" + username + "\",\n" +
"\t\"groupPermission\": false\n" +
"\t\n" +
"}")
diff --git a/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js
new file mode 100644
index 0000000000..eab4bab05a
--- /dev/null
+++ b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js
@@ -0,0 +1,47 @@
+// @flow
+import * as React from "react";
+import { withRouter } from "react-router-dom";
+import { withContextPath } from "./urls";
+
+/**
+ * Adds anchor links to markdown headings.
+ *
+ * @see Headings are missing anchors / ids
+ */
+
+type Props = {
+ children: React.Node,
+ level: number,
+ location: any
+};
+
+function flatten(text: string, child: any) {
+ return typeof child === "string"
+ ? text + child
+ : React.Children.toArray(child.props.children).reduce(flatten, text);
+}
+
+/**
+ * Turns heading text into a anchor id
+ *
+ * @VisibleForTesting
+ */
+export function headingToAnchorId(heading: string) {
+ return heading.toLowerCase().replace(/\W/g, "-");
+}
+
+function MarkdownHeadingRenderer(props: Props) {
+ const children = React.Children.toArray(props.children);
+ const heading = children.reduce(flatten, "");
+ const anchorId = headingToAnchorId(heading);
+ const headingElement = React.createElement("h" + props.level, {}, props.children);
+ const href = withContextPath(props.location.pathname + "#" + anchorId);
+
+ return (
+
+ {headingElement}
+
+ );
+}
+
+export default withRouter(MarkdownHeadingRenderer);
diff --git a/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js
new file mode 100644
index 0000000000..4fd8428e98
--- /dev/null
+++ b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js
@@ -0,0 +1,18 @@
+// @flow
+import React from "react";
+import { headingToAnchorId } from "./MarkdownHeadingRenderer";
+
+describe("headingToAnchorId tests", () => {
+
+ it("should lower case the text", () => {
+ expect(headingToAnchorId("Hello")).toBe("hello");
+ expect(headingToAnchorId("HeLlO")).toBe("hello");
+ expect(headingToAnchorId("HELLO")).toBe("hello");
+ });
+
+ it("should replace spaces with hyphen", () => {
+ expect(headingToAnchorId("awesome stuff")).toBe("awesome-stuff");
+ expect(headingToAnchorId("a b c d e f")).toBe("a-b-c-d-e-f");
+ });
+
+});
diff --git a/scm-ui-components/packages/ui-components/src/MarkdownView.js b/scm-ui-components/packages/ui-components/src/MarkdownView.js
index 4d2b2de92f..4620004f59 100644
--- a/scm-ui-components/packages/ui-components/src/MarkdownView.js
+++ b/scm-ui-components/packages/ui-components/src/MarkdownView.js
@@ -3,17 +3,49 @@ import React from "react";
import SyntaxHighlighter from "./SyntaxHighlighter";
import Markdown from "react-markdown/with-html";
import {binder} from "@scm-manager/ui-extensions";
+import MarkdownHeadingRenderer from "./MarkdownHeadingRenderer";
+import { withRouter } from "react-router-dom";
+
type Props = {
content: string,
renderContext?: Object,
renderers?: Object,
+ enableAnchorHeadings: boolean,
+
+ // context props
+ location: any
};
class MarkdownView extends React.Component {
+ static defaultProps = {
+ enableAnchorHeadings: false
+ };
+
+ contentRef: ?HTMLDivElement;
+
+ constructor(props: Props) {
+ super(props);
+ }
+
+ componentDidUpdate() {
+ // we have to use componentDidUpdate, because we have to wait until all
+ // children are rendered and componentDidMount is called before the
+ // markdown content was rendered.
+ const hash = this.props.location.hash;
+ if (this.contentRef && hash) {
+ // we query only child elements, to avoid strange scrolling with multiple
+ // markdown elements on one page.
+ const element = this.contentRef.querySelector(hash);
+ if (element && element.scrollIntoView) {
+ element.scrollIntoView();
+ }
+ }
+ }
+
render() {
- const {content, renderers, renderContext} = this.props;
+ const {content, renderers, renderContext, enableAnchorHeadings} = this.props;
const rendererFactory = binder.getExtension("markdown-renderer-factory");
let rendererList = renderers;
@@ -26,20 +58,26 @@ class MarkdownView extends React.Component {
rendererList = {};
}
+ if (enableAnchorHeadings) {
+ rendererList.heading = MarkdownHeadingRenderer;
+ }
+
if (!rendererList.code){
rendererList.code = SyntaxHighlighter;
}
return (
-
+ (this.contentRef = el)}>
+
+
);
}
}
-export default MarkdownView;
+export default withRouter(MarkdownView);
diff --git a/scm-ui-components/packages/ui-components/src/buttons/Button.js b/scm-ui-components/packages/ui-components/src/buttons/Button.js
index 2102bb540a..5e80db1e45 100644
--- a/scm-ui-components/packages/ui-components/src/buttons/Button.js
+++ b/scm-ui-components/packages/ui-components/src/buttons/Button.js
@@ -12,6 +12,8 @@ export type ButtonProps = {
fullWidth?: boolean,
className?: string,
children?: React.Node,
+
+ // context props
classes: any
};
diff --git a/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js
deleted file mode 100644
index ab7e8d82e4..0000000000
--- a/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// @flow
-
-export type RepositoryRole = {
- name: string,
- verbs: string[]
-};
-
-export type AvailableRepositoryPermissions = {
- availableVerbs: string[],
- availableRoles: RepositoryRole[]
-};
diff --git a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js
index ed3c925283..14a2298fbe 100644
--- a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js
+++ b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js
@@ -1,14 +1,15 @@
//@flow
import type {Links} from "./hal";
+export type PermissionCreateEntry = {
+ name: string,
+ role?: string,
+ verbs?: string[],
+ groupPermission: boolean
+}
+
export type Permission = PermissionCreateEntry & {
_links: Links
};
-export type PermissionCreateEntry = {
- name: string,
- verbs: string[],
- groupPermission: boolean
-}
-
export type PermissionCollection = Permission[];
diff --git a/scm-ui-components/packages/ui-types/src/RepositoryRole.js b/scm-ui-components/packages/ui-types/src/RepositoryRole.js
new file mode 100644
index 0000000000..195bdfe05c
--- /dev/null
+++ b/scm-ui-components/packages/ui-types/src/RepositoryRole.js
@@ -0,0 +1,13 @@
+// @flow
+
+import type {Links} from "./hal";
+
+export type RepositoryRole = {
+ name: string,
+ verbs: string[],
+ type?: string,
+ creationDate?: string,
+ lastModified?: string,
+ _links: Links
+};
+
diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js
index c57a3c6792..4024710300 100644
--- a/scm-ui-components/packages/ui-types/src/index.js
+++ b/scm-ui-components/packages/ui-types/src/index.js
@@ -25,6 +25,6 @@ export type { SubRepository, File } from "./Sources";
export type { SelectValue, AutocompleteObject } from "./Autocomplete";
-export type { AvailableRepositoryPermissions, RepositoryRole } from "./AvailableRepositoryPermissions";
+export type { RepositoryRole } from "./RepositoryRole";
export type { NamespaceStrategies } from "./NamespaceStrategies";
diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json
index b255e1c233..3fc61a9b47 100644
--- a/scm-ui/public/locales/de/config.json
+++ b/scm-ui/public/locales/de/config.json
@@ -6,6 +6,39 @@
"errorTitle": "Fehler",
"errorSubtitle": "Unbekannter Einstellungen Fehler"
},
+ "repositoryRole": {
+ "navLink": "Berechtigungsrollen",
+ "title": "Berechtigungsrollen",
+ "errorTitle": "Fehler",
+ "errorSubtitle": "Unbekannter Berechtigungsrollen Fehler",
+ "createSubtitle": "Berechtigungsrolle erstellen",
+ "editSubtitle": "Berechtigungsrolle bearbeiten",
+ "overview": {
+ "title": "Übersicht aller verfügbaren Berechtigungsrollen",
+ "noPermissionRoles": "Keine Berechtigungsrollen gefunden.",
+ "createButton": "Berechtigungsrolle erstellen"
+ },
+ "editButton": "Bearbeiten",
+ "name": "Name",
+ "type": "Typ",
+ "verbs": "Berechtigungen",
+ "system": "System",
+ "form": {
+ "name": "Name",
+ "permissions": "Berechtigungen",
+ "submit": "Speichern"
+ }
+ },
+ "deleteRole" : {
+ "button": "Löschen",
+ "subtitle": "Berechtigungsrolle löschen",
+ "confirmAlert": {
+ "title": "Berechtigungsrolle löschen",
+ "message": "Soll die Berechtigungsrolle wirklich gelöscht werden? Alle Nutzer dieser Rolle verlieren Ihre Berechtigungen.",
+ "submit": "Ja",
+ "cancel": "Nein"
+ }
+ },
"config-form": {
"submit": "Speichern",
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
diff --git a/scm-ui/public/locales/de/repos.json b/scm-ui/public/locales/de/repos.json
index f4ee071613..4ba7a725e6 100644
--- a/scm-ui/public/locales/de/repos.json
+++ b/scm-ui/public/locales/de/repos.json
@@ -119,6 +119,7 @@
"error-subtitle": "Unbekannter Fehler bei Berechtigung",
"name": "Benutzer oder Gruppe",
"role": "Rolle",
+ "custom": "CUSTOM",
"permissions": "Berechtigung",
"group-permission": "Gruppenberechtigung",
"user-permission": "Benutzerberechtigung",
diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json
index aeb62c1847..d7ffa3d229 100644
--- a/scm-ui/public/locales/en/config.json
+++ b/scm-ui/public/locales/en/config.json
@@ -6,6 +6,39 @@
"errorTitle": "Error",
"errorSubtitle": "Unknown Config Error"
},
+ "repositoryRole": {
+ "navLink": "Permission Roles",
+ "title": "Permission Roles",
+ "errorTitle": "Error",
+ "errorSubtitle": "Unknown Permission Role Error",
+ "createSubtitle": "Create Permission Role",
+ "editSubtitle": "Edit Permission Role",
+ "overview": {
+ "title": "Overview of all permission roles",
+ "noPermissionRoles": "No permission roles found.",
+ "createButton": "Create Permission Role"
+ },
+ "editButton": "Edit",
+ "name": "Name",
+ "type": "Type",
+ "verbs": "Permissions",
+ "system": "System",
+ "form": {
+ "name": "Name",
+ "permissions": "Permissions",
+ "submit": "Save"
+ }
+ },
+ "deleteRole": {
+ "button": "Delete",
+ "subtitle": "Delete Permission Role",
+ "confirmAlert": {
+ "title": "Delete Permission Role",
+ "message": "Do you really want to delete this permission role? All users who own this role will lose their permissions.",
+ "submit": "Yes",
+ "cancel": "No"
+ }
+ },
"config-form": {
"submit": "Submit",
"submit-success-notification": "Configuration changed successfully!",
diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json
index 7344547d49..9a2e83f983 100644
--- a/scm-ui/public/locales/en/repos.json
+++ b/scm-ui/public/locales/en/repos.json
@@ -122,6 +122,7 @@
"error-subtitle": "Unknown permissions error",
"name": "User or group",
"role": "Role",
+ "custom": "CUSTOM",
"permissions": "Permissions",
"group-permission": "Group Permission",
"user-permission": "User Permission",
diff --git a/scm-ui/src/config/containers/Config.js b/scm-ui/src/config/containers/Config.js
index 04de525c95..53f00ea3dc 100644
--- a/scm-ui/src/config/containers/Config.js
+++ b/scm-ui/src/config/containers/Config.js
@@ -1,16 +1,18 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
-import { Route } from "react-router";
+import { Route, Switch } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
-
-import type { Links } from "@scm-manager/ui-types";
-import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components";
-import GlobalConfig from "./GlobalConfig";
import type { History } from "history";
import { connect } from "react-redux";
import { compose } from "redux";
+import type { Links } from "@scm-manager/ui-types";
+import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components";
import { getLinks } from "../../modules/indexResource";
+import GlobalConfig from "./GlobalConfig";
+import RepositoryRoles from "../roles/containers/RepositoryRoles";
+import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
+import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
type Props = {
links: Links,
@@ -33,6 +35,12 @@ class Config extends React.Component {
return this.stripEndingSlash(this.props.match.url);
};
+ matchesRoles = (route: any) => {
+ const url = this.matchedUrl();
+ const regex = new RegExp(`${url}/role/`);
+ return route.location.pathname.match(regex);
+ };
+
render() {
const { links, t } = this.props;
@@ -46,12 +54,43 @@ class Config extends React.Component {
-
-
+
+
+ (
+
+ )}
+ />
+ }
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
@@ -60,6 +99,12 @@ class Config extends React.Component {
to={`${url}`}
label={t("config.globalConfigurationNavLink")}
/>
+
string
+};
+
+const styles = {
+ spacing: {
+ padding: "0 !important"
+ }
+};
+
+class AvailableVerbs extends React.Component {
+ render() {
+ const { role, t, classes } = this.props;
+
+ let verbs = null;
+ if (role.verbs.length > 0) {
+ verbs = (
+
+
+
+ {role.verbs.map(verb => {
+ return (
+ {t("verbs.repository." + verb + ".displayName")}
+ );
+ })}
+
+
+
+ );
+ }
+ return verbs;
+ }
+}
+
+export default compose(
+ injectSheet(styles),
+ translate("plugins")
+)(AvailableVerbs);
diff --git a/scm-ui/src/config/roles/components/PermissionRoleDetails.js b/scm-ui/src/config/roles/components/PermissionRoleDetails.js
new file mode 100644
index 0000000000..610bc98b6a
--- /dev/null
+++ b/scm-ui/src/config/roles/components/PermissionRoleDetails.js
@@ -0,0 +1,50 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import { ExtensionPoint } from "@scm-manager/ui-extensions";
+import PermissionRoleDetailsTable from "./PermissionRoleDetailsTable";
+import { Button } from "@scm-manager/ui-components";
+
+type Props = {
+ role: RepositoryRole,
+ url: string,
+
+ // context props
+ t: string => string
+};
+
+class PermissionRoleDetails extends React.Component {
+ renderEditButton() {
+ const { t, url } = this.props;
+ if (!!this.props.role._links.update) {
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const { role } = this.props;
+
+ return (
+ <>
+
+
+ {this.renderEditButton()}
+
+ >
+ );
+ }
+}
+
+export default translate("config")(PermissionRoleDetails);
diff --git a/scm-ui/src/config/roles/components/PermissionRoleDetailsTable.js b/scm-ui/src/config/roles/components/PermissionRoleDetailsTable.js
new file mode 100644
index 0000000000..28ab5e1f14
--- /dev/null
+++ b/scm-ui/src/config/roles/components/PermissionRoleDetailsTable.js
@@ -0,0 +1,38 @@
+//@flow
+import React from "react";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
+import AvailableVerbs from "./AvailableVerbs";
+
+type Props = {
+ role: RepositoryRole,
+
+ // context props
+ t: string => string
+};
+
+class PermissionRoleDetailsTable extends React.Component {
+ render() {
+ const { role, t } = this.props;
+ return (
+
+
+
+ {t("repositoryRole.name")}
+ {role.name}
+
+
+ {t("repositoryRole.type")}
+ {role.type}
+
+
+ {t("repositoryRole.verbs")}
+
+
+
+
+ );
+ }
+}
+
+export default translate("config")(PermissionRoleDetailsTable);
diff --git a/scm-ui/src/config/roles/components/PermissionRoleRow.js b/scm-ui/src/config/roles/components/PermissionRoleRow.js
new file mode 100644
index 0000000000..8a10bf93d0
--- /dev/null
+++ b/scm-ui/src/config/roles/components/PermissionRoleRow.js
@@ -0,0 +1,33 @@
+// @flow
+import React from "react";
+import { Link } from "react-router-dom";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import SystemRoleTag from "./SystemRoleTag";
+
+type Props = {
+ baseUrl: string,
+ role: RepositoryRole
+};
+
+class PermissionRoleRow extends React.Component {
+ renderLink(to: string, label: string, system?: boolean) {
+ return (
+
+ {label}
+
+ );
+ }
+
+ render() {
+ const { baseUrl, role } = this.props;
+ const singleRepoRoleUrl = baseUrl.substring(0, baseUrl.length - 1);
+ const to = `${singleRepoRoleUrl}/${encodeURIComponent(role.name)}/info`;
+ return (
+
+ {this.renderLink(to, role.name, !role._links.update)}
+
+ );
+ }
+}
+
+export default PermissionRoleRow;
diff --git a/scm-ui/src/config/roles/components/PermissionRoleTable.js b/scm-ui/src/config/roles/components/PermissionRoleTable.js
new file mode 100644
index 0000000000..13c5d775d1
--- /dev/null
+++ b/scm-ui/src/config/roles/components/PermissionRoleTable.js
@@ -0,0 +1,37 @@
+// @flow
+import React from "react";
+import { translate } from "react-i18next";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import PermissionRoleRow from "./PermissionRoleRow";
+
+type Props = {
+ baseUrl: string,
+ roles: RepositoryRole[],
+
+ // context props
+ t: string => string
+};
+
+class PermissionRoleTable extends React.Component {
+ render() {
+ const { baseUrl, roles, t } = this.props;
+ return (
+
+
+
+ {t("repositoryRole.name")}
+
+
+
+ {roles.map((role, index) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+export default translate("config")(PermissionRoleTable);
diff --git a/scm-ui/src/config/roles/components/SystemRoleTag.js b/scm-ui/src/config/roles/components/SystemRoleTag.js
new file mode 100644
index 0000000000..8aad57eddc
--- /dev/null
+++ b/scm-ui/src/config/roles/components/SystemRoleTag.js
@@ -0,0 +1,37 @@
+//@flow
+import React from "react";
+import injectSheet from "react-jss";
+import classNames from "classnames";
+import { translate } from "react-i18next";
+
+type Props = {
+ system?: boolean,
+
+ // context props
+ classes: any,
+ t: string => string
+};
+
+const styles = {
+ tag: {
+ marginLeft: "0.75rem",
+ verticalAlign: "inherit"
+ }
+};
+
+class SystemRoleTag extends React.Component {
+ render() {
+ const { system, classes, t } = this.props;
+
+ if (system) {
+ return (
+
+ {t("repositoryRole.system")}
+
+ );
+ }
+ return null;
+ }
+}
+
+export default injectSheet(styles)(translate("config")(SystemRoleTag));
diff --git a/scm-ui/src/config/roles/containers/CreateRepositoryRole.js b/scm-ui/src/config/roles/containers/CreateRepositoryRole.js
new file mode 100644
index 0000000000..55b141469f
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/CreateRepositoryRole.js
@@ -0,0 +1,88 @@
+// @flow
+import React from "react";
+import RepositoryRoleForm from "./RepositoryRoleForm";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import {ErrorNotification, Subtitle, Title} from "@scm-manager/ui-components";
+import {
+ createRole,
+ getCreateRoleFailure,
+ getFetchVerbsFailure,
+ isFetchVerbsPending
+} from "../modules/roles";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import {
+ getRepositoryRolesLink,
+ getRepositoryVerbsLink
+} from "../../../modules/indexResource";
+import type {History} from "history";
+
+type Props = {
+ repositoryRolesLink: string,
+ error?: Error,
+ history: History,
+
+ //dispatch function
+ addRole: (link: string, role: RepositoryRole, callback?: () => void) => void,
+
+ // context objects
+ t: string => string
+};
+
+class CreateRepositoryRole extends React.Component {
+ repositoryRoleCreated = (role: RepositoryRole) => {
+ const { history } = this.props;
+ history.push("/config/role/" + role.name + "/info");
+ };
+
+ createRepositoryRole = (role: RepositoryRole) => {
+ this.props.addRole(this.props.repositoryRolesLink, role, () =>
+ this.repositoryRoleCreated(role)
+ );
+ };
+
+ render() {
+ const { t, error } = this.props;
+
+ if (error) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ this.createRepositoryRole(role)}
+ />
+ >
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const loading = isFetchVerbsPending(state);
+ const error = getFetchVerbsFailure(state) || getCreateRoleFailure(state);
+ const verbsLink = getRepositoryVerbsLink(state);
+ const repositoryRolesLink = getRepositoryRolesLink(state);
+
+ return {
+ loading,
+ error,
+ verbsLink,
+ repositoryRolesLink
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ addRole: (link: string, role: RepositoryRole, callback?: () => void) => {
+ dispatch(createRole(link, role, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("config")(CreateRepositoryRole));
diff --git a/scm-ui/src/config/roles/containers/DeleteRepositoryRole.js b/scm-ui/src/config/roles/containers/DeleteRepositoryRole.js
new file mode 100644
index 0000000000..a00cc21840
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/DeleteRepositoryRole.js
@@ -0,0 +1,113 @@
+// @flow
+import React from "react";
+import { translate } from "react-i18next";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import {
+ Subtitle,
+ DeleteButton,
+ confirmAlert,
+ ErrorNotification
+} from "@scm-manager/ui-components";
+import { connect } from "react-redux";
+import { withRouter } from "react-router-dom";
+import type { History } from "history";
+import {
+ deleteRole,
+ getDeleteRoleFailure,
+ isDeleteRolePending
+} from "../modules/roles";
+
+type Props = {
+ loading: boolean,
+ error: Error,
+ role: RepositoryRole,
+ confirmDialog?: boolean,
+ deleteRole: (role: RepositoryRole, callback?: () => void) => void,
+
+ // context props
+ history: History,
+ t: string => string
+};
+
+class DeleteRepositoryRole extends React.Component {
+ static defaultProps = {
+ confirmDialog: true
+ };
+
+ roleDeleted = () => {
+ this.props.history.push("/config/roles/");
+ };
+
+ deleteRole = () => {
+ this.props.deleteRole(this.props.role, this.roleDeleted);
+ };
+
+ confirmDelete = () => {
+ const { t } = this.props;
+ confirmAlert({
+ title: t("deleteRole.confirmAlert.title"),
+ message: t("deleteRole.confirmAlert.message"),
+ buttons: [
+ {
+ label: t("deleteRole.confirmAlert.submit"),
+ onClick: () => this.deleteRole()
+ },
+ {
+ label: t("deleteRole.confirmAlert.cancel"),
+ onClick: () => null
+ }
+ ]
+ });
+ };
+
+ isDeletable = () => {
+ return this.props.role._links.delete;
+ };
+
+ render() {
+ const { loading, error, confirmDialog, t } = this.props;
+ const action = confirmDialog ? this.confirmDelete : this.deleteRole;
+
+ if (!this.isDeletable()) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const loading = isDeleteRolePending(state, ownProps.role.name);
+ const error = getDeleteRoleFailure(state, ownProps.role.name);
+ return {
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ deleteRole: (role: RepositoryRole, callback?: () => void) => {
+ dispatch(deleteRole(role, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(withRouter(translate("config")(DeleteRepositoryRole)));
diff --git a/scm-ui/src/config/roles/containers/EditRepositoryRole.js b/scm-ui/src/config/roles/containers/EditRepositoryRole.js
new file mode 100644
index 0000000000..15e3bee6ae
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/EditRepositoryRole.js
@@ -0,0 +1,81 @@
+// @flow
+import React from "react";
+import RepositoryRoleForm from "./RepositoryRoleForm";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import {
+ getModifyRoleFailure,
+ isModifyRolePending,
+ modifyRole
+} from "../modules/roles";
+import { ErrorNotification, Subtitle } from "@scm-manager/ui-components";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import type { History } from "history";
+import DeleteRepositoryRole from "./DeleteRepositoryRole";
+
+type Props = {
+ disabled: boolean,
+ role: RepositoryRole,
+ repositoryRolesLink: string,
+ error?: Error,
+
+ // context objects
+ t: string => string,
+ history: History,
+
+ //dispatch function
+ updateRole: (role: RepositoryRole, callback?: () => void) => void
+};
+
+class EditRepositoryRole extends React.Component {
+ repositoryRoleUpdated = () => {
+ this.props.history.push("/config/roles/");
+ };
+
+ updateRepositoryRole = (role: RepositoryRole) => {
+ this.props.updateRole(role, this.repositoryRoleUpdated);
+ };
+
+ render() {
+ const { error, t } = this.props;
+
+ if (error) {
+ return ;
+ }
+
+ return (
+ <>
+
+ this.updateRepositoryRole(role)}
+ />
+
+
+ >
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const loading = isModifyRolePending(state, ownProps.role.name);
+ const error = getModifyRoleFailure(state, ownProps.role.name);
+
+ return {
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ updateRole: (role: RepositoryRole, callback?: () => void) => {
+ dispatch(modifyRole(role, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("config")(EditRepositoryRole));
diff --git a/scm-ui/src/config/roles/containers/RepositoryRoleForm.js b/scm-ui/src/config/roles/containers/RepositoryRoleForm.js
new file mode 100644
index 0000000000..9269af3838
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/RepositoryRoleForm.js
@@ -0,0 +1,172 @@
+// @flow
+import React from "react";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import { InputField, SubmitButton } from "@scm-manager/ui-components";
+import PermissionCheckbox from "../../../repos/permissions/components/PermissionCheckbox";
+import {
+ fetchAvailableVerbs,
+ getFetchVerbsFailure,
+ getVerbsFromState,
+ isFetchVerbsPending
+} from "../modules/roles";
+import {
+ getRepositoryRolesLink,
+ getRepositoryVerbsLink
+} from "../../../modules/indexResource";
+
+type Props = {
+ role?: RepositoryRole,
+ loading?: boolean,
+ availableVerbs: string[],
+ verbsLink: string,
+ submitForm: RepositoryRole => void,
+
+ // context objects
+ t: string => string,
+
+ // dispatch functions
+ fetchAvailableVerbs: (link: string) => void
+};
+
+type State = {
+ role: RepositoryRole
+};
+
+class RepositoryRoleForm extends React.Component {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ role: {
+ name: "",
+ verbs: [],
+ system: false,
+ _links: {}
+ }
+ };
+ }
+
+ componentDidMount() {
+ const { fetchAvailableVerbs, verbsLink } = this.props;
+ fetchAvailableVerbs(verbsLink);
+ if (this.props.role) {
+ this.setState({ role: this.props.role });
+ }
+ }
+
+ isFalsy(value) {
+ return !value;
+ }
+
+ isValid = () => {
+ const { role } = this.state;
+ return !(
+ this.isFalsy(role) ||
+ this.isFalsy(role.name) ||
+ this.isFalsy(role.verbs.length > 0)
+ );
+ };
+
+ handleNameChange = (name: string) => {
+ this.setState({
+ role: {
+ ...this.state.role,
+ name
+ }
+ });
+ };
+
+ handleVerbChange = (value: boolean, name: string) => {
+ const { role } = this.state;
+
+ const newVerbs = value
+ ? [...role.verbs, name]
+ : role.verbs.filter(v => v !== name);
+
+ this.setState({
+ ...this.state,
+ role: {
+ ...role,
+ verbs: newVerbs
+ }
+ });
+ };
+
+ submit = (event: Event) => {
+ event.preventDefault();
+ if (this.isValid()) {
+ this.props.submitForm(this.state.role);
+ }
+ };
+
+ render() {
+ const { loading, availableVerbs, t } = this.props;
+ const { role } = this.state;
+
+ const verbSelectBoxes = !availableVerbs
+ ? null
+ : availableVerbs.map(verb => (
+
+ ));
+
+ return (
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const loading = isFetchVerbsPending(state);
+ const error = getFetchVerbsFailure(state);
+ const verbsLink = getRepositoryVerbsLink(state);
+ const availableVerbs = getVerbsFromState(state);
+ const repositoryRolesLink = getRepositoryRolesLink(state);
+
+ return {
+ loading,
+ error,
+ verbsLink,
+ availableVerbs,
+ repositoryRolesLink
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchAvailableVerbs: (link: string) => {
+ dispatch(fetchAvailableVerbs(link));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("config")(RepositoryRoleForm));
diff --git a/scm-ui/src/config/roles/containers/RepositoryRoles.js b/scm-ui/src/config/roles/containers/RepositoryRoles.js
new file mode 100644
index 0000000000..2de1d3d74c
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/RepositoryRoles.js
@@ -0,0 +1,155 @@
+// @flow
+import React from "react";
+import { connect } from "react-redux";
+import { withRouter } from "react-router-dom";
+import { translate } from "react-i18next";
+import type { History } from "history";
+import type { RepositoryRole, PagedCollection } from "@scm-manager/ui-types";
+import {
+ Title,
+ Subtitle,
+ Loading,
+ Notification,
+ LinkPaginator,
+ urls,
+ CreateButton
+} from "@scm-manager/ui-components";
+import {
+ fetchRolesByPage,
+ getRolesFromState,
+ selectListAsCollection,
+ isPermittedToCreateRoles,
+ isFetchRolesPending,
+ getFetchRolesFailure
+} from "../modules/roles";
+import PermissionRoleTable from "../components/PermissionRoleTable";
+import { getRepositoryRolesLink } from "../../../modules/indexResource";
+
+type Props = {
+ baseUrl: string,
+ roles: RepositoryRole[],
+ loading: boolean,
+ error: Error,
+ canAddRoles: boolean,
+ list: PagedCollection,
+ page: number,
+ rolesLink: string,
+
+ // context objects
+ t: string => string,
+ history: History,
+ location: any,
+
+ // dispatch functions
+ fetchRolesByPage: (link: string, page: number) => void
+};
+
+class RepositoryRoles extends React.Component {
+ componentDidMount() {
+ const { fetchRolesByPage, rolesLink, page } = this.props;
+ fetchRolesByPage(rolesLink, page);
+ }
+
+ componentDidUpdate = (prevProps: Props) => {
+ const {
+ loading,
+ list,
+ page,
+ rolesLink,
+ location,
+ fetchRolesByPage
+ } = this.props;
+ if (list && page && !loading) {
+ const statePage: number = list.page + 1;
+ if (page !== statePage || prevProps.location.search !== location.search) {
+ fetchRolesByPage(
+ rolesLink,
+ page
+ );
+ }
+ }
+ };
+
+ render() {
+ const { t, loading } = this.props;
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {this.renderPermissionsTable()}
+ {this.renderCreateButton()}
+ >
+ );
+ }
+
+ renderPermissionsTable() {
+ const { baseUrl, roles, list, page, t } = this.props;
+ if (roles && roles.length > 0) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ return (
+
+ {t("repositoryRole.overview.noPermissionRoles")}
+
+ );
+ }
+
+ renderCreateButton() {
+ const { canAddRoles, baseUrl, t } = this.props;
+ if (canAddRoles) {
+ return (
+
+ );
+ }
+ return null;
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const { match } = ownProps;
+ const roles = getRolesFromState(state);
+ const loading = isFetchRolesPending(state);
+ const error = getFetchRolesFailure(state);
+ const page = urls.getPageFromMatch(match);
+ const canAddRoles = isPermittedToCreateRoles(state);
+ const list = selectListAsCollection(state);
+ const rolesLink = getRepositoryRolesLink(state);
+
+ return {
+ roles,
+ loading,
+ error,
+ canAddRoles,
+ list,
+ page,
+ rolesLink
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchRolesByPage: (link: string, page: number) => {
+ dispatch(fetchRolesByPage(link, page));
+ }
+ };
+};
+
+export default withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(translate("config")(RepositoryRoles))
+);
diff --git a/scm-ui/src/config/roles/containers/SingleRepositoryRole.js b/scm-ui/src/config/roles/containers/SingleRepositoryRole.js
new file mode 100644
index 0000000000..4aec3b0b06
--- /dev/null
+++ b/scm-ui/src/config/roles/containers/SingleRepositoryRole.js
@@ -0,0 +1,133 @@
+//@flow
+import React from "react";
+import { connect } from "react-redux";
+import { Loading, ErrorPage, Title } from "@scm-manager/ui-components";
+import { Route } from "react-router";
+import type { History } from "history";
+import { translate } from "react-i18next";
+import type { RepositoryRole } from "@scm-manager/ui-types";
+import { getRepositoryRolesLink } from "../../../modules/indexResource";
+import { ExtensionPoint } from "@scm-manager/ui-extensions";
+import {
+ fetchRoleByName,
+ getFetchRoleFailure,
+ getRoleByName,
+ isFetchRolePending
+} from "../modules/roles";
+import { withRouter } from "react-router-dom";
+import PermissionRoleDetail from "../components/PermissionRoleDetails";
+import EditRepositoryRole from "./EditRepositoryRole";
+
+type Props = {
+ roleName: string,
+ role: RepositoryRole,
+ loading: boolean,
+ error: Error,
+ repositoryRolesLink: string,
+ disabled: boolean,
+
+ // dispatcher function
+ fetchRoleByName: (string, string) => void,
+
+ // context objects
+ t: string => string,
+ match: any,
+ history: History
+};
+
+class SingleRepositoryRole extends React.Component {
+ componentDidMount() {
+ this.props.fetchRoleByName(
+ this.props.repositoryRolesLink,
+ this.props.roleName
+ );
+ }
+
+ stripEndingSlash = (url: string) => {
+ if (url.endsWith("/")) {
+ return url.substring(0, url.length - 2);
+ }
+ return url;
+ };
+
+ matchedUrl = () => {
+ return this.stripEndingSlash(this.props.match.url);
+ };
+
+ render() {
+ const { t, loading, error, role } = this.props;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!role || loading) {
+ return ;
+ }
+
+ const url = this.matchedUrl();
+
+ const extensionProps = {
+ role,
+ url
+ };
+
+ return (
+ <>
+
+ }
+ />
+ (
+
+ )}
+ />
+
+ >
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const roleName = ownProps.match.params.role;
+ const role = getRoleByName(state, roleName);
+ const loading = isFetchRolePending(state, roleName);
+ const error = getFetchRoleFailure(state, roleName);
+ const repositoryRolesLink = getRepositoryRolesLink(state);
+ return {
+ repositoryRolesLink,
+ roleName,
+ role,
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchRoleByName: (link: string, name: string) => {
+ dispatch(fetchRoleByName(link, name));
+ }
+ };
+};
+
+export default withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(translate("config")(SingleRepositoryRole))
+);
diff --git a/scm-ui/src/config/roles/modules/roles.js b/scm-ui/src/config/roles/modules/roles.js
new file mode 100644
index 0000000000..fffc395e9e
--- /dev/null
+++ b/scm-ui/src/config/roles/modules/roles.js
@@ -0,0 +1,542 @@
+// @flow
+import { apiClient } from "@scm-manager/ui-components";
+import { isPending } from "../../../modules/pending";
+import { getFailure } from "../../../modules/failure";
+import * as types from "../../../modules/types";
+import { combineReducers, Dispatch } from "redux";
+import type {
+ Action,
+ PagedCollection,
+ RepositoryRole
+} from "@scm-manager/ui-types";
+
+export const FETCH_ROLES = "scm/roles/FETCH_ROLES";
+export const FETCH_ROLES_PENDING = `${FETCH_ROLES}_${types.PENDING_SUFFIX}`;
+export const FETCH_ROLES_SUCCESS = `${FETCH_ROLES}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_ROLES_FAILURE = `${FETCH_ROLES}_${types.FAILURE_SUFFIX}`;
+
+export const FETCH_ROLE = "scm/roles/FETCH_ROLE";
+export const FETCH_ROLE_PENDING = `${FETCH_ROLE}_${types.PENDING_SUFFIX}`;
+export const FETCH_ROLE_SUCCESS = `${FETCH_ROLE}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_ROLE_FAILURE = `${FETCH_ROLE}_${types.FAILURE_SUFFIX}`;
+
+export const CREATE_ROLE = "scm/roles/CREATE_ROLE";
+export const CREATE_ROLE_PENDING = `${CREATE_ROLE}_${types.PENDING_SUFFIX}`;
+export const CREATE_ROLE_SUCCESS = `${CREATE_ROLE}_${types.SUCCESS_SUFFIX}`;
+export const CREATE_ROLE_FAILURE = `${CREATE_ROLE}_${types.FAILURE_SUFFIX}`;
+export const CREATE_ROLE_RESET = `${CREATE_ROLE}_${types.RESET_SUFFIX}`;
+
+export const MODIFY_ROLE = "scm/roles/MODIFY_ROLE";
+export const MODIFY_ROLE_PENDING = `${MODIFY_ROLE}_${types.PENDING_SUFFIX}`;
+export const MODIFY_ROLE_SUCCESS = `${MODIFY_ROLE}_${types.SUCCESS_SUFFIX}`;
+export const MODIFY_ROLE_FAILURE = `${MODIFY_ROLE}_${types.FAILURE_SUFFIX}`;
+export const MODIFY_ROLE_RESET = `${MODIFY_ROLE}_${types.RESET_SUFFIX}`;
+
+export const DELETE_ROLE = "scm/roles/DELETE_ROLE";
+export const DELETE_ROLE_PENDING = `${DELETE_ROLE}_${types.PENDING_SUFFIX}`;
+export const DELETE_ROLE_SUCCESS = `${DELETE_ROLE}_${types.SUCCESS_SUFFIX}`;
+export const DELETE_ROLE_FAILURE = `${DELETE_ROLE}_${types.FAILURE_SUFFIX}`;
+
+export const FETCH_VERBS = "scm/roles/FETCH_VERBS";
+export const FETCH_VERBS_PENDING = `${FETCH_VERBS}_${types.PENDING_SUFFIX}`;
+export const FETCH_VERBS_SUCCESS = `${FETCH_VERBS}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_VERBS_FAILURE = `${FETCH_VERBS}_${types.FAILURE_SUFFIX}`;
+
+const CONTENT_TYPE_ROLE = "application/vnd.scmm-repositoryRole+json;v=2";
+
+// fetch roles
+export function fetchRolesPending(): Action {
+ return {
+ type: FETCH_ROLES_PENDING
+ };
+}
+
+export function fetchRolesSuccess(roles: any): Action {
+ return {
+ type: FETCH_ROLES_SUCCESS,
+ payload: roles
+ };
+}
+
+export function fetchRolesFailure(url: string, error: Error): Action {
+ return {
+ type: FETCH_ROLES_FAILURE,
+ payload: {
+ error,
+ url
+ }
+ };
+}
+
+export function fetchRolesByLink(link: string) {
+ return function(dispatch: any) {
+ dispatch(fetchRolesPending());
+ return apiClient
+ .get(link)
+ .then(response => response.json())
+ .then(data => {
+ dispatch(fetchRolesSuccess(data));
+ })
+ .catch(error => {
+ dispatch(fetchRolesFailure(link, error));
+ });
+ };
+}
+
+export function fetchRoles(link: string) {
+ return fetchRolesByLink(link);
+}
+
+export function fetchRolesByPage(link: string, page: number) {
+ // backend start counting by 0
+ return fetchRolesByLink(`${link}?page=${page - 1}`);
+}
+
+// fetch role
+export function fetchRolePending(name: string): Action {
+ return {
+ type: FETCH_ROLE_PENDING,
+ payload: name,
+ itemId: name
+ };
+}
+
+export function fetchRoleSuccess(role: any): Action {
+ return {
+ type: FETCH_ROLE_SUCCESS,
+ payload: role,
+ itemId: role.name
+ };
+}
+
+export function fetchRoleFailure(name: string, error: Error): Action {
+ return {
+ type: FETCH_ROLE_FAILURE,
+ payload: {
+ name,
+ error
+ },
+ itemId: name
+ };
+}
+
+function fetchRole(link: string, name: string) {
+ return function(dispatch: any) {
+ dispatch(fetchRolePending(name));
+ return apiClient
+ .get(link)
+ .then(response => {
+ return response.json();
+ })
+ .then(data => {
+ dispatch(fetchRoleSuccess(data));
+ })
+ .catch(error => {
+ dispatch(fetchRoleFailure(name, error));
+ });
+ };
+}
+
+export function fetchRoleByName(link: string, name: string) {
+ const roleUrl = link.endsWith("/") ? link + name : link + "/" + name;
+ return fetchRole(roleUrl, name);
+}
+
+export function fetchRoleByLink(role: RepositoryRole) {
+ return fetchRole(role._links.self.href, role.name);
+}
+
+// create role
+export function createRolePending(role: RepositoryRole): Action {
+ return {
+ type: CREATE_ROLE_PENDING,
+ role
+ };
+}
+
+export function createRoleSuccess(): Action {
+ return {
+ type: CREATE_ROLE_SUCCESS
+ };
+}
+
+export function createRoleFailure(error: Error): Action {
+ return {
+ type: CREATE_ROLE_FAILURE,
+ payload: error
+ };
+}
+
+export function createRoleReset() {
+ return {
+ type: CREATE_ROLE_RESET
+ };
+}
+
+export function createRole(
+ link: string,
+ role: RepositoryRole,
+ callback?: () => void
+) {
+ return function(dispatch: Dispatch) {
+ dispatch(createRolePending(role));
+ return apiClient
+ .post(link, role, CONTENT_TYPE_ROLE)
+ .then(() => {
+ dispatch(createRoleSuccess());
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(error => dispatch(createRoleFailure(error)));
+ };
+}
+
+//fetch verbs
+export function fetchVerbsPending(): Action {
+ return {
+ type: FETCH_VERBS_PENDING
+ };
+}
+
+export function fetchVerbsSuccess(verbs: any): Action {
+ return {
+ type: FETCH_VERBS_SUCCESS,
+ payload: verbs
+ };
+}
+
+export function fetchVerbsFailure(error: Error): Action {
+ return {
+ type: FETCH_VERBS_FAILURE,
+ payload: error
+ };
+}
+
+export function fetchAvailableVerbs(link: string) {
+ return function(dispatch: any) {
+ dispatch(fetchVerbsPending());
+ return apiClient
+ .get(link)
+ .then(response => {
+ return response.json();
+ })
+ .then(data => {
+ dispatch(fetchVerbsSuccess(data));
+ })
+ .catch(error => {
+ dispatch(fetchVerbsFailure(error));
+ });
+ };
+}
+
+function verbReducer(state: any = {}, action: any = {}) {
+ switch (action.type) {
+ case FETCH_VERBS_SUCCESS:
+ const verbs = action.payload.verbs;
+ return { ...state, verbs };
+ default:
+ return state;
+ }
+}
+
+// modify role
+export function modifyRolePending(role: RepositoryRole): Action {
+ return {
+ type: MODIFY_ROLE_PENDING,
+ payload: role,
+ itemId: role.name
+ };
+}
+
+export function modifyRoleSuccess(role: RepositoryRole): Action {
+ return {
+ type: MODIFY_ROLE_SUCCESS,
+ payload: role,
+ itemId: role.name
+ };
+}
+
+export function modifyRoleFailure(role: RepositoryRole, error: Error): Action {
+ return {
+ type: MODIFY_ROLE_FAILURE,
+ payload: {
+ error,
+ role
+ },
+ itemId: role.name
+ };
+}
+
+export function modifyRoleReset(role: RepositoryRole): Action {
+ return {
+ type: MODIFY_ROLE_RESET,
+ itemId: role.name
+ };
+}
+
+export function modifyRole(role: RepositoryRole, callback?: () => void) {
+ return function(dispatch: Dispatch) {
+ dispatch(modifyRolePending(role));
+ return apiClient
+ .put(role._links.update.href, role, CONTENT_TYPE_ROLE)
+ .then(() => {
+ dispatch(modifyRoleSuccess(role));
+ if (callback) {
+ callback();
+ }
+ })
+ .then(() => {
+ dispatch(fetchRoleByLink(role));
+ })
+ .catch(err => {
+ dispatch(modifyRoleFailure(role, err));
+ });
+ };
+}
+
+// delete role
+export function deleteRolePending(role: RepositoryRole): Action {
+ return {
+ type: DELETE_ROLE_PENDING,
+ payload: role,
+ itemId: role.name
+ };
+}
+
+export function deleteRoleSuccess(role: RepositoryRole): Action {
+ return {
+ type: DELETE_ROLE_SUCCESS,
+ payload: role,
+ itemId: role.name
+ };
+}
+
+export function deleteRoleFailure(role: RepositoryRole, error: Error): Action {
+ return {
+ type: DELETE_ROLE_FAILURE,
+ payload: {
+ error,
+ role
+ },
+ itemId: role.name
+ };
+}
+
+export function deleteRole(role: RepositoryRole, callback?: () => void) {
+ return function(dispatch: any) {
+ dispatch(deleteRolePending(role));
+ return apiClient
+ .delete(role._links.delete.href)
+ .then(() => {
+ dispatch(deleteRoleSuccess(role));
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(error => {
+ dispatch(deleteRoleFailure(role, error));
+ });
+ };
+}
+
+function extractRolesByNames(
+ roles: RepositoryRole[],
+ roleNames: string[],
+ oldRolesByNames: Object
+) {
+ const rolesByNames = {};
+
+ for (let role of roles) {
+ rolesByNames[role.name] = role;
+ }
+
+ for (let roleName in oldRolesByNames) {
+ rolesByNames[roleName] = oldRolesByNames[roleName];
+ }
+ return rolesByNames;
+}
+
+function deleteRoleInRolesByNames(roles: {}, roleName: string) {
+ let newRoles = {};
+ for (let rolename in roles) {
+ if (rolename !== roleName) newRoles[rolename] = roles[rolename];
+ }
+ return newRoles;
+}
+
+function deleteRoleInEntries(roles: [], roleName: string) {
+ let newRoles = [];
+ for (let role of roles) {
+ if (role !== roleName) newRoles.push(role);
+ }
+ return newRoles;
+}
+
+const reducerByName = (state: any, rolename: string, newRoleState: any) => {
+ return {
+ ...state,
+ [rolename]: newRoleState
+ };
+};
+
+function listReducer(state: any = {}, action: any = {}) {
+ switch (action.type) {
+ case FETCH_ROLES_SUCCESS:
+ const roles = action.payload._embedded.repositoryRoles;
+ const roleNames = roles.map(role => role.name);
+ return {
+ ...state,
+ entries: roleNames,
+ entry: {
+ roleCreatePermission: !!action.payload._links.create,
+ page: action.payload.page,
+ pageTotal: action.payload.pageTotal,
+ _links: action.payload._links
+ }
+ };
+
+ // Delete single role actions
+ case DELETE_ROLE_SUCCESS:
+ const newRoleEntries = deleteRoleInEntries(
+ state.entries,
+ action.payload.name
+ );
+ return {
+ ...state,
+ entries: newRoleEntries
+ };
+ default:
+ return state;
+ }
+}
+
+function byNamesReducer(state: any = {}, action: any = {}) {
+ switch (action.type) {
+ // Fetch all roles actions
+ case FETCH_ROLES_SUCCESS:
+ const roles = action.payload._embedded.repositoryRoles;
+ const roleNames = roles.map(role => role.name);
+ const byNames = extractRolesByNames(roles, roleNames, state.byNames);
+ return {
+ ...byNames
+ };
+
+ // Fetch single role actions
+ case FETCH_ROLE_SUCCESS:
+ return reducerByName(state, action.payload.name, action.payload);
+
+ case DELETE_ROLE_SUCCESS:
+ return deleteRoleInRolesByNames(state, action.payload.name);
+
+ default:
+ return state;
+ }
+}
+
+export default combineReducers({
+ list: listReducer,
+ byNames: byNamesReducer,
+ verbs: verbReducer
+});
+
+// selectors
+const selectList = (state: Object) => {
+ if (state.roles && state.roles.list) {
+ return state.roles.list;
+ }
+ return {};
+};
+const selectListEntry = (state: Object): Object => {
+ const list = selectList(state);
+ if (list.entry) {
+ return list.entry;
+ }
+ return {};
+};
+
+export const selectListAsCollection = (state: Object): PagedCollection => {
+ return selectListEntry(state);
+};
+
+export const isPermittedToCreateRoles = (state: Object): boolean => {
+ return !!selectListEntry(state).roleCreatePermission;
+};
+
+export function getRolesFromState(state: Object) {
+ const roleNames = selectList(state).entries;
+ if (!roleNames) {
+ return null;
+ }
+ const roleEntries: RepositoryRole[] = [];
+
+ for (let roleName of roleNames) {
+ roleEntries.push(state.roles.byNames[roleName]);
+ }
+
+ return roleEntries;
+}
+
+export function getRoleCreateLink(state: Object) {
+ if (state && state.list && state.list._links && state.list._links.create) {
+ return state.list._links.create.href;
+ }
+}
+
+export function getVerbsFromState(state: Object) {
+ return state.roles.verbs.verbs;
+}
+
+export function isFetchRolesPending(state: Object) {
+ return isPending(state, FETCH_ROLES);
+}
+
+export function getFetchRolesFailure(state: Object) {
+ return getFailure(state, FETCH_ROLES);
+}
+
+export function isFetchVerbsPending(state: Object) {
+ return isPending(state, FETCH_VERBS);
+}
+
+export function getFetchVerbsFailure(state: Object) {
+ return getFailure(state, FETCH_VERBS);
+}
+
+export function isCreateRolePending(state: Object) {
+ return isPending(state, CREATE_ROLE);
+}
+
+export function getCreateRoleFailure(state: Object) {
+ return getFailure(state, CREATE_ROLE);
+}
+
+export function getRoleByName(state: Object, name: string) {
+ if (state.roles && state.roles.byNames) {
+ return state.roles.byNames[name];
+ }
+}
+
+export function isFetchRolePending(state: Object, name: string) {
+ return isPending(state, FETCH_ROLE, name);
+}
+
+export function getFetchRoleFailure(state: Object, name: string) {
+ return getFailure(state, FETCH_ROLE, name);
+}
+
+export function isModifyRolePending(state: Object, name: string) {
+ return isPending(state, MODIFY_ROLE, name);
+}
+
+export function getModifyRoleFailure(state: Object, name: string) {
+ return getFailure(state, MODIFY_ROLE, name);
+}
+
+export function isDeleteRolePending(state: Object, name: string) {
+ return isPending(state, DELETE_ROLE, name);
+}
+
+export function getDeleteRoleFailure(state: Object, name: string) {
+ return getFailure(state, DELETE_ROLE, name);
+}
diff --git a/scm-ui/src/config/roles/modules/roles.test.js b/scm-ui/src/config/roles/modules/roles.test.js
new file mode 100644
index 0000000000..3c09bb7db1
--- /dev/null
+++ b/scm-ui/src/config/roles/modules/roles.test.js
@@ -0,0 +1,653 @@
+// @flow
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import fetchMock from "fetch-mock";
+
+import reducer, {
+ FETCH_ROLES,
+ FETCH_ROLES_PENDING,
+ FETCH_ROLES_SUCCESS,
+ FETCH_ROLES_FAILURE,
+ FETCH_ROLE,
+ FETCH_ROLE_PENDING,
+ FETCH_ROLE_SUCCESS,
+ FETCH_ROLE_FAILURE,
+ CREATE_ROLE,
+ CREATE_ROLE_PENDING,
+ CREATE_ROLE_SUCCESS,
+ CREATE_ROLE_FAILURE,
+ MODIFY_ROLE,
+ MODIFY_ROLE_PENDING,
+ MODIFY_ROLE_SUCCESS,
+ MODIFY_ROLE_FAILURE,
+ DELETE_ROLE,
+ DELETE_ROLE_PENDING,
+ DELETE_ROLE_SUCCESS,
+ DELETE_ROLE_FAILURE,
+ fetchRoles,
+ getFetchRolesFailure,
+ getRolesFromState,
+ isFetchRolesPending,
+ fetchRolesSuccess,
+ fetchRoleByLink,
+ fetchRoleByName,
+ fetchRoleSuccess,
+ isFetchRolePending,
+ getFetchRoleFailure,
+ createRole,
+ isCreateRolePending,
+ getCreateRoleFailure,
+ getRoleByName,
+ modifyRole,
+ isModifyRolePending,
+ getModifyRoleFailure,
+ deleteRole,
+ isDeleteRolePending,
+ deleteRoleSuccess,
+ getDeleteRoleFailure,
+ selectListAsCollection,
+ isPermittedToCreateRoles
+} from "./roles";
+
+const role1 = {
+ name: "specialrole",
+ verbs: ["read", "pull", "push", "readPullRequest"],
+ system: false,
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
+ },
+ delete: {
+ href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
+ },
+ update: {
+ href: "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole"
+ }
+ }
+};
+const role2 = {
+ name: "WRITE",
+ verbs: [
+ "read",
+ "pull",
+ "push",
+ "createPullRequest",
+ "readPullRequest",
+ "commentPullRequest",
+ "mergePullRequest"
+ ],
+ system: true,
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/v2/repositoryRoles/WRITE"
+ }
+ }
+};
+
+const responseBody = {
+ page: 0,
+ pageTotal: 1,
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
+ },
+ first: {
+ href:
+ "http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
+ },
+ last: {
+ href:
+ "http://localhost:8081/scm/api/v2/repositoryRoles/?page=0&pageSize=10"
+ },
+ create: {
+ href: "http://localhost:8081/scm/api/v2/repositoryRoles/"
+ }
+ },
+ _embedded: {
+ repositoryRoles: [role1, role2]
+ }
+};
+
+const response = {
+ headers: { "content-type": "application/json" },
+ responseBody
+};
+
+const URL = "repositoryRoles";
+const ROLES_URL = "/api/v2/repositoryRoles";
+const ROLE1_URL =
+ "http://localhost:8081/scm/api/v2/repositoryRoles/specialrole";
+
+const error = new Error("FEHLER!");
+
+describe("repository roles fetch", () => {
+ const mockStore = configureMockStore([thunk]);
+ afterEach(() => {
+ fetchMock.reset();
+ fetchMock.restore();
+ });
+
+ it("should successfully fetch repository roles", () => {
+ fetchMock.getOnce(ROLES_URL, response);
+
+ const expectedActions = [
+ { type: FETCH_ROLES_PENDING },
+ {
+ type: FETCH_ROLES_SUCCESS,
+ payload: response
+ }
+ ];
+
+ const store = mockStore({});
+
+ return store.dispatch(fetchRoles(URL)).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should fail getting repository roles on HTTP 500", () => {
+ fetchMock.getOnce(ROLES_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+
+ return store.dispatch(fetchRoles(URL)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_ROLES_PENDING);
+ expect(actions[1].type).toEqual(FETCH_ROLES_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should sucessfully fetch single role by name", () => {
+ fetchMock.getOnce(ROLES_URL + "/specialrole", role1);
+
+ const store = mockStore({});
+ return store.dispatch(fetchRoleByName(URL, "specialrole")).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
+ expect(actions[1].type).toEqual(FETCH_ROLE_SUCCESS);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should fail fetching single role by name on HTTP 500", () => {
+ fetchMock.getOnce(ROLES_URL + "/specialrole", {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchRoleByName(URL, "specialrole")).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
+ expect(actions[1].type).toEqual(FETCH_ROLE_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should sucessfully fetch single role", () => {
+ fetchMock.getOnce(ROLE1_URL, role1);
+
+ const store = mockStore({});
+ return store.dispatch(fetchRoleByLink(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
+ expect(actions[1].type).toEqual(FETCH_ROLE_SUCCESS);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should fail fetching single role on HTTP 500", () => {
+ fetchMock.getOnce(ROLE1_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchRoleByLink(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_ROLE_PENDING);
+ expect(actions[1].type).toEqual(FETCH_ROLE_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should add a role successfully", () => {
+ // unmatched
+ fetchMock.postOnce(ROLES_URL, {
+ status: 204
+ });
+
+ // after create, the roles are fetched again
+ fetchMock.getOnce(ROLES_URL, response);
+
+ const store = mockStore({});
+
+ return store.dispatch(createRole(URL, role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_ROLE_PENDING);
+ expect(actions[1].type).toEqual(CREATE_ROLE_SUCCESS);
+ });
+ });
+
+ it("should fail adding a role on HTTP 500", () => {
+ fetchMock.postOnce(ROLES_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+
+ return store.dispatch(createRole(URL, role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_ROLE_PENDING);
+ expect(actions[1].type).toEqual(CREATE_ROLE_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should call the callback after role successfully created", () => {
+ // unmatched
+ fetchMock.postOnce(ROLES_URL, {
+ status: 204
+ });
+
+ let callMe = "not yet";
+
+ const callback = () => {
+ callMe = "yeah";
+ };
+
+ const store = mockStore({});
+ return store.dispatch(createRole(URL, role1, callback)).then(() => {
+ expect(callMe).toBe("yeah");
+ });
+ });
+
+ it("successfully update role", () => {
+ fetchMock.putOnce(ROLE1_URL, {
+ status: 204
+ });
+ fetchMock.getOnce(ROLE1_URL, role1);
+
+ const store = mockStore({});
+ return store.dispatch(modifyRole(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions.length).toBe(3);
+ expect(actions[0].type).toEqual(MODIFY_ROLE_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_ROLE_SUCCESS);
+ expect(actions[2].type).toEqual(FETCH_ROLE_PENDING);
+ });
+ });
+
+ it("should call callback, after successful modified role", () => {
+ fetchMock.putOnce(ROLE1_URL, {
+ status: 204
+ });
+ fetchMock.getOnce(ROLE1_URL, role1);
+
+ let called = false;
+ const callMe = () => {
+ called = true;
+ };
+
+ const store = mockStore({});
+ return store.dispatch(modifyRole(role1, callMe)).then(() => {
+ expect(called).toBeTruthy();
+ });
+ });
+
+ it("should fail updating role on HTTP 500", () => {
+ fetchMock.putOnce(ROLE1_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(modifyRole(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_ROLE_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_ROLE_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should delete successfully role1", () => {
+ fetchMock.deleteOnce(ROLE1_URL, {
+ status: 204
+ });
+
+ const store = mockStore({});
+ return store.dispatch(deleteRole(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions.length).toBe(2);
+ expect(actions[0].type).toEqual(DELETE_ROLE_PENDING);
+ expect(actions[0].payload).toBe(role1);
+ expect(actions[1].type).toEqual(DELETE_ROLE_SUCCESS);
+ });
+ });
+
+ it("should call the callback after successful delete", () => {
+ fetchMock.deleteOnce(ROLE1_URL, {
+ status: 204
+ });
+
+ let called = false;
+ const callMe = () => {
+ called = true;
+ };
+
+ const store = mockStore({});
+ return store.dispatch(deleteRole(role1, callMe)).then(() => {
+ expect(called).toBeTruthy();
+ });
+ });
+
+ it("should fail to delete role1", () => {
+ fetchMock.deleteOnce(ROLE1_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(deleteRole(role1)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(DELETE_ROLE_PENDING);
+ expect(actions[0].payload).toBe(role1);
+ expect(actions[1].type).toEqual(DELETE_ROLE_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+});
+
+describe("repository roles reducer", () => {
+ it("should update state correctly according to FETCH_ROLES_SUCCESS action", () => {
+ const newState = reducer({}, fetchRolesSuccess(responseBody));
+
+ expect(newState.list).toEqual({
+ entries: ["specialrole", "WRITE"],
+ entry: {
+ roleCreatePermission: true,
+ page: 0,
+ pageTotal: 1,
+ _links: responseBody._links
+ }
+ });
+
+ expect(newState.byNames).toEqual({
+ specialrole: role1,
+ WRITE: role2
+ });
+
+ expect(newState.list.entry.roleCreatePermission).toBeTruthy();
+ });
+
+ it("should set roleCreatePermission to true if update link is present", () => {
+ const newState = reducer({}, fetchRolesSuccess(responseBody));
+
+ expect(newState.list.entry.roleCreatePermission).toBeTruthy();
+ });
+
+ it("should not replace whole byNames map when fetching roles", () => {
+ const oldState = {
+ byNames: {
+ WRITE: role2
+ }
+ };
+
+ const newState = reducer(oldState, fetchRolesSuccess(responseBody));
+ expect(newState.byNames["specialrole"]).toBeDefined();
+ expect(newState.byNames["WRITE"]).toBeDefined();
+ });
+
+ it("should remove role from state when delete succeeds", () => {
+ const state = {
+ list: {
+ entries: ["WRITE", "specialrole"]
+ },
+ byNames: {
+ specialrole: role1,
+ WRITE: role2
+ }
+ };
+
+ const newState = reducer(state, deleteRoleSuccess(role2));
+ expect(newState.byNames["specialrole"]).toBeDefined();
+ expect(newState.byNames["WRITE"]).toBeFalsy();
+ expect(newState.list.entries).toEqual(["specialrole"]);
+ });
+
+ it("should set roleCreatePermission to true if create link is present", () => {
+ const newState = reducer({}, fetchRolesSuccess(responseBody));
+
+ expect(newState.list.entry.roleCreatePermission).toBeTruthy();
+ expect(newState.list.entries).toEqual(["specialrole", "WRITE"]);
+ expect(newState.byNames["WRITE"]).toBeTruthy();
+ expect(newState.byNames["specialrole"]).toBeTruthy();
+ });
+
+ it("should update state according to FETCH_ROLE_SUCCESS action", () => {
+ const newState = reducer({}, fetchRoleSuccess(role2));
+ expect(newState.byNames["WRITE"]).toBe(role2);
+ });
+
+ it("should affect roles state nor the state of other roles", () => {
+ const newState = reducer(
+ {
+ list: {
+ entries: ["specialrole"]
+ }
+ },
+ fetchRoleSuccess(role2)
+ );
+ expect(newState.byNames["WRITE"]).toBe(role2);
+ expect(newState.list.entries).toEqual(["specialrole"]);
+ });
+});
+
+describe("repository roles selector", () => {
+ it("should return an empty object", () => {
+ expect(selectListAsCollection({})).toEqual({});
+ expect(selectListAsCollection({ roles: { a: "a" } })).toEqual({});
+ });
+
+ it("should return a state slice collection", () => {
+ const collection = {
+ page: 3,
+ totalPages: 42
+ };
+
+ const state = {
+ roles: {
+ list: {
+ entry: collection
+ }
+ }
+ };
+ expect(selectListAsCollection(state)).toBe(collection);
+ });
+
+ it("should return false", () => {
+ expect(isPermittedToCreateRoles({})).toBe(false);
+ expect(isPermittedToCreateRoles({ roles: { list: { entry: {} } } })).toBe(
+ false
+ );
+ expect(
+ isPermittedToCreateRoles({
+ roles: { list: { entry: { roleCreatePermission: false } } }
+ })
+ ).toBe(false);
+ });
+
+ it("should return true", () => {
+ const state = {
+ roles: {
+ list: {
+ entry: {
+ roleCreatePermission: true
+ }
+ }
+ }
+ };
+ expect(isPermittedToCreateRoles(state)).toBe(true);
+ });
+
+ it("should get repositoryRoles from state", () => {
+ const state = {
+ roles: {
+ list: {
+ entries: ["a", "b"]
+ },
+ byNames: {
+ a: { name: "a" },
+ b: { name: "b" }
+ }
+ }
+ };
+ expect(getRolesFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
+ });
+
+ it("should return true, when fetch repositoryRoles is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_ROLES]: true
+ }
+ };
+ expect(isFetchRolesPending(state)).toEqual(true);
+ });
+
+ it("should return false, when fetch repositoryRoles is not pending", () => {
+ expect(isFetchRolesPending({})).toEqual(false);
+ });
+
+ it("should return error when fetch repositoryRoles did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_ROLES]: error
+ }
+ };
+ expect(getFetchRolesFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when fetch repositoryRoles did not fail", () => {
+ expect(getFetchRolesFailure({})).toBe(undefined);
+ });
+
+ it("should return true if create role is pending", () => {
+ const state = {
+ pending: {
+ [CREATE_ROLE]: true
+ }
+ };
+ expect(isCreateRolePending(state)).toBe(true);
+ });
+
+ it("should return false if create role is not pending", () => {
+ const state = {
+ pending: {
+ [CREATE_ROLE]: false
+ }
+ };
+ expect(isCreateRolePending(state)).toBe(false);
+ });
+
+ it("should return error when create role did fail", () => {
+ const state = {
+ failure: {
+ [CREATE_ROLE]: error
+ }
+ };
+ expect(getCreateRoleFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when create role did not fail", () => {
+ expect(getCreateRoleFailure({})).toBe(undefined);
+ });
+
+ it("should return role1", () => {
+ const state = {
+ roles: {
+ byNames: {
+ role1: role1
+ }
+ }
+ };
+ expect(getRoleByName(state, "role1")).toEqual(role1);
+ });
+
+ it("should return true, when fetch role2 is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_ROLE + "/role2"]: true
+ }
+ };
+ expect(isFetchRolePending(state, "role2")).toEqual(true);
+ });
+
+ it("should return false, when fetch role2 is not pending", () => {
+ expect(isFetchRolePending({}, "role2")).toEqual(false);
+ });
+
+ it("should return error when fetch role2 did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_ROLE + "/role2"]: error
+ }
+ };
+ expect(getFetchRoleFailure(state, "role2")).toEqual(error);
+ });
+
+ it("should return undefined when fetch role2 did not fail", () => {
+ expect(getFetchRoleFailure({}, "role2")).toBe(undefined);
+ });
+
+ it("should return true, when modify role1 is pending", () => {
+ const state = {
+ pending: {
+ [MODIFY_ROLE + "/role1"]: true
+ }
+ };
+ expect(isModifyRolePending(state, "role1")).toEqual(true);
+ });
+
+ it("should return false, when modify role1 is not pending", () => {
+ expect(isModifyRolePending({}, "role1")).toEqual(false);
+ });
+
+ it("should return error when modify role1 did fail", () => {
+ const state = {
+ failure: {
+ [MODIFY_ROLE + "/role1"]: error
+ }
+ };
+ expect(getModifyRoleFailure(state, "role1")).toEqual(error);
+ });
+
+ it("should return undefined when modify role1 did not fail", () => {
+ expect(getModifyRoleFailure({}, "role1")).toBe(undefined);
+ });
+
+ it("should return true, when delete role2 is pending", () => {
+ const state = {
+ pending: {
+ [DELETE_ROLE + "/role2"]: true
+ }
+ };
+ expect(isDeleteRolePending(state, "role2")).toEqual(true);
+ });
+
+ it("should return false, when delete role2 is not pending", () => {
+ expect(isDeleteRolePending({}, "role2")).toEqual(false);
+ });
+
+ it("should return error when delete role2 did fail", () => {
+ const state = {
+ failure: {
+ [DELETE_ROLE + "/role2"]: error
+ }
+ };
+ expect(getDeleteRoleFailure(state, "role2")).toEqual(error);
+ });
+
+ it("should return undefined when delete role2 did not fail", () => {
+ expect(getDeleteRoleFailure({}, "role2")).toBe(undefined);
+ });
+});
diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js
index eb0586d657..500ea23752 100644
--- a/scm-ui/src/createReduxStore.js
+++ b/scm-ui/src/createReduxStore.js
@@ -15,6 +15,7 @@ import pending from "./modules/pending";
import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions";
import config from "./config/modules/config";
+import roles from "./config/roles/modules/roles";
import namespaceStrategies from "./config/modules/namespaceStrategies";
import indexResources from "./modules/indexResource";
@@ -39,6 +40,7 @@ function createReduxStore(history: BrowserHistory) {
groups,
auth,
config,
+ roles,
sources,
namespaceStrategies
});
diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js
index cb3c24aa0f..6884a8817f 100644
--- a/scm-ui/src/groups/modules/groups.js
+++ b/scm-ui/src/groups/modules/groups.js
@@ -28,7 +28,7 @@ export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
export const MODIFY_GROUP_RESET = `${MODIFY_GROUP}_${types.RESET_SUFFIX}`;
-export const DELETE_GROUP = "scm/groups/DELETE";
+export const DELETE_GROUP = "scm/groups/DELETE_GROUP";
export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`;
diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js
index df55c63756..12126e4841 100644
--- a/scm-ui/src/modules/indexResource.js
+++ b/scm-ui/src/modules/indexResource.js
@@ -127,6 +127,14 @@ export function getUsersLink(state: Object) {
return getLink(state, "users");
}
+export function getRepositoryRolesLink(state: Object) {
+ return getLink(state, "repositoryRoles");
+}
+
+export function getRepositoryVerbsLink(state: Object) {
+ return getLink(state, "repositoryVerbs");
+}
+
export function getGroupsLink(state: Object) {
return getLink(state, "groups");
}
diff --git a/scm-ui/src/repos/branches/modules/branches.js b/scm-ui/src/repos/branches/modules/branches.js
index 40228eac91..7886a0fcbf 100644
--- a/scm-ui/src/repos/branches/modules/branches.js
+++ b/scm-ui/src/repos/branches/modules/branches.js
@@ -307,10 +307,9 @@ const reduceByBranchesSuccess = (state, payload) => {
const byName = repoState.byName || {};
repoState.byName = byName;
- if(response._embedded) {
+ if (response._embedded) {
const branches = response._embedded.branches;
- const names = branches.map(b => b.name);
- response._embedded.branches = names;
+ response._embedded.branches = branches.map(b => b.name);
for (let branch of branches) {
byName[branch.name] = branch;
}
diff --git a/scm-ui/src/repos/modules/changesets.js b/scm-ui/src/repos/modules/changesets.js
index 80c405f5de..3cd617ac56 100644
--- a/scm-ui/src/repos/modules/changesets.js
+++ b/scm-ui/src/repos/modules/changesets.js
@@ -1,10 +1,19 @@
// @flow
-import {FAILURE_SUFFIX, PENDING_SUFFIX, SUCCESS_SUFFIX} from "../../modules/types";
-import {apiClient, urls} from "@scm-manager/ui-components";
-import {isPending} from "../../modules/pending";
-import {getFailure} from "../../modules/failure";
-import type {Action, Branch, PagedCollection, Repository} from "@scm-manager/ui-types";
+import {
+ FAILURE_SUFFIX,
+ PENDING_SUFFIX,
+ SUCCESS_SUFFIX
+} from "../../modules/types";
+import { apiClient, urls } from "@scm-manager/ui-components";
+import { isPending } from "../../modules/pending";
+import { getFailure } from "../../modules/failure";
+import type {
+ Action,
+ Branch,
+ PagedCollection,
+ Repository
+} from "@scm-manager/ui-types";
export const FETCH_CHANGESETS = "scm/repos/FETCH_CHANGESETS";
export const FETCH_CHANGESETS_PENDING = `${FETCH_CHANGESETS}_${PENDING_SUFFIX}`;
diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js
index cd48f89fea..ae942e668b 100644
--- a/scm-ui/src/repos/modules/repos.js
+++ b/scm-ui/src/repos/modules/repos.js
@@ -353,15 +353,13 @@ function normalizeByNamespaceAndName(
const reducerByNames = (state: Object, repository: Repository) => {
const identifier = createIdentifier(repository);
- const newState = {
+ return {
...state,
byNames: {
...state.byNames,
[identifier]: repository
}
};
-
- return newState;
};
export default function reducer(
diff --git a/scm-ui/src/repos/modules/repositoryTypes.js b/scm-ui/src/repos/modules/repositoryTypes.js
index d96d24b612..043ab04d68 100644
--- a/scm-ui/src/repos/modules/repositoryTypes.js
+++ b/scm-ui/src/repos/modules/repositoryTypes.js
@@ -49,10 +49,7 @@ export function shouldFetchRepositoryTypes(state: Object) {
) {
return false;
}
- if (state.repositoryTypes && state.repositoryTypes.length > 0) {
- return false;
- }
- return true;
+ return !(state.repositoryTypes && state.repositoryTypes.length > 0);
}
export function fetchRepositoryTypesPending(): Action {
diff --git a/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js b/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js
index cc662e5f06..d15b9112b9 100644
--- a/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js
+++ b/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js
@@ -26,9 +26,11 @@ class AdvancedPermissionsDialog extends React.Component {
const verbs = {};
props.availableVerbs.forEach(
- verb => (verbs[verb] = props.selectedVerbs.includes(verb))
+ verb =>
+ (verbs[verb] = props.selectedVerbs
+ ? props.selectedVerbs.includes(verb)
+ : false)
);
-
this.state = { verbs };
}
diff --git a/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js b/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js
index b5f683b28e..112ecd1c20 100644
--- a/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js
+++ b/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js
@@ -1,6 +1,12 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
+import type {
+ RepositoryRole,
+ PermissionCollection,
+ PermissionCreateEntry,
+ SelectValue
+} from "@scm-manager/ui-types";
import {
Subtitle,
Autocomplete,
@@ -9,30 +15,28 @@ import {
LabelWithHelpIcon,
Radio
} from "@scm-manager/ui-components";
-import RoleSelector from "../components/RoleSelector";
-import type {
- AvailableRepositoryPermissions,
- PermissionCollection,
- PermissionCreateEntry,
- SelectValue
-} from "@scm-manager/ui-types";
import * as validator from "../components/permissionValidation";
-import { findMatchingRoleName } from "../modules/permissions";
+import RoleSelector from "../components/RoleSelector";
import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog";
+import { findVerbsForRole } from "../modules/permissions";
type Props = {
- t: string => string,
- availablePermissions: AvailableRepositoryPermissions,
+ availableRoles: RepositoryRole[],
+ availableVerbs: string[],
createPermission: (permission: PermissionCreateEntry) => void,
loading: boolean,
currentPermissions: PermissionCollection,
groupAutoCompleteLink: string,
- userAutoCompleteLink: string
+ userAutoCompleteLink: string,
+
+ // Context props
+ t: string => string
};
type State = {
name: string,
- verbs: string[],
+ role?: string,
+ verbs?: string[],
groupPermission: boolean,
valid: boolean,
value?: SelectValue,
@@ -45,7 +49,8 @@ class CreatePermissionForm extends React.Component {
this.state = {
name: "",
- verbs: props.availablePermissions.availableRoles[0].verbs,
+ role: props.availableRoles[0].name,
+ verbs: undefined,
groupPermission: false,
valid: true,
value: undefined,
@@ -90,6 +95,7 @@ class CreatePermissionForm extends React.Component {
});
});
}
+
renderAutocompletionField = () => {
const { t } = this.props;
if (this.state.groupPermission) {
@@ -133,19 +139,17 @@ class CreatePermissionForm extends React.Component {
};
render() {
- const { t, availablePermissions, loading } = this.props;
+ const { t, availableRoles, availableVerbs, loading } = this.props;
+ const { role, verbs, showAdvancedDialog } = this.state;
- const { verbs, showAdvancedDialog } = this.state;
+ const availableRoleNames = availableRoles.map(r => r.name);
- const availableRoleNames = availablePermissions.availableRoles.map(
- r => r.name
- );
- const matchingRole = findMatchingRoleName(availablePermissions, verbs);
+ const selectedVerbs = role ? findVerbsForRole(availableRoles, role) : verbs;
const advancedDialog = showAdvancedDialog ? (
@@ -187,7 +191,7 @@ class CreatePermissionForm extends React.Component {
label={t("permission.role")}
helpText={t("permission.help.roleHelpText")}
handleRoleChange={this.handleRoleChange}
- role={matchingRole}
+ role={role}
/>
@@ -228,6 +232,7 @@ class CreatePermissionForm extends React.Component
{
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
this.setState({
showAdvancedDialog: false,
+ role: undefined,
verbs: newVerbs
});
};
@@ -235,6 +240,7 @@ class CreatePermissionForm extends React.Component {
submit = e => {
this.props.createPermission({
name: this.state.name,
+ role: this.state.role,
verbs: this.state.verbs,
groupPermission: this.state.groupPermission
});
@@ -245,7 +251,8 @@ class CreatePermissionForm extends React.Component {
removeState = () => {
this.setState({
name: "",
- verbs: this.props.availablePermissions.availableRoles[0].verbs,
+ role: this.props.availableRoles[0].name,
+ verbs: undefined,
valid: true,
value: undefined
});
@@ -257,14 +264,13 @@ class CreatePermissionForm extends React.Component {
return;
}
this.setState({
- verbs: selectedRole.verbs
+ role: selectedRole.name,
+ verbs: []
});
};
findAvailableRole = (roleName: string) => {
- return this.props.availablePermissions.availableRoles.find(
- role => role.name === roleName
- );
+ return this.props.availableRoles.find(role => role.name === roleName);
};
}
diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js
index 6fd9cbab38..eff984ad5d 100644
--- a/scm-ui/src/repos/permissions/containers/Permissions.js
+++ b/scm-ui/src/repos/permissions/containers/Permissions.js
@@ -19,7 +19,9 @@ import {
getDeletePermissionsFailure,
getModifyPermissionsFailure,
modifyPermissionReset,
- deletePermissionReset
+ deletePermissionReset,
+ getAvailableRepositoryRoles,
+ getAvailableRepositoryVerbs
} from "../modules/permissions";
import {
Loading,
@@ -28,10 +30,10 @@ import {
LabelWithHelpIcon
} from "@scm-manager/ui-components";
import type {
- AvailableRepositoryPermissions,
Permission,
PermissionCollection,
- PermissionCreateEntry
+ PermissionCreateEntry,
+ RepositoryRole
} from "@scm-manager/ui-types";
import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "./CreatePermissionForm";
@@ -39,11 +41,15 @@ import type { History } from "history";
import { getPermissionsLink } from "../../modules/repos";
import {
getGroupAutoCompleteLink,
+ getRepositoryRolesLink,
+ getRepositoryVerbsLink,
getUserAutoCompleteLink
} from "../../../modules/indexResource";
type Props = {
- availablePermissions: AvailableRepositoryPermissions,
+ availablePermissions: boolean,
+ availableRepositoryRoles: RepositoryRole[],
+ availableVerbs: string[],
namespace: string,
repoName: string,
loading: boolean,
@@ -51,12 +57,17 @@ type Props = {
permissions: PermissionCollection,
hasPermissionToCreate: boolean,
loadingCreatePermission: boolean,
+ repositoryRolesLink: string,
+ repositoryVerbsLink: string,
permissionsLink: string,
groupAutoCompleteLink: string,
userAutoCompleteLink: string,
//dispatch functions
- fetchAvailablePermissionsIfNeeded: () => void,
+ fetchAvailablePermissionsIfNeeded: (
+ repositoryRolesLink: string,
+ repositoryVerbsLink: string
+ ) => void,
fetchPermissions: (link: string, namespace: string, repoName: string) => void,
createPermission: (
link: string,
@@ -74,7 +85,6 @@ type Props = {
history: History
};
-
class Permissions extends React.Component {
componentDidMount() {
const {
@@ -85,13 +95,15 @@ class Permissions extends React.Component {
modifyPermissionReset,
createPermissionReset,
deletePermissionReset,
- permissionsLink
+ permissionsLink,
+ repositoryRolesLink,
+ repositoryVerbsLink
} = this.props;
createPermissionReset(namespace, repoName);
modifyPermissionReset(namespace, repoName);
deletePermissionReset(namespace, repoName);
- fetchAvailablePermissionsIfNeeded();
+ fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink);
fetchPermissions(permissionsLink, namespace, repoName);
}
@@ -107,6 +119,8 @@ class Permissions extends React.Component {
render() {
const {
availablePermissions,
+ availableRepositoryRoles,
+ availableVerbs,
loading,
error,
permissions,
@@ -134,7 +148,8 @@ class Permissions extends React.Component {
const createPermissionForm = hasPermissionToCreate ? (
this.createPermission(permission)}
loading={loadingCreatePermission}
currentPermissions={permissions}
@@ -174,7 +189,8 @@ class Permissions extends React.Component {
{permissions.map(permission => {
return (
{
repoName
);
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
+ const repositoryRolesLink = getRepositoryRolesLink(state);
+ const repositoryVerbsLink = getRepositoryVerbsLink(state);
const permissionsLink = getPermissionsLink(state, namespace, repoName);
const groupAutoCompleteLink = getGroupAutoCompleteLink(state);
const userAutoCompleteLink = getUserAutoCompleteLink(state);
const availablePermissions = getAvailablePermissions(state);
+ const availableRepositoryRoles = getAvailableRepositoryRoles(state);
+ const availableVerbs = getAvailableRepositoryVerbs(state);
+
return {
availablePermissions,
+ availableRepositoryRoles,
+ availableVerbs,
namespace,
repoName,
+ repositoryRolesLink,
+ repositoryVerbsLink,
error,
loading,
permissions,
@@ -233,8 +258,16 @@ const mapDispatchToProps = dispatch => {
fetchPermissions: (link: string, namespace: string, repoName: string) => {
dispatch(fetchPermissions(link, namespace, repoName));
},
- fetchAvailablePermissionsIfNeeded: () => {
- dispatch(fetchAvailablePermissionsIfNeeded());
+ fetchAvailablePermissionsIfNeeded: (
+ repositoryRolesLink: string,
+ repositoryVerbsLink: string
+ ) => {
+ dispatch(
+ fetchAvailablePermissionsIfNeeded(
+ repositoryRolesLink,
+ repositoryVerbsLink
+ )
+ );
},
createPermission: (
link: string,
diff --git a/scm-ui/src/repos/permissions/containers/SinglePermission.js b/scm-ui/src/repos/permissions/containers/SinglePermission.js
index e5a9c604a6..bdb96de0bd 100644
--- a/scm-ui/src/repos/permissions/containers/SinglePermission.js
+++ b/scm-ui/src/repos/permissions/containers/SinglePermission.js
@@ -1,16 +1,13 @@
// @flow
import React from "react";
-import type {
- AvailableRepositoryPermissions,
- Permission
-} from "@scm-manager/ui-types";
+import type { RepositoryRole, Permission } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import {
modifyPermission,
isModifyPermissionPending,
deletePermission,
isDeletePermissionPending,
- findMatchingRoleName
+ findVerbsForRole
} from "../modules/permissions";
import { connect } from "react-redux";
import type { History } from "history";
@@ -22,7 +19,8 @@ import classNames from "classnames";
import injectSheet from "react-jss";
type Props = {
- availablePermissions: AvailableRepositoryPermissions,
+ availableRepositoryRoles: RepositoryRole[],
+ availableRepositoryVerbs: string[],
submitForm: Permission => void,
modifyPermission: (
permission: Permission,
@@ -46,7 +44,6 @@ type Props = {
};
type State = {
- role: string,
permission: Permission,
showAdvancedDialog: boolean
};
@@ -68,39 +65,34 @@ class SinglePermission extends React.Component {
constructor(props: Props) {
super(props);
- const defaultPermission = props.availablePermissions.availableRoles
- ? props.availablePermissions.availableRoles[0]
+ const defaultPermission = props.availableRepositoryRoles
+ ? props.availableRepositoryRoles[0]
: {};
this.state = {
permission: {
name: "",
+ role: undefined,
verbs: defaultPermission.verbs,
groupPermission: false,
_links: {}
},
- role: defaultPermission.name,
showAdvancedDialog: false
};
}
componentDidMount() {
- const { availablePermissions, permission } = this.props;
-
- const matchingRole = findMatchingRoleName(
- availablePermissions,
- permission.verbs
- );
+ const { permission } = this.props;
if (permission) {
this.setState({
permission: {
name: permission.name,
+ role: permission.role,
verbs: permission.verbs,
groupPermission: permission.groupPermission,
_links: permission._links
- },
- role: matchingRole
+ }
});
}
}
@@ -114,37 +106,41 @@ class SinglePermission extends React.Component {
};
render() {
- const { role, permission, showAdvancedDialog } = this.state;
+ const { permission, showAdvancedDialog } = this.state;
const {
t,
- availablePermissions,
+ availableRepositoryRoles,
+ availableRepositoryVerbs,
loading,
namespace,
repoName,
classes
} = this.props;
- const availableRoleNames = availablePermissions.availableRoles.map(
- r => r.name
- );
+ const availableRoleNames =
+ !!availableRepositoryRoles && availableRepositoryRoles.map(r => r.name);
const readOnly = !this.mayChangePermissions();
const roleSelector = readOnly ? (
- {role}
+ {permission.role ? permission.role : t("permission.custom")}
) : (
);
- const advancedDialg = showAdvancedDialog ? (
+ const selectedVerbs = permission.role
+ ? findVerbsForRole(availableRepositoryRoles, permission.role)
+ : permission.verbs;
+
+ const advancedDialog = showAdvancedDialog ? (
@@ -152,9 +148,15 @@ class SinglePermission extends React.Component {
const iconType =
permission && permission.groupPermission ? (
-
+
) : (
-
+
);
return (
@@ -177,7 +179,7 @@ class SinglePermission extends React.Component {
deletePermission={this.deletePermission}
loading={this.props.deleteLoading}
/>
- {advancedDialg}
+ {advancedDialog}
);
@@ -197,41 +199,41 @@ class SinglePermission extends React.Component {
submitAdvancedPermissionsDialog = (newVerbs: string[]) => {
const { permission } = this.state;
- const newRole = findMatchingRoleName(
- this.props.availablePermissions,
- newVerbs
- );
this.setState(
{
showAdvancedDialog: false,
- permission: { ...permission, verbs: newVerbs },
- role: newRole
+ permission: { ...permission, role: undefined, verbs: newVerbs }
},
- () => this.modifyPermission(newVerbs)
+ () => this.modifyPermissionVerbs(newVerbs)
);
};
handleRoleChange = (role: string) => {
- const selectedRole = this.findAvailableRole(role);
+ const { permission } = this.state;
this.setState(
{
- permission: {
- ...this.state.permission,
- verbs: selectedRole.verbs
- },
- role: role
+ permission: { ...permission, role: role, verbs: undefined }
},
- () => this.modifyPermission(selectedRole.verbs)
+ () => this.modifyPermissionRole(role)
);
};
findAvailableRole = (roleName: string) => {
- return this.props.availablePermissions.availableRoles.find(
- role => role.name === roleName
+ const { availableRepositoryRoles } = this.props;
+ return availableRepositoryRoles.find(role => role.name === roleName);
+ };
+
+ modifyPermissionRole = (role: string) => {
+ let permission = this.state.permission;
+ permission.role = role;
+ this.props.modifyPermission(
+ permission,
+ this.props.namespace,
+ this.props.repoName
);
};
- modifyPermission = (verbs: string[]) => {
+ modifyPermissionVerbs = (verbs: string[]) => {
let permission = this.state.permission;
permission.verbs = verbs;
this.props.modifyPermission(
diff --git a/scm-ui/src/repos/permissions/modules/permissions.js b/scm-ui/src/repos/permissions/modules/permissions.js
index 8c4161e907..276f28f672 100644
--- a/scm-ui/src/repos/permissions/modules/permissions.js
+++ b/scm-ui/src/repos/permissions/modules/permissions.js
@@ -4,7 +4,7 @@ import type { Action } from "@scm-manager/ui-components";
import { apiClient } from "@scm-manager/ui-components";
import * as types from "../../../modules/types";
import type {
- AvailableRepositoryPermissions,
+ RepositoryRole,
Permission,
PermissionCollection,
PermissionCreateEntry
@@ -12,7 +12,6 @@ import type {
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import { Dispatch } from "redux";
-import { getLinks } from "../../../modules/indexResource";
export const FETCH_AVAILABLE = "scm/permissions/FETCH_AVAILABLE";
export const FETCH_AVAILABLE_PENDING = `${FETCH_AVAILABLE}_${
@@ -78,22 +77,45 @@ const CONTENT_TYPE = "application/vnd.scmm-repositoryPermission+json";
// fetch available permissions
-export function fetchAvailablePermissionsIfNeeded() {
+export function fetchAvailablePermissionsIfNeeded(
+ repositoryRolesLink: string,
+ repositoryVerbsLink: string
+) {
return function(dispatch: any, getState: () => Object) {
if (shouldFetchAvailablePermissions(getState())) {
- return fetchAvailablePermissions(dispatch, getState);
+ return fetchAvailablePermissions(
+ dispatch,
+ getState,
+ repositoryRolesLink,
+ repositoryVerbsLink
+ );
}
};
}
export function fetchAvailablePermissions(
dispatch: any,
- getState: () => Object
+ getState: () => Object,
+ repositoryRolesLink: string,
+ repositoryVerbsLink: string
) {
dispatch(fetchAvailablePending());
return apiClient
- .get(getLinks(getState()).availableRepositoryPermissions.href)
- .then(response => response.json())
+ .get(repositoryRolesLink)
+ .then(repositoryRoles => repositoryRoles.json())
+ .then(repositoryRoles => repositoryRoles._embedded.repositoryRoles)
+ .then(repositoryRoles => {
+ return apiClient
+ .get(repositoryVerbsLink)
+ .then(repositoryVerbs => repositoryVerbs.json())
+ .then(repositoryVerbs => repositoryVerbs.verbs)
+ .then(repositoryVerbs => {
+ return {
+ repositoryVerbs,
+ repositoryRoles
+ };
+ });
+ })
.then(available => {
dispatch(fetchAvailableSuccess(available));
})
@@ -121,7 +143,7 @@ export function fetchAvailablePending(): Action {
}
export function fetchAvailableSuccess(
- available: AvailableRepositoryPermissions
+ available: [RepositoryRole[], string[]]
): Action {
return {
type: FETCH_AVAILABLE_SUCCESS,
@@ -543,14 +565,28 @@ export function getAvailablePermissions(state: Object) {
}
}
+export function getAvailableRepositoryRoles(state: Object) {
+ return available(state).repositoryRoles;
+}
+
+export function getAvailableRepositoryVerbs(state: Object) {
+ return available(state).repositoryVerbs;
+}
+
+function available(state: Object) {
+ if (state.permissions && state.permissions.available) {
+ return state.permissions.available;
+ }
+ return {};
+}
+
export function getPermissionsOfRepo(
state: Object,
namespace: string,
repoName: string
) {
if (state.permissions && state.permissions[namespace + "/" + repoName]) {
- const permissions = state.permissions[namespace + "/" + repoName].entries;
- return permissions;
+ return state.permissions[namespace + "/" + repoName].entries;
}
}
@@ -704,32 +740,16 @@ export function getModifyPermissionsFailure(
return null;
}
-export function findMatchingRoleName(
- availablePermissions: AvailableRepositoryPermissions,
- verbs: string[]
+export function findVerbsForRole(
+ availableRepositoryRoles: RepositoryRole[],
+ roleName: string
) {
- if (!verbs) {
- return "";
- }
- const matchingRole = availablePermissions.availableRoles.find(role => {
- return equalVerbs(role.verbs, verbs);
- });
-
+ const matchingRole = availableRepositoryRoles.find(
+ role => roleName === role.name
+ );
if (matchingRole) {
- return matchingRole.name;
+ return matchingRole.verbs;
} else {
- return "";
+ return [];
}
}
-
-function equalVerbs(verbs1: string[], verbs2: string[]) {
- if (!verbs1 || !verbs2) {
- return false;
- }
-
- if (verbs1.length !== verbs2.length) {
- return false;
- }
-
- return verbs1.every(verb => verbs2.includes(verb));
-}
diff --git a/scm-ui/src/users/modules/users.js b/scm-ui/src/users/modules/users.js
index c83a4d9b94..f515fb27e8 100644
--- a/scm-ui/src/users/modules/users.js
+++ b/scm-ui/src/users/modules/users.js
@@ -28,7 +28,7 @@ export const MODIFY_USER_SUCCESS = `${MODIFY_USER}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_USER_FAILURE = `${MODIFY_USER}_${types.FAILURE_SUFFIX}`;
export const MODIFY_USER_RESET = `${MODIFY_USER}_${types.RESET_SUFFIX}`;
-export const DELETE_USER = "scm/users/DELETE";
+export const DELETE_USER = "scm/users/DELETE_USER";
export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`;
export const DELETE_USER_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`;
export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`;
@@ -324,12 +324,10 @@ function deleteUserInEntries(users: [], userName: string) {
}
const reducerByName = (state: any, username: string, newUserState: any) => {
- const newUsersByNames = {
+ return {
...state,
[username]: newUserState
};
-
- return newUsersByNames;
};
function listReducer(state: any = {}, action: any = {}) {
@@ -341,7 +339,7 @@ function listReducer(state: any = {}, action: any = {}) {
...state,
entries: userNames,
entry: {
- userCreatePermission: action.payload._links.create ? true : false,
+ userCreatePermission: !!action.payload._links.create,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
@@ -379,11 +377,10 @@ function byNamesReducer(state: any = {}, action: any = {}) {
return reducerByName(state, action.payload.name, action.payload);
case DELETE_USER_SUCCESS:
- const newUserByNames = deleteUserInUsersByNames(
+ return deleteUserInUsersByNames(
state,
action.payload.name
);
- return newUserByNames;
default:
return state;
@@ -417,11 +414,7 @@ export const selectListAsCollection = (state: Object): PagedCollection => {
};
export const isPermittedToCreateUsers = (state: Object): boolean => {
- const permission = selectListEntry(state).userCreatePermission;
- if (permission) {
- return true;
- }
- return false;
+ return !!selectListEntry(state).userCreatePermission;
};
export function getUsersFromState(state: Object) {
diff --git a/scm-ui/src/users/modules/users.test.js b/scm-ui/src/users/modules/users.test.js
index ac4d1c97a9..e046443f3b 100644
--- a/scm-ui/src/users/modules/users.test.js
+++ b/scm-ui/src/users/modules/users.test.js
@@ -4,49 +4,49 @@ import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
- CREATE_USER_FAILURE,
- CREATE_USER_PENDING,
- CREATE_USER_SUCCESS,
- createUser,
- DELETE_USER_FAILURE,
- DELETE_USER_PENDING,
- DELETE_USER_SUCCESS,
- deleteUser,
- deleteUserSuccess,
- FETCH_USER_FAILURE,
- FETCH_USER_PENDING,
- isFetchUserPending,
- FETCH_USER_SUCCESS,
- FETCH_USERS_FAILURE,
+ FETCH_USERS,
FETCH_USERS_PENDING,
FETCH_USERS_SUCCESS,
+ FETCH_USERS_FAILURE,
+ FETCH_USER,
+ FETCH_USER_PENDING,
+ FETCH_USER_SUCCESS,
+ FETCH_USER_FAILURE,
+ CREATE_USER,
+ CREATE_USER_PENDING,
+ CREATE_USER_SUCCESS,
+ CREATE_USER_FAILURE,
+ MODIFY_USER,
+ MODIFY_USER_PENDING,
+ MODIFY_USER_SUCCESS,
+ MODIFY_USER_FAILURE,
+ DELETE_USER,
+ DELETE_USER_PENDING,
+ DELETE_USER_SUCCESS,
+ DELETE_USER_FAILURE,
+ fetchUsers,
+ getFetchUsersFailure,
+ getUsersFromState,
+ isFetchUsersPending,
+ fetchUsersSuccess,
fetchUserByLink,
fetchUserByName,
fetchUserSuccess,
+ isFetchUserPending,
getFetchUserFailure,
- fetchUsers,
- fetchUsersSuccess,
- isFetchUsersPending,
- selectListAsCollection,
- isPermittedToCreateUsers,
- MODIFY_USER,
- MODIFY_USER_FAILURE,
- MODIFY_USER_PENDING,
- MODIFY_USER_SUCCESS,
- modifyUser,
- getUsersFromState,
- FETCH_USERS,
- getFetchUsersFailure,
- FETCH_USER,
- CREATE_USER,
+ createUser,
isCreateUserPending,
getCreateUserFailure,
getUserByName,
+ modifyUser,
isModifyUserPending,
getModifyUserFailure,
- DELETE_USER,
+ deleteUser,
isDeleteUserPending,
- getDeleteUserFailure
+ deleteUserSuccess,
+ getDeleteUserFailure,
+ selectListAsCollection,
+ isPermittedToCreateUsers
} from "./users";
const userZaphod = {
@@ -302,7 +302,7 @@ describe("users fetch()", () => {
});
it("should fail updating user on HTTP 500", () => {
- fetchMock.putOnce("http://localhost:8081/api/v2/users/zaphod", {
+ fetchMock.putOnce(USER_ZAPHOD_URL, {
status: 500
});
@@ -316,7 +316,7 @@ describe("users fetch()", () => {
});
it("should delete successfully user zaphod", () => {
- fetchMock.deleteOnce("http://localhost:8081/api/v2/users/zaphod", {
+ fetchMock.deleteOnce(USER_ZAPHOD_URL, {
status: 204
});
@@ -331,7 +331,7 @@ describe("users fetch()", () => {
});
it("should call the callback, after successful delete", () => {
- fetchMock.deleteOnce("http://localhost:8081/api/v2/users/zaphod", {
+ fetchMock.deleteOnce(USER_ZAPHOD_URL, {
status: 204
});
@@ -347,7 +347,7 @@ describe("users fetch()", () => {
});
it("should fail to delete user zaphod", () => {
- fetchMock.deleteOnce("http://localhost:8081/api/v2/users/zaphod", {
+ fetchMock.deleteOnce(USER_ZAPHOD_URL, {
status: 500
});
diff --git a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
index e89cedb750..f338d6d277 100644
--- a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
+++ b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
@@ -7,6 +7,8 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
+import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
+
public class ManagerDaoAdapter {
private final GenericDAO dao;
@@ -19,6 +21,9 @@ public class ManagerDaoAdapter {
T notModified = dao.get(object.getId());
if (notModified != null) {
permissionCheck.apply(notModified).check();
+
+ doThrow().violation("type must not be changed").when(!notModified.getType().equals(object.getType()));
+
AssertUtil.assertIsValid(object);
beforeUpdate.handle(notModified);
diff --git a/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java b/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java
index 6dbc33af03..40fb345caa 100644
--- a/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java
+++ b/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java
@@ -132,9 +132,6 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList
List moduleList = Lists.newArrayList();
moduleList.add(new ResteasyModule());
- moduleList.add(new ScmInitializerModule());
- moduleList.add(new ScmEventBusModule());
- moduleList.add(new EagerSingletonModule());
moduleList.add(ShiroWebModule.guiceFilterModule());
moduleList.add(new WebElementModule(pluginLoader));
moduleList.add(new ScmServletModule(context, pluginLoader, overrides));
diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java
index bc62927f39..83eb03fe61 100644
--- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java
@@ -64,6 +64,7 @@ import sonia.scm.plugin.DefaultPluginManager;
import sonia.scm.plugin.PluginManager;
import sonia.scm.repository.DefaultRepositoryManager;
import sonia.scm.repository.DefaultRepositoryProvider;
+import sonia.scm.repository.DefaultRepositoryRoleManager;
import sonia.scm.repository.HealthCheckContextListener;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.repository.NamespaceStrategyProvider;
@@ -72,10 +73,13 @@ import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryManagerProvider;
import sonia.scm.repository.RepositoryProvider;
+import sonia.scm.repository.RepositoryRoleDAO;
+import sonia.scm.repository.RepositoryRoleManager;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.spi.HookEventFacade;
import sonia.scm.repository.xml.XmlRepositoryDAO;
+import sonia.scm.repository.xml.XmlRepositoryRoleDAO;
import sonia.scm.schedule.QuartzScheduler;
import sonia.scm.schedule.Scheduler;
import sonia.scm.security.AccessTokenCookieIssuer;
@@ -237,6 +241,8 @@ public class ScmServletModule extends ServletModule
bind(GroupDAO.class, XmlGroupDAO.class);
bind(UserDAO.class, XmlUserDAO.class);
bind(RepositoryDAO.class, XmlRepositoryDAO.class);
+ bind(RepositoryRoleDAO.class, XmlRepositoryRoleDAO.class);
+ bind(RepositoryRoleManager.class).to(DefaultRepositoryRoleManager.class);
bindDecorated(RepositoryManager.class, DefaultRepositoryManager.class,
RepositoryManagerProvider.class);
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java
deleted file mode 100644
index 60203b565b..0000000000
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package sonia.scm.api.v2.resources;
-
-import de.otto.edison.hal.HalRepresentation;
-import de.otto.edison.hal.Links;
-import sonia.scm.security.RepositoryRole;
-
-import java.util.Collection;
-
-public class AvailableRepositoryPermissionsDto extends HalRepresentation {
- private final Collection availableVerbs;
- private final Collection availableRoles;
-
- public AvailableRepositoryPermissionsDto(Collection availableVerbs, Collection availableRoles) {
- this.availableVerbs = availableVerbs;
- this.availableRoles = availableRoles;
- }
-
- public Collection getAvailableVerbs() {
- return availableVerbs;
- }
-
- public Collection getAvailableRoles() {
- return availableRoles;
- }
-
- @Override
- @SuppressWarnings("squid:S1185") // We want to have this method available in this package
- protected HalRepresentation add(Links links) {
- return super.add(links);
- }
-}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbs.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbs.java
new file mode 100644
index 0000000000..d2f7fc6fd8
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbs.java
@@ -0,0 +1,23 @@
+package sonia.scm.api.v2.resources;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Target({TYPE})
+@Retention(RUNTIME)
+@Constraint(validatedBy = EitherRoleOrVerbsValidator.class)
+@Documented
+public @interface EitherRoleOrVerbs {
+
+ String message() default "permission must either have a role or a not empty set of verbs";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbsValidator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbsValidator.java
new file mode 100644
index 0000000000..194277ece3
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EitherRoleOrVerbsValidator.java
@@ -0,0 +1,30 @@
+package sonia.scm.api.v2.resources;
+
+import com.google.common.base.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+public class EitherRoleOrVerbsValidator implements ConstraintValidator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(EitherRoleOrVerbsValidator.class);
+
+ @Override
+ public void initialize(EitherRoleOrVerbs constraintAnnotation) {
+ }
+
+ @Override
+ public boolean isValid(RepositoryPermissionDto object, ConstraintValidatorContext constraintContext) {
+ if (Strings.isNullOrEmpty(object.getRole())) {
+ boolean result = object.getVerbs() != null && !object.getVerbs().isEmpty();
+ LOG.trace("Validation result for permission with empty or no role: {}", result);
+ return result;
+ } else {
+ boolean result = object.getVerbs() == null || object.getVerbs().isEmpty();
+ LOG.trace("Validation result for permission with non empty role: {}", result);
+ return result;
+ }
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
index a3e7568957..634de9381c 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
@@ -8,6 +8,7 @@ import org.apache.shiro.SecurityUtils;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.group.GroupPermissions;
+import sonia.scm.repository.RepositoryRolePermissions;
import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.UserPermissions;
@@ -58,10 +59,13 @@ public class IndexDtoGenerator extends HalAppenderMapper {
if (PermissionPermissions.list().isPermitted()) {
builder.single(link("permissions", resourceLinks.permissions().self()));
}
- builder.single(link("availableRepositoryPermissions", resourceLinks.availableRepositoryPermissions().self()));
+ builder.single(link("repositoryVerbs", resourceLinks.repositoryVerbs().self()));
builder.single(link("repositoryTypes", resourceLinks.repositoryTypeCollection().self()));
builder.single(link("namespaceStrategies", resourceLinks.namespaceStrategies().self()));
+ if (RepositoryRolePermissions.read().isPermitted()) {
+ builder.single(link("repositoryRoles", resourceLinks.repositoryRoleCollection().self()));
+ }
} else {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
index c74f16ad70..cf09eeb128 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
@@ -28,6 +28,10 @@ public class MapperModule extends AbstractModule {
bind(RepositoryPermissionDtoToRepositoryPermissionMapper.class).to(Mappers.getMapper(RepositoryPermissionDtoToRepositoryPermissionMapper.class).getClass());
bind(RepositoryPermissionToRepositoryPermissionDtoMapper.class).to(Mappers.getMapper(RepositoryPermissionToRepositoryPermissionDtoMapper.class).getClass());
+ bind(RepositoryRoleToRepositoryRoleDtoMapper.class).to(Mappers.getMapper(RepositoryRoleToRepositoryRoleDtoMapper.class).getClass());
+ bind(RepositoryRoleDtoToRepositoryRoleMapper.class).to(Mappers.getMapper(RepositoryRoleDtoToRepositoryRoleMapper.class).getClass());
+ bind(RepositoryRoleCollectionToDtoMapper.class);
+
bind(ChangesetToChangesetDtoMapper.class).to(Mappers.getMapper(DefaultChangesetToChangesetDtoMapper.class).getClass());
bind(ChangesetToParentDtoMapper.class).to(Mappers.getMapper(ChangesetToParentDtoMapper.class).getClass());
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java
index 23d57f4d8e..8d48eafc82 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java
@@ -7,12 +7,15 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
+import javax.validation.constraints.NotNull;
+
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class PermissionListDto extends HalRepresentation {
+ @NotNull
private String[] permissions;
@Override
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
index b4d148b4bf..8b351aa46a 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
@@ -106,7 +106,7 @@ public class RepositoryCollectionResource {
private Repository createModelObjectFromDto(@Valid RepositoryDto repositoryDto) {
Repository repository = dtoToRepositoryMapper.map(repositoryDto, null);
- repository.setPermissions(singletonList(new RepositoryPermission(currentUser(), singletonList("*"), false)));
+ repository.setPermissions(singletonList(new RepositoryPermission(currentUser(), "OWNER", false)));
return repository;
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java
index fada89c44e..398e219207 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java
@@ -14,6 +14,7 @@ import javax.validation.constraints.Pattern;
import java.util.Collection;
@Getter @Setter @ToString @NoArgsConstructor
+@EitherRoleOrVerbs
public class RepositoryPermissionDto extends HalRepresentation {
public static final String GROUP_PREFIX = "@";
@@ -21,9 +22,11 @@ public class RepositoryPermissionDto extends HalRepresentation {
@Pattern(regexp = ValidationUtil.REGEX_NAME)
private String name;
- @NotEmpty
+ @NoBlankStrings
private Collection verbs;
+ private String role;
+
private boolean groupPermission = false;
public RepositoryPermissionDto(String permissionName, boolean groupPermission) {
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionResource.java
new file mode 100644
index 0000000000..99d8672eec
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionResource.java
@@ -0,0 +1,94 @@
+package sonia.scm.api.v2.resources;
+
+import com.webcohesion.enunciate.metadata.rs.ResponseCode;
+import com.webcohesion.enunciate.metadata.rs.ResponseHeader;
+import com.webcohesion.enunciate.metadata.rs.ResponseHeaders;
+import com.webcohesion.enunciate.metadata.rs.StatusCodes;
+import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleManager;
+import sonia.scm.web.VndMediaType;
+
+import javax.inject.Inject;
+import javax.validation.Valid;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Response;
+
+public class RepositoryRoleCollectionResource {
+
+ private static final int DEFAULT_PAGE_SIZE = 10;
+ private final RepositoryRoleDtoToRepositoryRoleMapper dtoToRepositoryRoleMapper;
+ private final RepositoryRoleCollectionToDtoMapper repositoryRoleCollectionToDtoMapper;
+ private final ResourceLinks resourceLinks;
+
+ private final IdResourceManagerAdapter adapter;
+
+ @Inject
+ public RepositoryRoleCollectionResource(RepositoryRoleManager manager, RepositoryRoleDtoToRepositoryRoleMapper dtoToRepositoryRoleMapper,
+ RepositoryRoleCollectionToDtoMapper repositoryRoleCollectionToDtoMapper, ResourceLinks resourceLinks) {
+ this.dtoToRepositoryRoleMapper = dtoToRepositoryRoleMapper;
+ this.repositoryRoleCollectionToDtoMapper = repositoryRoleCollectionToDtoMapper;
+ this.adapter = new IdResourceManagerAdapter<>(manager, RepositoryRole.class);
+ this.resourceLinks = resourceLinks;
+ }
+
+ /**
+ * Returns all repository roles for a given page number with a given page size (default page size is {@value DEFAULT_PAGE_SIZE}).
+ *
+ * Note: This method requires "repositoryRole" privilege.
+ *
+ * @param page the number of the requested page
+ * @param pageSize the page size (default page size is {@value DEFAULT_PAGE_SIZE})
+ * @param sortBy sort parameter (if empty - undefined sorting)
+ * @param desc sort direction desc or asc
+ */
+ @GET
+ @Path("")
+ @Produces(VndMediaType.REPOSITORY_ROLE_COLLECTION)
+ @TypeHint(CollectionDto.class)
+ @StatusCodes({
+ @ResponseCode(code = 200, condition = "success"),
+ @ResponseCode(code = 400, condition = "\"sortBy\" field unknown"),
+ @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
+ @ResponseCode(code = 403, condition = "not authorized, the current repositoryRole does not have the \"repositoryRole\" privilege"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
+ @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize,
+ @QueryParam("sortBy") String sortBy,
+ @DefaultValue("false") @QueryParam("desc") boolean desc
+ ) {
+ return adapter.getAll(page, pageSize, x -> true, sortBy, desc,
+ pageResult -> repositoryRoleCollectionToDtoMapper.map(page, pageSize, pageResult));
+ }
+
+ /**
+ * Creates a new repository role.
+ *
+ * Note: This method requires "repositoryRole" privilege.
+ *
+ * @param repositoryRole The repositoryRole to be created.
+ * @return A response with the link to the new repository role (if created successfully).
+ */
+ @POST
+ @Path("")
+ @Consumes(VndMediaType.REPOSITORY_ROLE)
+ @StatusCodes({
+ @ResponseCode(code = 201, condition = "create success"),
+ @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
+ @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"),
+ @ResponseCode(code = 409, condition = "conflict, a repository role with this name already exists"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ @TypeHint(TypeHint.NO_CONTENT.class)
+ @ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repositoryRole"))
+ public Response create(@Valid RepositoryRoleDto repositoryRole) {
+ return adapter.create(repositoryRole, () -> dtoToRepositoryRoleMapper.map(repositoryRole), u -> resourceLinks.repositoryRole().self(u.getName()));
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionToDtoMapper.java
new file mode 100644
index 0000000000..ab34efd7fe
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleCollectionToDtoMapper.java
@@ -0,0 +1,34 @@
+package sonia.scm.api.v2.resources;
+
+import sonia.scm.PageResult;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRolePermissions;
+
+import javax.inject.Inject;
+import java.util.Optional;
+
+import static java.util.Optional.empty;
+import static java.util.Optional.of;
+
+public class RepositoryRoleCollectionToDtoMapper extends BasicCollectionToDtoMapper {
+
+ private final ResourceLinks resourceLinks;
+
+ @Inject
+ public RepositoryRoleCollectionToDtoMapper(RepositoryRoleToRepositoryRoleDtoMapper repositoryRoleToDtoMapper, ResourceLinks resourceLinks) {
+ super("repositoryRoles", repositoryRoleToDtoMapper);
+ this.resourceLinks = resourceLinks;
+ }
+
+ public CollectionDto map(int pageNumber, int pageSize, PageResult pageResult) {
+ return map(pageNumber, pageSize, pageResult, this.createSelfLink(), this.createCreateLink());
+ }
+
+ Optional createCreateLink() {
+ return RepositoryRolePermissions.modify().isPermitted() ? of(resourceLinks.repositoryRoleCollection().create()): empty();
+ }
+
+ String createSelfLink() {
+ return resourceLinks.repositoryRoleCollection().self();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDto.java
new file mode 100644
index 0000000000..7840cad0ee
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDto.java
@@ -0,0 +1,29 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import java.time.Instant;
+import java.util.Collection;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class RepositoryRoleDto extends HalRepresentation {
+ @NotEmpty
+ private String name;
+ @NoBlankStrings @NotEmpty
+ private Collection verbs;
+ private String type;
+ private Instant creationDate;
+ private Instant lastModified;
+
+ RepositoryRoleDto(Links links, Embedded embedded) {
+ super(links, embedded);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDtoToRepositoryRoleMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDtoToRepositoryRoleMapper.java
new file mode 100644
index 0000000000..dc969b59b3
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleDtoToRepositoryRoleMapper.java
@@ -0,0 +1,14 @@
+package sonia.scm.api.v2.resources;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import sonia.scm.repository.RepositoryRole;
+
+// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
+@SuppressWarnings("squid:S3306")
+@Mapper
+public abstract class RepositoryRoleDtoToRepositoryRoleMapper extends BaseDtoMapper {
+
+ @Mapping(target = "creationDate", ignore = true)
+ public abstract RepositoryRole map(RepositoryRoleDto repositoryRoleDto);
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleResource.java
new file mode 100644
index 0000000000..59adbce264
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleResource.java
@@ -0,0 +1,103 @@
+package sonia.scm.api.v2.resources;
+
+import com.webcohesion.enunciate.metadata.rs.ResponseCode;
+import com.webcohesion.enunciate.metadata.rs.StatusCodes;
+import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleManager;
+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.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Response;
+
+public class RepositoryRoleResource {
+
+ private final RepositoryRoleDtoToRepositoryRoleMapper dtoToRepositoryRoleMapper;
+ private final RepositoryRoleToRepositoryRoleDtoMapper repositoryRoleToDtoMapper;
+
+ private final IdResourceManagerAdapter adapter;
+
+ @Inject
+ public RepositoryRoleResource(
+ RepositoryRoleDtoToRepositoryRoleMapper dtoToRepositoryRoleMapper,
+ RepositoryRoleToRepositoryRoleDtoMapper repositoryRoleToDtoMapper,
+ RepositoryRoleManager manager) {
+ this.dtoToRepositoryRoleMapper = dtoToRepositoryRoleMapper;
+ this.repositoryRoleToDtoMapper = repositoryRoleToDtoMapper;
+ this.adapter = new IdResourceManagerAdapter<>(manager, RepositoryRole.class);
+ }
+
+ /**
+ * Returns a repository role.
+ *
+ * Note: This method requires "repositoryRole" privilege.
+ *
+ * @param name the id/name of the repository role
+ */
+ @GET
+ @Path("")
+ @Produces(VndMediaType.REPOSITORY_ROLE)
+ @TypeHint(RepositoryRoleDto.class)
+ @StatusCodes({
+ @ResponseCode(code = 200, condition = "success"),
+ @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
+ @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository role"),
+ @ResponseCode(code = 404, condition = "not found, no repository role with the specified name available"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ public Response get(@PathParam("name") String name) {
+ return adapter.get(name, repositoryRoleToDtoMapper::map);
+ }
+
+ /**
+ * Deletes a repository role.
+ *
+ * Note: This method requires "repositoryRole" privilege.
+ *
+ * @param name the name of the repository role to delete.
+ */
+ @DELETE
+ @Path("")
+ @StatusCodes({
+ @ResponseCode(code = 204, condition = "delete success or nothing to delete"),
+ @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
+ @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ @TypeHint(TypeHint.NO_CONTENT.class)
+ public Response delete(@PathParam("name") String name) {
+ return adapter.delete(name);
+ }
+
+ /**
+ * Modifies the given repository role.
+ *
+ * Note: This method requires "repositoryRole" privilege.
+ *
+ * @param name name of the repository role to be modified
+ * @param repositoryRole repository role object to modify
+ */
+ @PUT
+ @Path("")
+ @Consumes(VndMediaType.REPOSITORY_ROLE)
+ @StatusCodes({
+ @ResponseCode(code = 204, condition = "update success"),
+ @ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of repository role name"),
+ @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
+ @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"),
+ @ResponseCode(code = 404, condition = "not found, no repository role with the specified name available"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ @TypeHint(TypeHint.NO_CONTENT.class)
+ public Response update(@PathParam("name") String name, @Valid RepositoryRoleDto repositoryRole) {
+ return adapter.update(name, existing -> dtoToRepositoryRoleMapper.map(repositoryRole));
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleRootResource.java
new file mode 100644
index 0000000000..44e3a10fba
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleRootResource.java
@@ -0,0 +1,34 @@
+package sonia.scm.api.v2.resources;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.ws.rs.Path;
+
+/**
+ * RESTful web service resource to manage repository roles.
+ */
+@Path(RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
+public class RepositoryRoleRootResource {
+
+ static final String REPOSITORY_ROLES_PATH_V2 = "v2/repositoryRoles/";
+
+ private final Provider repositoryRoleCollectionResource;
+ private final Provider repositoryRoleResource;
+
+ @Inject
+ public RepositoryRoleRootResource(Provider repositoryRoleCollectionResource,
+ Provider repositoryRoleResource) {
+ this.repositoryRoleCollectionResource = repositoryRoleCollectionResource;
+ this.repositoryRoleResource = repositoryRoleResource;
+ }
+
+ @Path("")
+ public RepositoryRoleCollectionResource getRepositoryRoleCollectionResource() {
+ return repositoryRoleCollectionResource.get();
+ }
+
+ @Path("{name}")
+ public RepositoryRoleResource getRepositoryRoleResource() {
+ return repositoryRoleResource.get();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleToRepositoryRoleDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleToRepositoryRoleDtoMapper.java
new file mode 100644
index 0000000000..43d364c1b3
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRoleToRepositoryRoleDtoMapper.java
@@ -0,0 +1,41 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.Links;
+import org.mapstruct.Mapper;
+import org.mapstruct.ObjectFactory;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRolePermissions;
+
+import javax.inject.Inject;
+
+import static de.otto.edison.hal.Embedded.embeddedBuilder;
+import static de.otto.edison.hal.Link.link;
+import static de.otto.edison.hal.Links.linkingTo;
+
+// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
+@SuppressWarnings("squid:S3306")
+@Mapper
+public abstract class RepositoryRoleToRepositoryRoleDtoMapper extends BaseMapper {
+
+ @Inject
+ private ResourceLinks resourceLinks;
+
+ @Override
+ public abstract RepositoryRoleDto map(RepositoryRole modelObject);
+
+ @ObjectFactory
+ RepositoryRoleDto createDto(RepositoryRole repositoryRole) {
+ Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryRole().self(repositoryRole.getName()));
+ if (!"system".equals(repositoryRole.getType()) && RepositoryRolePermissions.modify().isPermitted()) {
+ linksBuilder.single(link("delete", resourceLinks.repositoryRole().delete(repositoryRole.getName())));
+ linksBuilder.single(link("update", resourceLinks.repositoryRole().update(repositoryRole.getName())));
+ }
+
+ Embedded.Builder embeddedBuilder = embeddedBuilder();
+ applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repositoryRole);
+
+ return new RepositoryRoleDto(linksBuilder.build(), embeddedBuilder.build());
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbResource.java
similarity index 52%
rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java
rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbResource.java
index e5734085ca..4d4de067e5 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbResource.java
@@ -12,18 +12,18 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces;
/**
- * RESTful Web Service Resource to get available repository types.
+ * RESTful Web Service Resource to get available repository verbs.
*/
-@Path(RepositoryPermissionResource.PATH)
-public class RepositoryPermissionResource {
+@Path(RepositoryVerbResource.PATH)
+public class RepositoryVerbResource {
- static final String PATH = "v2/repositoryPermissions/";
+ static final String PATH = "v2/repositoryVerbs/";
private final RepositoryPermissionProvider repositoryPermissionProvider;
private final ResourceLinks resourceLinks;
@Inject
- public RepositoryPermissionResource(RepositoryPermissionProvider repositoryPermissionProvider, ResourceLinks resourceLinks) {
+ public RepositoryVerbResource(RepositoryPermissionProvider repositoryPermissionProvider, ResourceLinks resourceLinks) {
this.repositoryPermissionProvider = repositoryPermissionProvider;
this.resourceLinks = resourceLinks;
}
@@ -34,10 +34,11 @@ public class RepositoryPermissionResource {
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
- @Produces(VndMediaType.REPOSITORY_PERMISSION_COLLECTION)
- public AvailableRepositoryPermissionsDto get() {
- AvailableRepositoryPermissionsDto dto = new AvailableRepositoryPermissionsDto(repositoryPermissionProvider.availableVerbs(), repositoryPermissionProvider.availableRoles());
- dto.add(Links.linkingTo().self(resourceLinks.availableRepositoryPermissions().self()).build());
- return dto;
+ @Produces(VndMediaType.REPOSITORY_VERB_COLLECTION)
+ public RepositoryVerbsDto getAll() {
+ return new RepositoryVerbsDto(
+ Links.linkingTo().self(resourceLinks.repositoryVerbs().self()).build(),
+ repositoryPermissionProvider.availableVerbs()
+ );
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbsDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbsDto.java
new file mode 100644
index 0000000000..cbfa61c9ef
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryVerbsDto.java
@@ -0,0 +1,19 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+
+import java.util.Collection;
+
+public class RepositoryVerbsDto extends HalRepresentation {
+ private final Collection verbs;
+
+ public RepositoryVerbsDto(Links links, Collection verbs) {
+ super(links);
+ this.verbs = verbs;
+ }
+
+ public Collection getVerbs() {
+ return verbs;
+ }
+}
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 ff1013bb76..5e7e89a2e6 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
@@ -172,7 +172,6 @@ class ResourceLinks {
}
}
-
UserCollectionLinks userCollection() {
return new UserCollectionLinks(scmPathInfoStore.get());
}
@@ -522,8 +521,66 @@ class ResourceLinks {
public String content(String namespace, String name, String revision, String path) {
return addPath(sourceLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("content").parameters().method("get").parameters(revision, "").href(), path);
}
+ }
+ RepositoryVerbLinks repositoryVerbs() {
+ return new RepositoryVerbLinks(scmPathInfoStore.get());
+ }
+ static class RepositoryVerbLinks {
+ private final LinkBuilder repositoryVerbLinkBuilder;
+
+ RepositoryVerbLinks(ScmPathInfo pathInfo) {
+ repositoryVerbLinkBuilder = new LinkBuilder(pathInfo, RepositoryVerbResource.class);
+ }
+
+ String self() {
+ return repositoryVerbLinkBuilder.method("getAll").parameters().href();
+ }
+ }
+
+ RepositoryRoleLinks repositoryRole() {
+ return new RepositoryRoleLinks(scmPathInfoStore.get());
+ }
+
+ static class RepositoryRoleLinks {
+ private final LinkBuilder repositoryRoleLinkBuilder;
+
+ RepositoryRoleLinks(ScmPathInfo pathInfo) {
+ repositoryRoleLinkBuilder = new LinkBuilder(pathInfo, RepositoryRoleRootResource.class, RepositoryRoleResource.class);
+ }
+
+ String self(String name) {
+ return repositoryRoleLinkBuilder.method("getRepositoryRoleResource").parameters(name).method("get").parameters().href();
+ }
+
+ String delete(String name) {
+ return repositoryRoleLinkBuilder.method("getRepositoryRoleResource").parameters(name).method("delete").parameters().href();
+ }
+
+ String update(String name) {
+ return repositoryRoleLinkBuilder.method("getRepositoryRoleResource").parameters(name).method("update").parameters().href();
+ }
+ }
+
+ RepositoryRoleCollectionLinks repositoryRoleCollection() {
+ return new RepositoryRoleCollectionLinks(scmPathInfoStore.get());
+ }
+
+ static class RepositoryRoleCollectionLinks {
+ private final LinkBuilder collectionLinkBuilder;
+
+ RepositoryRoleCollectionLinks(ScmPathInfo pathInfo) {
+ collectionLinkBuilder = new LinkBuilder(pathInfo, RepositoryRoleRootResource.class, RepositoryRoleCollectionResource.class);
+ }
+
+ String self() {
+ return collectionLinkBuilder.method("getRepositoryRoleCollectionResource").parameters().method("getAll").parameters().href();
+ }
+
+ String create() {
+ return collectionLinkBuilder.method("getRepositoryRoleCollectionResource").parameters().method("create").parameters().href();
+ }
}
public RepositoryPermissionLinks repositoryPermission() {
@@ -669,20 +726,4 @@ class ResourceLinks {
return permissionsLinkBuilder.method("getAll").parameters().href();
}
}
-
- public AvailableRepositoryPermissionLinks availableRepositoryPermissions() {
- return new AvailableRepositoryPermissionLinks(scmPathInfoStore.get());
- }
-
- static class AvailableRepositoryPermissionLinks {
- private final LinkBuilder linkBuilder;
-
- AvailableRepositoryPermissionLinks(ScmPathInfo scmPathInfo) {
- this.linkBuilder = new LinkBuilder(scmPathInfo, RepositoryPermissionResource.class);
- }
-
- String self() {
- return linkBuilder.method("get").parameters().href();
- }
- }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java
index 2b02104646..a961dfaa0e 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java
@@ -8,6 +8,7 @@ import sonia.scm.security.PermissionDescriptor;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
+import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
@@ -69,7 +70,7 @@ public class UserPermissionResource {
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
- public Response overwritePermissions(@PathParam("id") String id, PermissionListDto newPermissions) {
+ public Response overwritePermissions(@PathParam("id") String id, @Valid PermissionListDto newPermissions) {
Collection permissionDescriptors = Arrays.stream(newPermissions.getPermissions())
.map(PermissionDescriptor::new)
.collect(Collectors.toList());
diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java
index b2dcbbba4a..3af8a76650 100644
--- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java
+++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java
@@ -39,14 +39,18 @@ import com.google.inject.Module;
import com.google.inject.assistedinject.FactoryModuleBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import sonia.scm.EagerSingletonModule;
import sonia.scm.SCMContext;
import sonia.scm.ScmContextListener;
+import sonia.scm.ScmEventBusModule;
+import sonia.scm.ScmInitializerModule;
import sonia.scm.Stage;
import sonia.scm.event.ScmEventBus;
import sonia.scm.plugin.DefaultPluginLoader;
import sonia.scm.plugin.Plugin;
import sonia.scm.plugin.PluginException;
import sonia.scm.plugin.PluginLoadException;
+import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginWrapper;
import sonia.scm.plugin.PluginsInternal;
import sonia.scm.plugin.SmpArchive;
@@ -134,6 +138,19 @@ public class BootstrapContextListener implements ServletContextListener {
File pluginDirectory = getPluginDirectory();
+ createContextListener(pluginDirectory);
+
+ contextListener.contextInitialized(sce);
+
+ // register for restart events
+ if (!registered && (SCMContext.getContext().getStage() == Stage.DEVELOPMENT)) {
+ logger.info("register for restart events");
+ ScmEventBus.getInstance().register(this);
+ registered = true;
+ }
+ }
+
+ private void createContextListener(File pluginDirectory) {
try {
if (!isCorePluginExtractionDisabled()) {
extractCorePlugins(context, pluginDirectory);
@@ -145,12 +162,9 @@ public class BootstrapContextListener implements ServletContextListener {
Set plugins = PluginsInternal.collectPlugins(cl, pluginDirectory.toPath());
- DefaultPluginLoader pluginLoader = new DefaultPluginLoader(context, cl, plugins);
+ PluginLoader pluginLoader = new DefaultPluginLoader(context, cl, plugins);
- Module scmContextListenerModule = new ScmContextListenerModule();
- BootstrapModule bootstrapModule = new BootstrapModule(pluginLoader);
-
- Injector bootstrapInjector = Guice.createInjector(bootstrapModule, scmContextListenerModule);
+ Injector bootstrapInjector = createBootstrapInjector(pluginLoader);
processUpdates(pluginLoader, bootstrapInjector);
@@ -158,19 +172,25 @@ public class BootstrapContextListener implements ServletContextListener {
} catch (IOException ex) {
throw new PluginLoadException("could not load plugins", ex);
}
-
- contextListener.contextInitialized(sce);
-
- // register for restart events
- if (!registered
- && (SCMContext.getContext().getStage() == Stage.DEVELOPMENT)) {
- logger.info("register for restart events");
- ScmEventBus.getInstance().register(this);
- registered = true;
- }
}
- private void processUpdates(DefaultPluginLoader pluginLoader, Injector bootstrapInjector) {
+ private Injector createBootstrapInjector(PluginLoader pluginLoader) {
+ Module scmContextListenerModule = new ScmContextListenerModule();
+ BootstrapModule bootstrapModule = new BootstrapModule(pluginLoader);
+ ScmInitializerModule scmInitializerModule = new ScmInitializerModule();
+ EagerSingletonModule eagerSingletonModule = new EagerSingletonModule();
+ ScmEventBusModule scmEventBusModule = new ScmEventBusModule();
+
+ return Guice.createInjector(
+ bootstrapModule,
+ scmContextListenerModule,
+ scmEventBusModule,
+ scmInitializerModule,
+ eagerSingletonModule
+ );
+ }
+
+ private void processUpdates(PluginLoader pluginLoader, Injector bootstrapInjector) {
Injector updateInjector = bootstrapInjector.createChildInjector(new UpdateStepModule(pluginLoader));
UpdateEngine updateEngine = updateInjector.getInstance(UpdateEngine.class);
@@ -390,7 +410,6 @@ public class BootstrapContextListener implements ServletContextListener {
private static class ScmContextListenerModule extends AbstractModule {
@Override
protected void configure() {
-
install(new FactoryModuleBuilder().build(ScmContextListener.Factory.class));
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapModule.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapModule.java
index 2c7098a951..57c05b9d21 100644
--- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapModule.java
@@ -9,7 +9,6 @@ import sonia.scm.SCMContext;
import sonia.scm.SCMContextProvider;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
-import sonia.scm.plugin.DefaultPluginLoader;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver;
@@ -33,7 +32,7 @@ public class BootstrapModule extends AbstractModule {
private final ClassOverrides overrides;
private final PluginLoader pluginLoader;
- BootstrapModule(DefaultPluginLoader pluginLoader) {
+ BootstrapModule(PluginLoader pluginLoader) {
this.overrides = ClassOverrides.findOverrides(pluginLoader.getUberClassLoader());
this.pluginLoader = pluginLoader;
}
diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryRoleManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryRoleManager.java
new file mode 100644
index 0000000000..ab0ad16d0e
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryRoleManager.java
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+
+
+
+package sonia.scm.repository;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.shiro.authz.UnauthorizedException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.EagerSingleton;
+import sonia.scm.HandlerEventType;
+import sonia.scm.ManagerDaoAdapter;
+import sonia.scm.NotFoundException;
+import sonia.scm.SCMContextProvider;
+import sonia.scm.security.RepositoryPermissionProvider;
+import sonia.scm.util.Util;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+@Singleton @EagerSingleton
+public class DefaultRepositoryRoleManager extends AbstractRepositoryRoleManager
+{
+
+ /** the logger for XmlRepositoryRoleManager */
+ private static final Logger logger =
+ LoggerFactory.getLogger(DefaultRepositoryRoleManager.class);
+
+ @Inject
+ public DefaultRepositoryRoleManager(RepositoryRoleDAO repositoryRoleDAO, RepositoryPermissionProvider repositoryPermissionProvider)
+ {
+ this.repositoryRoleDAO = repositoryRoleDAO;
+ this.managerDaoAdapter = new ManagerDaoAdapter<>(repositoryRoleDAO);
+ this.repositoryPermissionProvider = repositoryPermissionProvider;
+ }
+
+ @Override
+ public void close() {
+ // do nothing
+ }
+
+ @Override
+ public RepositoryRole create(RepositoryRole repositoryRole) {
+ assertNoSystemRole(repositoryRole);
+ String type = repositoryRole.getType();
+ if (Util.isEmpty(type)) {
+ repositoryRole.setType(repositoryRoleDAO.getType());
+ }
+
+ logger.info("create repositoryRole {} of type {}", repositoryRole.getName(), repositoryRole.getType());
+
+ return managerDaoAdapter.create(
+ repositoryRole,
+ RepositoryRolePermissions::modify,
+ newRepositoryRole -> fireEvent(HandlerEventType.BEFORE_CREATE, newRepositoryRole),
+ newRepositoryRole -> fireEvent(HandlerEventType.CREATE, newRepositoryRole)
+ );
+ }
+
+ @Override
+ public void delete(RepositoryRole repositoryRole) {
+ assertNoSystemRole(repositoryRole);
+ logger.info("delete repositoryRole {} of type {}", repositoryRole.getName(), repositoryRole.getType());
+ managerDaoAdapter.delete(
+ repositoryRole,
+ RepositoryRolePermissions::modify,
+ toDelete -> fireEvent(HandlerEventType.BEFORE_DELETE, toDelete),
+ toDelete -> fireEvent(HandlerEventType.DELETE, toDelete)
+ );
+ }
+
+ @Override
+ public void init(SCMContextProvider context) {
+ }
+
+ @Override
+ public void modify(RepositoryRole repositoryRole) {
+ assertNoSystemRole(repositoryRole);
+ logger.info("modify repositoryRole {} of type {}", repositoryRole.getName(), repositoryRole.getType());
+ managerDaoAdapter.modify(
+ repositoryRole,
+ x -> RepositoryRolePermissions.modify(),
+ notModified -> fireEvent(HandlerEventType.BEFORE_MODIFY, repositoryRole, notModified),
+ notModified -> fireEvent(HandlerEventType.MODIFY, repositoryRole, notModified));
+ }
+
+ @Override
+ public void refresh(RepositoryRole repositoryRole) {
+ logger.info("refresh repositoryRole {} of type {}", repositoryRole.getName(), repositoryRole.getType());
+
+ RepositoryRolePermissions.read().check();
+ RepositoryRole fresh = repositoryRoleDAO.get(repositoryRole.getName());
+
+ if (fresh == null) {
+ throw new NotFoundException(RepositoryRole.class, repositoryRole.getName());
+ }
+ }
+
+ @Override
+ public RepositoryRole get(String id) {
+ RepositoryRolePermissions.read().check();
+
+ return findSystemRole(id).orElse(findCustomRole(id));
+ }
+
+ private void assertNoSystemRole(RepositoryRole repositoryRole) {
+ if (findSystemRole(repositoryRole.getId()).isPresent()) {
+ throw new UnauthorizedException("system roles cannot be modified");
+ }
+ }
+
+ private RepositoryRole findCustomRole(String id) {
+ RepositoryRole repositoryRole = repositoryRoleDAO.get(id);
+
+ if (repositoryRole != null) {
+ return repositoryRole.clone();
+ } else {
+ return null;
+ }
+ }
+
+ private Optional findSystemRole(String id) {
+ return repositoryPermissionProvider
+ .availableRoles()
+ .stream()
+ .filter(role -> !repositoryRoleDAO.getType().equals(role.getType()))
+ .filter(role -> role.getName().equals(id)).findFirst();
+ }
+
+ @Override
+ public List getAll() {
+ List repositoryRoles = new ArrayList<>();
+
+ if (!RepositoryRolePermissions.read().isPermitted()) {
+ return Collections.emptyList();
+ }
+ for (RepositoryRole repositoryRole : repositoryPermissionProvider.availableRoles()) {
+ repositoryRoles.add(repositoryRole.clone());
+ }
+
+ return repositoryRoles;
+ }
+
+ @Override
+ public Collection getAll(Predicate filter, Comparator comparator) {
+ List repositoryRoles = getAll();
+
+ List filteredRoles = repositoryRoles.stream().filter(filter::test).collect(Collectors.toList());
+
+ if (comparator != null) {
+ filteredRoles.sort(comparator);
+ }
+
+ return filteredRoles;
+ }
+
+ @Override
+ public Collection getAll(Comparator comaparator, int start, int limit) {
+ return Util.createSubCollection(getAll(), comaparator,
+ (collection, item) -> {
+ collection.add(item.clone());
+ }, start, limit);
+ }
+
+ @Override
+ public Collection getAll(int start, int limit)
+ {
+ return getAll(null, start, limit);
+ }
+
+ @Override
+ public Long getLastModified()
+ {
+ return repositoryRoleDAO.getLastModified();
+ }
+
+ private final RepositoryRoleDAO repositoryRoleDAO;
+ private final ManagerDaoAdapter managerDaoAdapter;
+ private final RepositoryPermissionProvider repositoryPermissionProvider;
+}
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 f4efd3a307..baff3b951c 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java
@@ -52,7 +52,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
-import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupNames;
import sonia.scm.group.GroupPermissions;
import sonia.scm.plugin.Extension;
@@ -64,7 +63,6 @@ import sonia.scm.user.UserPermissions;
import sonia.scm.util.Util;
import java.util.Collection;
-import java.util.Set;
//~--- JDK imports ------------------------------------------------------------
@@ -90,18 +88,19 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
/**
* Constructs ...
- *
- * @param cacheManager
+ * @param cacheManager
* @param repositoryDAO
* @param securitySystem
+ * @param repositoryPermissionProvider
*/
@Inject
public DefaultAuthorizationCollector(CacheManager cacheManager,
- RepositoryDAO repositoryDAO, SecuritySystem securitySystem)
+ RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider)
{
this.cache = cacheManager.getCache(CACHE_NAME);
this.repositoryDAO = repositoryDAO;
this.securitySystem = securitySystem;
+ this.repositoryPermissionProvider = repositoryPermissionProvider;
}
//~--- methods --------------------------------------------------------------
@@ -201,16 +200,8 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
for (RepositoryPermission permission : repositoryPermissions)
{
hasPermission = isUserPermitted(user, groups, permission);
- if (hasPermission && !permission.getVerbs().isEmpty())
- {
- String perm = "repository:" + String.join(",", permission.getVerbs()) + ":" + repository.getId();
- if (logger.isTraceEnabled())
- {
- logger.trace("add repository permission {} for user {} at repository {}",
- perm, user.getName(), repository.getName());
- }
-
- builder.add(perm);
+ if (hasPermission) {
+ addRepositoryPermission(builder, repository, user, permission);
}
}
@@ -226,6 +217,34 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
}
}
+ private void addRepositoryPermission(Builder builder, Repository repository, User user, RepositoryPermission permission) {
+ Collection verbs = getVerbs(permission);
+ if (!verbs.isEmpty())
+ {
+ String perm = "repository:" + String.join(",", verbs) + ":" + repository.getId();
+ if (logger.isTraceEnabled())
+ {
+ logger.trace("add repository permission {} for user {} at repository {}",
+ perm, user.getName(), repository.getName());
+ }
+
+ builder.add(perm);
+ }
+ }
+
+ private Collection getVerbs(RepositoryPermission permission) {
+ return permission.getRole() == null? permission.getVerbs(): getVerbsForRole(permission.getRole());
+ }
+
+ private Collection getVerbsForRole(String roleName) {
+ return repositoryPermissionProvider.availableRoles()
+ .stream()
+ .filter(role -> roleName.equals(role.getName()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("unknown role: " + roleName))
+ .getVerbs();
+ }
+
private AuthorizationInfo createAuthorizationInfo(User user, GroupNames groups) {
Builder builder = ImmutableSet.builder();
@@ -353,4 +372,6 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
/** security system */
private final SecuritySystem securitySystem;
+
+ private final RepositoryPermissionProvider repositoryPermissionProvider;
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java b/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java
index 070990c6d6..bacd9fb637 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java
@@ -1,147 +1,42 @@
package sonia.scm.security;
import com.google.inject.Inject;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import sonia.scm.plugin.PluginLoader;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleDAO;
-import javax.xml.bind.JAXBContext;
-import javax.xml.bind.JAXBException;
-import javax.xml.bind.annotation.XmlAccessType;
-import javax.xml.bind.annotation.XmlAccessorType;
-import javax.xml.bind.annotation.XmlElement;
-import javax.xml.bind.annotation.XmlRootElement;
-import java.io.IOException;
-import java.net.URL;
-import java.util.ArrayList;
+import java.util.AbstractList;
import java.util.Collection;
-import java.util.Enumeration;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-import static java.util.Collections.unmodifiableCollection;
+import java.util.List ;
public class RepositoryPermissionProvider {
- private static final Logger logger = LoggerFactory.getLogger(RepositoryPermissionProvider.class);
- private static final String REPOSITORY_PERMISSION_DESCRIPTOR = "META-INF/scm/repository-permissions.xml";
- private final Collection availableVerbs;
- private final Collection availableRoles;
+ private final SystemRepositoryPermissionProvider systemRepositoryPermissionProvider;
+ private final RepositoryRoleDAO repositoryRoleDAO;
@Inject
- public RepositoryPermissionProvider(PluginLoader pluginLoader) {
- AvailableRepositoryPermissions availablePermissions = readAvailablePermissions(pluginLoader);
- this.availableVerbs = unmodifiableCollection(new LinkedHashSet<>(availablePermissions.availableVerbs));
- this.availableRoles = unmodifiableCollection(new LinkedHashSet<>(availablePermissions.availableRoles.stream().map(r -> new RepositoryRole(r.name, r.verbs.verbs)).collect(Collectors.toList())));
+ public RepositoryPermissionProvider(SystemRepositoryPermissionProvider systemRepositoryPermissionProvider, RepositoryRoleDAO repositoryRoleDAO) {
+ this.systemRepositoryPermissionProvider = systemRepositoryPermissionProvider;
+ this.repositoryRoleDAO = repositoryRoleDAO;
}
public Collection availableVerbs() {
- return availableVerbs;
+ return systemRepositoryPermissionProvider.availableVerbs();
}
public Collection availableRoles() {
- return availableRoles;
- }
+ List availableSystemRoles = systemRepositoryPermissionProvider.availableRoles();
+ List customRoles = repositoryRoleDAO.getAll();
- private static AvailableRepositoryPermissions readAvailablePermissions(PluginLoader pluginLoader) {
- Collection availableVerbs = new ArrayList<>();
- Collection availableRoles = new ArrayList<>();
-
- try {
- JAXBContext context =
- JAXBContext.newInstance(RepositoryPermissionsRoot.class);
-
- // Querying permissions from uberClassLoader returns also the permissions from plugin
- Enumeration descriptorEnum =
- pluginLoader.getUberClassLoader().getResources(REPOSITORY_PERMISSION_DESCRIPTOR);
-
- while (descriptorEnum.hasMoreElements()) {
- URL descriptorUrl = descriptorEnum.nextElement();
-
- logger.debug("read repository permission descriptor from {}", descriptorUrl);
-
- RepositoryPermissionsRoot repositoryPermissionsRoot = parsePermissionDescriptor(context, descriptorUrl);
- availableVerbs.addAll(repositoryPermissionsRoot.verbs.verbs);
- mergeRolesInto(availableRoles, repositoryPermissionsRoot.roles.roles);
+ return new AbstractList() {
+ @Override
+ public RepositoryRole get(int index) {
+ return index < availableSystemRoles.size()? availableSystemRoles.get(index): customRoles.get(index - availableSystemRoles.size());
}
- } catch (IOException ex) {
- logger.error("could not read permission descriptors", ex);
- } catch (JAXBException ex) {
- logger.error(
- "could not create jaxb context to read permission descriptors", ex);
- }
- return new AvailableRepositoryPermissions(availableVerbs, availableRoles);
- }
-
- private static void mergeRolesInto(Collection targetRoles, List additionalRoles) {
- additionalRoles.forEach(r -> addOrMergeInto(targetRoles, r));
- }
-
- private static void addOrMergeInto(Collection targetRoles, RoleDescriptor additionalRole) {
- Optional existingRole = targetRoles
- .stream()
- .filter(r -> r.name.equals(additionalRole.name))
- .findFirst();
- if (existingRole.isPresent()) {
- existingRole.get().verbs.verbs.addAll(additionalRole.verbs.verbs);
- } else {
- targetRoles.add(additionalRole);
- }
- }
-
- private static RepositoryPermissionsRoot parsePermissionDescriptor(JAXBContext context, URL descriptorUrl) {
- try {
- RepositoryPermissionsRoot descriptorWrapper =
- (RepositoryPermissionsRoot) context.createUnmarshaller().unmarshal(
- descriptorUrl);
- logger.trace("repository permissions from {}: {}", descriptorUrl, descriptorWrapper.verbs.verbs);
- logger.trace("repository roles from {}: {}", descriptorUrl, descriptorWrapper.roles.roles);
- return descriptorWrapper;
- } catch (JAXBException ex) {
- logger.error("could not parse permission descriptor", ex);
- return new RepositoryPermissionsRoot();
- }
- }
-
- private static class AvailableRepositoryPermissions {
- private final Collection availableVerbs;
- private final Collection availableRoles;
-
- private AvailableRepositoryPermissions(Collection availableVerbs, Collection availableRoles) {
- this.availableVerbs = unmodifiableCollection(availableVerbs);
- this.availableRoles = unmodifiableCollection(availableRoles);
- }
- }
-
- @XmlRootElement(name = "repository-permissions")
- @XmlAccessorType(XmlAccessType.FIELD)
- private static class RepositoryPermissionsRoot {
- private VerbListDescriptor verbs = new VerbListDescriptor();
- private RoleListDescriptor roles = new RoleListDescriptor();
- }
-
- @XmlRootElement(name = "verbs")
- private static class VerbListDescriptor {
- @XmlElement(name = "verb")
- private Set verbs = new LinkedHashSet<>();
- }
-
- @XmlRootElement(name = "roles")
- private static class RoleListDescriptor {
- @XmlElement(name = "role")
- private List roles = new ArrayList<>();
- }
-
- @XmlRootElement(name = "role")
- @XmlAccessorType(XmlAccessType.FIELD)
- public static class RoleDescriptor {
- @XmlElement(name = "name")
- private String name;
- @XmlElement(name = "verbs")
- private VerbListDescriptor verbs = new VerbListDescriptor();
+ @Override
+ public int size() {
+ return availableSystemRoles.size() + customRoles.size();
+ }
+ };
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java b/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java
deleted file mode 100644
index 1fab500d79..0000000000
--- a/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package sonia.scm.security;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Objects;
-
-public class RepositoryRole {
-
- private final String name;
- private final Collection verbs;
-
- public RepositoryRole(String name, Collection verbs) {
- this.name = name;
- this.verbs = verbs;
- }
-
- public String getName() {
- return name;
- }
-
- public Collection getVerbs() {
- return Collections.unmodifiableCollection(verbs);
- }
-
- public String toString() {
- return "Role " + name + " (" + String.join(", ", verbs) + ")";
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (!(o instanceof RepositoryRole)) return false;
- RepositoryRole that = (RepositoryRole) o;
- return name.equals(that.name)
- && this.verbs.containsAll(that.verbs)
- && this.verbs.size() == that.verbs.size();
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(name, verbs.size());
- }
-}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java b/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java
new file mode 100644
index 0000000000..0350698352
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java
@@ -0,0 +1,152 @@
+package sonia.scm.security;
+
+import com.google.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.plugin.PluginLoader;
+import sonia.scm.repository.RepositoryRole;
+
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static java.util.Collections.unmodifiableCollection;
+import static java.util.stream.Collectors.toList;
+
+class SystemRepositoryPermissionProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(SystemRepositoryPermissionProvider.class);
+ private static final String REPOSITORY_PERMISSION_DESCRIPTOR = "META-INF/scm/repository-permissions.xml";
+ private final List availableVerbs;
+ private final List availableRoles;
+
+ @Inject
+ public SystemRepositoryPermissionProvider(PluginLoader pluginLoader) {
+ AvailableRepositoryPermissions availablePermissions = readAvailablePermissions(pluginLoader);
+ this.availableVerbs = removeDuplicates(availablePermissions.availableVerbs);
+ this.availableRoles = removeDuplicates(availablePermissions.availableRoles.stream().map(r -> new RepositoryRole(r.name, r.verbs.verbs, "system")).collect(toList()));
+ }
+
+ public List availableVerbs() {
+ return availableVerbs;
+ }
+
+ public List availableRoles() {
+ return availableRoles;
+ }
+
+ private static AvailableRepositoryPermissions readAvailablePermissions(PluginLoader pluginLoader) {
+ Collection availableVerbs = new ArrayList<>();
+ Collection availableRoles = new ArrayList<>();
+
+ try {
+ JAXBContext context =
+ JAXBContext.newInstance(RepositoryPermissionsRoot.class);
+
+ // Querying permissions from uberClassLoader returns also the permissions from plugin
+ Enumeration descriptorEnum =
+ pluginLoader.getUberClassLoader().getResources(REPOSITORY_PERMISSION_DESCRIPTOR);
+
+ while (descriptorEnum.hasMoreElements()) {
+ URL descriptorUrl = descriptorEnum.nextElement();
+
+ logger.debug("read repository permission descriptor from {}", descriptorUrl);
+
+ RepositoryPermissionsRoot repositoryPermissionsRoot = parsePermissionDescriptor(context, descriptorUrl);
+ availableVerbs.addAll(repositoryPermissionsRoot.verbs.verbs);
+ mergeRolesInto(availableRoles, repositoryPermissionsRoot.roles.roles);
+ }
+ } catch (IOException ex) {
+ logger.error("could not read permission descriptors", ex);
+ } catch (JAXBException ex) {
+ logger.error(
+ "could not create jaxb context to read permission descriptors", ex);
+ }
+
+ return new AvailableRepositoryPermissions(availableVerbs, availableRoles);
+ }
+
+ private static void mergeRolesInto(Collection targetRoles, List additionalRoles) {
+ additionalRoles.forEach(r -> addOrMergeInto(targetRoles, r));
+ }
+
+ private static void addOrMergeInto(Collection targetRoles, RoleDescriptor additionalRole) {
+ Optional existingRole = targetRoles
+ .stream()
+ .filter(r -> r.name.equals(additionalRole.name))
+ .findFirst();
+ if (existingRole.isPresent()) {
+ existingRole.get().verbs.verbs.addAll(additionalRole.verbs.verbs);
+ } else {
+ targetRoles.add(additionalRole);
+ }
+ }
+
+ private static RepositoryPermissionsRoot parsePermissionDescriptor(JAXBContext context, URL descriptorUrl) {
+ try {
+ RepositoryPermissionsRoot descriptorWrapper =
+ (RepositoryPermissionsRoot) context.createUnmarshaller().unmarshal(
+ descriptorUrl);
+ logger.trace("repository permissions from {}: {}", descriptorUrl, descriptorWrapper.verbs.verbs);
+ logger.trace("repository roles from {}: {}", descriptorUrl, descriptorWrapper.roles.roles);
+ return descriptorWrapper;
+ } catch (JAXBException ex) {
+ logger.error("could not parse permission descriptor", ex);
+ return new RepositoryPermissionsRoot();
+ }
+ }
+
+ private static List removeDuplicates(Collection items) {
+ return items.stream().distinct().collect(toList());
+ }
+
+ private static class AvailableRepositoryPermissions {
+ private final Collection availableVerbs;
+ private final Collection availableRoles;
+
+ private AvailableRepositoryPermissions(Collection availableVerbs, Collection availableRoles) {
+ this.availableVerbs = unmodifiableCollection(availableVerbs);
+ this.availableRoles = unmodifiableCollection(availableRoles);
+ }
+ }
+
+ @XmlRootElement(name = "repository-permissions")
+ @XmlAccessorType(XmlAccessType.FIELD)
+ private static class RepositoryPermissionsRoot {
+ private VerbListDescriptor verbs = new VerbListDescriptor();
+ private RoleListDescriptor roles = new RoleListDescriptor();
+ }
+
+ @XmlRootElement(name = "verbs")
+ private static class VerbListDescriptor {
+ @XmlElement(name = "verb")
+ private Set verbs = new LinkedHashSet<>();
+ }
+
+ @XmlRootElement(name = "roles")
+ private static class RoleListDescriptor {
+ @XmlElement(name = "role")
+ private List roles = new ArrayList<>();
+ }
+
+ @XmlRootElement(name = "role")
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class RoleDescriptor {
+ @XmlElement(name = "name")
+ private String name;
+ @XmlElement(name = "verbs")
+ private VerbListDescriptor verbs = new VerbListDescriptor();
+ }
+}
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 27f343bc30..620fc484b1 100644
--- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml
+++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml
@@ -66,5 +66,8 @@
configuration:read,write:*
+
+ repositoryRole:read,write
+
diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json
index 41bb53de1e..0bdfa39b3e 100644
--- a/scm-webapp/src/main/resources/locales/de/plugins.json
+++ b/scm-webapp/src/main/resources/locales/de/plugins.json
@@ -60,6 +60,12 @@
}
}
},
+ "repositoryRole": {
+ "read,write": {
+ "displayName": "Benutzerdefinierte Repository-Rollen-Berechtigungen verwalten",
+ "description": "Kann benutzerdefinierte Rollen und deren Berechtigungen erstellen, ändern und löschen"
+ }
+ },
"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 0ad62ddf5c..4255f519ca 100644
--- a/scm-webapp/src/main/resources/locales/en/plugins.json
+++ b/scm-webapp/src/main/resources/locales/en/plugins.json
@@ -60,6 +60,12 @@
}
}
},
+ "repositoryRole": {
+ "read,write": {
+ "displayName": "Administer custom repository role permissions",
+ "description": "May create, modify and delete custom repository roles and their permissions"
+ }
+ },
"unknown": "Unknown permission"
},
"verbs": {
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java
index fb533ad280..e9ea0bead5 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java
@@ -2,8 +2,6 @@ package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.github.sdorra.shiro.ShiroRule;
-import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableList;
import com.google.inject.util.Providers;
import de.otto.edison.hal.HalRepresentation;
@@ -21,7 +19,6 @@ import org.jboss.resteasy.mock.MockHttpResponse;
import org.jboss.resteasy.spi.HttpRequest;
import org.junit.After;
import org.junit.Before;
-import org.junit.Rule;
import org.junit.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -35,6 +32,7 @@ import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.web.VndMediaType;
+import javax.ws.rs.HttpMethod;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
@@ -64,11 +62,6 @@ import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX;
@Slf4j
-@SubjectAware(
- username = "trillian",
- password = "secret",
- configuration = "classpath:sonia/scm/repository/shiro.ini"
-)
public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
private static final String REPOSITORY_NAMESPACE = "repo_namespace";
private static final String REPOSITORY_NAME = "repo";
@@ -114,9 +107,6 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
private Dispatcher dispatcher;
- @Rule
- public ShiroRule shiro = new ShiroRule();
-
@Mock
private RepositoryManager repositoryManager;
@@ -363,6 +353,69 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
assertGettingExpectedPermissions(expectedPermissions, PERMISSION_READ);
}
+ @Test
+ public void shouldCreateValidationErrorForMissingRoleAndEmptyVerbs() throws Exception {
+ createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
+ MockHttpResponse response = new MockHttpResponse();
+ HttpRequest request = MockHttpRequest
+ .create(HttpMethod.POST, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS)
+ .content("{ 'name' : 'permission_name', 'verbs' : [] }".replaceAll("'", "\"").getBytes())
+ .contentType(VndMediaType.REPOSITORY_PERMISSION);
+ dispatcher.invoke(request, response);
+ assertThat(response.getStatus()).isEqualTo(400);
+ assertThat(response.getContentAsString()).contains("permission must either have a role or a not empty set of verbs");
+ }
+
+ @Test
+ public void shouldCreateValidationErrorForEmptyRoleAndEmptyVerbs() throws Exception {
+ createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
+ MockHttpResponse response = new MockHttpResponse();
+ HttpRequest request = MockHttpRequest
+ .create(HttpMethod.POST, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS)
+ .content("{ 'name' : 'permission_name', 'role': '', 'verbs' : [] }".replaceAll("'", "\"").getBytes())
+ .contentType(VndMediaType.REPOSITORY_PERMISSION);
+ dispatcher.invoke(request, response);
+ assertThat(response.getStatus()).isEqualTo(400);
+ assertThat(response.getContentAsString()).contains("permission must either have a role or a not empty set of verbs");
+ }
+
+ @Test
+ public void shouldCreateValidationErrorForRoleAndVerbs() throws Exception {
+ createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
+ MockHttpResponse response = new MockHttpResponse();
+ HttpRequest request = MockHttpRequest
+ .create(HttpMethod.POST, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS)
+ .content("{ 'name' : 'permission_name', 'role': 'some role', 'verbs' : ['read'] }".replaceAll("'", "\"").getBytes())
+ .contentType(VndMediaType.REPOSITORY_PERMISSION);
+ dispatcher.invoke(request, response);
+ assertThat(response.getStatus()).isEqualTo(400);
+ assertThat(response.getContentAsString()).contains("permission must either have a role or a not empty set of verbs");
+ }
+
+ @Test
+ public void shouldPassWithoutValidationErrorForRoleAndEmptyVerbs() throws Exception {
+ createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
+ MockHttpResponse response = new MockHttpResponse();
+ HttpRequest request = MockHttpRequest
+ .create(HttpMethod.POST, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS)
+ .content("{ 'name' : 'permission_name', 'role': 'some role', 'verbs': [] }".replaceAll("'", "\"").getBytes())
+ .contentType(VndMediaType.REPOSITORY_PERMISSION);
+ dispatcher.invoke(request, response);
+ assertThat(response.getStatus()).isEqualTo(201);
+ }
+
+ @Test
+ public void shouldPassWithoutValidationErrorForRoleAndNoVerbs() throws Exception {
+ createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER);
+ MockHttpResponse response = new MockHttpResponse();
+ HttpRequest request = MockHttpRequest
+ .create(HttpMethod.POST, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS)
+ .content("{ 'name' : 'permission_name', 'role': 'some role' }".replaceAll("'", "\"").getBytes())
+ .contentType(VndMediaType.REPOSITORY_PERMISSION);
+ dispatcher.invoke(request, response);
+ assertThat(response.getStatus()).isEqualTo(201);
+ }
+
private void assertGettingExpectedPermissions(ImmutableList expectedPermissions, String userPermission) throws URISyntaxException {
assertExpectedRequest(requestGETAllPermissions
.expectedResponseStatus(200)
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRoleRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRoleRootResourceTest.java
new file mode 100644
index 0000000000..af968efd1f
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRoleRootResourceTest.java
@@ -0,0 +1,317 @@
+package sonia.scm.api.v2.resources;
+
+import com.github.sdorra.shiro.ShiroRule;
+import com.github.sdorra.shiro.SubjectAware;
+import com.google.inject.util.Providers;
+import org.jboss.resteasy.core.Dispatcher;
+import org.jboss.resteasy.mock.MockHttpRequest;
+import org.jboss.resteasy.mock.MockHttpResponse;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import sonia.scm.PageResult;
+import sonia.scm.api.rest.JSONContextResolver;
+import sonia.scm.api.rest.ObjectMapperProvider;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleManager;
+import sonia.scm.web.VndMediaType;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.util.Collections;
+
+import static java.net.URI.create;
+import static java.util.Arrays.asList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
+
+@SubjectAware(
+ username = "trillian",
+ password = "secret",
+ configuration = "classpath:sonia/scm/repository/shiro.ini"
+)
+@RunWith(MockitoJUnitRunner.Silent.class)
+public class RepositoryRoleRootResourceTest {
+
+ public static final String CUSTOM_ROLE = "customRole";
+ public static final String SYSTEM_ROLE = "systemRole";
+ public static final RepositoryRole CUSTOM_REPOSITORY_ROLE = new RepositoryRole(CUSTOM_ROLE, Collections.singleton("verb"), "xml");
+ public static final RepositoryRole SYSTEM_REPOSITORY_ROLE = new RepositoryRole(SYSTEM_ROLE, Collections.singleton("admin"), "system");
+ private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(create("/"));
+
+ @Rule
+ public ShiroRule shiroRule = new ShiroRule();
+
+ @Mock
+ private RepositoryRoleManager repositoryRoleManager;
+
+ @InjectMocks
+ private RepositoryRoleToRepositoryRoleDtoMapperImpl roleToDtoMapper;
+
+ @InjectMocks
+ private RepositoryRoleDtoToRepositoryRoleMapperImpl dtoToRoleMapper;
+
+ private RepositoryRoleCollectionToDtoMapper collectionToDtoMapper;
+
+ private Dispatcher dispatcher;
+
+ @Captor
+ private ArgumentCaptor modifyCaptor;
+ @Captor
+ private ArgumentCaptor createCaptor;
+ @Captor
+ private ArgumentCaptor deleteCaptor;
+
+ @Before
+ public void init() {
+ collectionToDtoMapper = new RepositoryRoleCollectionToDtoMapper(roleToDtoMapper, resourceLinks);
+
+ RepositoryRoleCollectionResource collectionResource = new RepositoryRoleCollectionResource(repositoryRoleManager, dtoToRoleMapper, collectionToDtoMapper, resourceLinks);
+ RepositoryRoleResource roleResource = new RepositoryRoleResource(dtoToRoleMapper, roleToDtoMapper, repositoryRoleManager);
+ RepositoryRoleRootResource rootResource = new RepositoryRoleRootResource(Providers.of(collectionResource), Providers.of(roleResource));
+
+ doNothing().when(repositoryRoleManager).modify(modifyCaptor.capture());
+ when(repositoryRoleManager.create(createCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]);
+ doNothing().when(repositoryRoleManager).delete(deleteCaptor.capture());
+
+ dispatcher = createDispatcher(rootResource);
+ dispatcher.getProviderFactory().registerProviderInstance(new JSONContextResolver(new ObjectMapperProvider().get()));
+
+ when(repositoryRoleManager.get(CUSTOM_ROLE)).thenReturn(CUSTOM_REPOSITORY_ROLE);
+ when(repositoryRoleManager.get(SYSTEM_ROLE)).thenReturn(SYSTEM_REPOSITORY_ROLE);
+ when(repositoryRoleManager.getPage(any(), any(), anyInt(), anyInt())).thenReturn(new PageResult<>(asList(CUSTOM_REPOSITORY_ROLE, SYSTEM_REPOSITORY_ROLE), 2));
+ }
+
+ @Test
+ public void shouldGetNotFoundForNotExistingRole() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + "noSuchRole");
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ @Test
+ public void shouldGetCustomRole() throws URISyntaxException, UnsupportedEncodingException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + CUSTOM_ROLE);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ assertThat(response.getContentAsString())
+ .contains(
+ "\"name\":\"" + CUSTOM_ROLE + "\"",
+ "\"verbs\":[\"verb\"]",
+ "\"self\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}",
+ "\"update\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}",
+ "\"delete\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}"
+ );
+ }
+
+ @Test
+ public void shouldGetSystemRole() throws URISyntaxException, UnsupportedEncodingException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + SYSTEM_ROLE);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ assertThat(response.getContentAsString())
+ .contains(
+ "\"name\":\"" + SYSTEM_ROLE + "\"",
+ "\"verbs\":[\"admin\"]",
+ "\"self\":{\"href\":\"/v2/repositoryRoles/" + SYSTEM_ROLE + "\"}"
+ )
+ .doesNotContain(
+ "\"delete\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}",
+ "\"update\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}"
+ );
+ }
+
+ @Test
+ @SubjectAware(username = "dent")
+ public void shouldNotGetDeleteLinkWithoutPermission() throws URISyntaxException, UnsupportedEncodingException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + CUSTOM_ROLE);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ assertThat(response.getContentAsString())
+ .doesNotContain("delete");
+ }
+
+ @Test
+ public void shouldUpdateRole() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .put("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + CUSTOM_ROLE)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': '" + CUSTOM_ROLE + "', 'verbs': ['write', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
+ verify(repositoryRoleManager).modify(any());
+ assertThat(modifyCaptor.getValue().getName()).isEqualTo(CUSTOM_ROLE);
+ assertThat(modifyCaptor.getValue().getVerbs()).containsExactly("write", "push");
+ }
+
+ @Test
+ public void shouldNotChangeRoleName() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .put("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + CUSTOM_ROLE)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': 'changedName', 'verbs': ['write', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
+ verify(repositoryRoleManager, never()).modify(any());
+ }
+
+ @Test
+ public void shouldFailForUpdateOfNotExistingRole() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .put("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + "noSuchRole")
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': 'noSuchRole', 'verbs': ['write', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND);
+ verify(repositoryRoleManager, never()).modify(any());
+ }
+
+ @Test
+ public void shouldCreateRole() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .post("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': 'newRole', 'verbs': ['write', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_CREATED);
+ verify(repositoryRoleManager).create(any());
+ assertThat(createCaptor.getValue().getName()).isEqualTo("newRole");
+ assertThat(createCaptor.getValue().getVerbs()).containsExactly("write", "push");
+ Object location = response.getOutputHeaders().getFirst("Location");
+ assertThat(location).isEqualTo(create("/v2/repositoryRoles/newRole"));
+ }
+
+ @Test
+ public void shouldDeleteRole() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .delete("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2 + CUSTOM_ROLE);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
+ verify(repositoryRoleManager).delete(any());
+ assertThat(deleteCaptor.getValue().getName()).isEqualTo(CUSTOM_ROLE);
+ }
+
+ @Test
+ public void shouldGetAllRoles() throws URISyntaxException, UnsupportedEncodingException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ assertThat(response.getContentAsString())
+ .contains(
+ "\"name\":\"" + CUSTOM_ROLE + "\"",
+ "\"name\":\"" + SYSTEM_ROLE + "\"",
+ "\"verbs\":[\"verb\"]",
+ "\"verbs\":[\"admin\"]",
+ "\"self\":{\"href\":\"/v2/repositoryRoles",
+ "\"delete\":{\"href\":\"/v2/repositoryRoles/" + CUSTOM_ROLE + "\"}",
+ "\"create\":{\"href\":\"/v2/repositoryRoles/\"}"
+ )
+ .doesNotContain(
+ "\"delete\":{\"href\":\"/v2/repositoryRoles/" + SYSTEM_ROLE + "\"}"
+ );
+ }
+
+ @Test
+ public void shouldFailForEmptyName() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .post("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': '', 'verbs': ['write', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
+ verify(repositoryRoleManager, never()).create(any());
+ }
+
+ @Test
+ public void shouldFailForMissingVerbs() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .post("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': 'ok', 'verbs': []}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
+ verify(repositoryRoleManager, never()).create(any());
+ }
+
+ @Test
+ public void shouldFailForEmptyVerb() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest
+ .post("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
+ .contentType(VndMediaType.REPOSITORY_ROLE)
+ .content(content("{'name': 'ok', 'verbs': ['', 'push']}"));
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
+ verify(repositoryRoleManager, never()).create(any());
+ }
+
+ @Test
+ @SubjectAware(username = "dent")
+ public void shouldNotGetCreateLinkWithoutPermission() throws URISyntaxException, UnsupportedEncodingException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ assertThat(response.getContentAsString())
+ .doesNotContain(
+ "create"
+ );
+ }
+
+ private byte[] content(String data) {
+ return data.replaceAll("'", "\"").getBytes();
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
index 55754469ec..c47250470d 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
@@ -332,7 +332,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
.hasSize(1)
.allSatisfy(p -> {
assertThat(p.getName()).isEqualTo("trillian");
- assertThat(p.getVerbs()).containsExactly("*");
+ assertThat(p.getRole()).isEqualTo("OWNER");
});
}
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 073e41a65e..6950d882f4 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
@@ -42,8 +42,9 @@ public class ResourceLinksMock {
when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(uriInfo));
when(resourceLinks.merge()).thenReturn(new ResourceLinks.MergeLinks(uriInfo));
when(resourceLinks.permissions()).thenReturn(new ResourceLinks.PermissionsLinks(uriInfo));
- when(resourceLinks.availableRepositoryPermissions()).thenReturn(new ResourceLinks.AvailableRepositoryPermissionLinks(uriInfo));
- when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo));
+ when(resourceLinks.repositoryVerbs()).thenReturn(new ResourceLinks.RepositoryVerbLinks(uriInfo));
+ when(resourceLinks.repositoryRole()).thenReturn(new ResourceLinks.RepositoryRoleLinks(uriInfo));
+ when(resourceLinks.repositoryRoleCollection()).thenReturn(new ResourceLinks.RepositoryRoleCollectionLinks(uriInfo));
when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(uriInfo));
return resourceLinks;
diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java
new file mode 100644
index 0000000000..cd0ba963b9
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryRoleManagerTest.java
@@ -0,0 +1,218 @@
+package sonia.scm.repository;
+
+import org.apache.shiro.authz.UnauthorizedException;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.subject.support.SubjectThreadState;
+import org.apache.shiro.util.ThreadContext;
+import org.apache.shiro.util.ThreadState;
+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 org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import sonia.scm.NotFoundException;
+import sonia.scm.ScmConstraintViolationException;
+import sonia.scm.security.RepositoryPermissionProvider;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class DefaultRepositoryRoleManagerTest {
+
+ private static final String CUSTOM_ROLE_NAME = "customRole";
+ private static final String SYSTEM_ROLE_NAME = "systemRole";
+ private static final RepositoryRole CUSTOM_ROLE = new RepositoryRole(CUSTOM_ROLE_NAME, singletonList("custom"), "xml");
+ private static final RepositoryRole SYSTEM_ROLE = new RepositoryRole(SYSTEM_ROLE_NAME, singletonList("system"), "system");
+
+ private final Subject subject = mock(Subject.class);
+ private final ThreadState subjectThreadState = new SubjectThreadState(subject);
+
+ @Mock
+ private RepositoryRoleDAO dao;
+ @Mock
+ private RepositoryPermissionProvider permissionProvider;
+
+ @InjectMocks
+ private DefaultRepositoryRoleManager manager;
+
+ @BeforeEach
+ void initUser() {
+ subjectThreadState.bind();
+ doAnswer(invocation -> {
+ String permission = invocation.getArguments()[0].toString();
+ if (!subject.isPermitted(permission)) {
+ throw new UnauthorizedException(permission);
+ }
+ return null;
+ }).when(subject).checkPermission(anyString());
+ ThreadContext.bind(subject);
+ }
+
+ @BeforeEach
+ void initDao() {
+ when(dao.getType()).thenReturn("xml");
+ }
+
+ @BeforeEach
+ void mockExistingRole() {
+ when(dao.get(CUSTOM_ROLE_NAME)).thenReturn(CUSTOM_ROLE);
+ when(permissionProvider.availableRoles()).thenReturn(asList(CUSTOM_ROLE, SYSTEM_ROLE));
+ }
+
+ @AfterEach
+ void cleanupContext() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Nested
+ class WithAuthorizedUser {
+
+ @BeforeEach
+ void authorizeUser() {
+ when(subject.isPermitted("repositoryRole:read")).thenReturn(true);
+ when(subject.isPermitted("repositoryRole:modify")).thenReturn(true);
+ }
+
+ @Test
+ void shouldReturnNull_forNotExistingRole() {
+ RepositoryRole role = manager.get("noSuchRole");
+ assertThat(role).isNull();
+ }
+
+ @Test
+ void shouldReturnRole_forExistingRole() {
+ RepositoryRole role = manager.get(CUSTOM_ROLE_NAME);
+ assertThat(role).isNotNull();
+ }
+
+ @Test
+ void shouldCreateRole() {
+ RepositoryRole role = manager.create(new RepositoryRole("new", singletonList("custom"), null));
+ assertThat(role.getType()).isEqualTo("xml");
+ verify(dao).add(role);
+ }
+
+ @Test
+ void shouldNotCreateRole_whenSystemRoleExists() {
+ assertThrows(UnauthorizedException.class, () -> manager.create(new RepositoryRole(SYSTEM_ROLE_NAME, singletonList("custom"), null)));
+ verify(dao, never()).add(any());
+ }
+
+ @Test
+ void shouldModifyRole() {
+ RepositoryRole role = new RepositoryRole(CUSTOM_ROLE_NAME, singletonList("changed"), "xml");
+ manager.modify(role);
+ verify(dao).modify(role);
+ }
+
+ @Test
+ void shouldNotModifyRole_whenTypeChanged() {
+ assertThrows(ScmConstraintViolationException.class, () -> manager.modify(new RepositoryRole(CUSTOM_ROLE_NAME, singletonList("changed"), null)));
+ verify(dao, never()).modify(any());
+ }
+
+ @Test
+ void shouldNotModifyRole_whenRoleDoesNotExists() {
+ assertThrows(NotFoundException.class, () -> manager.modify(new RepositoryRole("noSuchRole", singletonList("changed"), null)));
+ verify(dao, never()).modify(any());
+ }
+
+ @Test
+ void shouldNotModifyRole_whenSystemRoleExists() {
+ assertThrows(UnauthorizedException.class, () -> manager.modify(new RepositoryRole(SYSTEM_ROLE_NAME, singletonList("changed"), null)));
+ verify(dao, never()).modify(any());
+ }
+
+ @Test
+ void shouldReturnAllRoles() {
+ List allRoles = manager.getAll();
+ assertThat(allRoles).containsExactly(CUSTOM_ROLE, SYSTEM_ROLE);
+ }
+
+ @Test
+ void shouldReturnFilteredRoles() {
+ Collection allRoles = manager.getAll(role -> CUSTOM_ROLE_NAME.equals(role.getName()), null);
+ assertThat(allRoles).containsExactly(CUSTOM_ROLE);
+ }
+
+ @Test
+ void shouldReturnOrderedFilteredRoles() {
+ Collection allRoles =
+ manager.getAll(
+ role -> true,
+ Comparator.comparing(RepositoryRole::getType));
+ assertThat(allRoles).containsExactly(SYSTEM_ROLE, CUSTOM_ROLE);
+ }
+
+ @Test
+ void shouldReturnPaginatedRoles() {
+ Collection allRoles =
+ manager.getAll(
+ Comparator.comparing(RepositoryRole::getType),
+ 1, 1
+ );
+ assertThat(allRoles).containsExactly(CUSTOM_ROLE);
+ }
+ }
+
+ @Nested
+ class WithUnauthorizedUser {
+
+ @BeforeEach
+ void authorizeUser() {
+ when(subject.isPermitted(any(String.class))).thenReturn(false);
+ }
+
+ @Test
+ void shouldThrowException_forGet() {
+ assertThrows(UnauthorizedException.class, () -> manager.get("any"));
+ }
+
+ @Test
+ void shouldThrowException_forCreate() {
+ assertThrows(UnauthorizedException.class, () -> manager.create(new RepositoryRole("new", singletonList("custom"), null)));
+ verify(dao, never()).add(any());
+ }
+
+ @Test
+ void shouldThrowException_forModify() {
+ assertThrows(UnauthorizedException.class, () -> manager.modify(new RepositoryRole(CUSTOM_ROLE_NAME, singletonList("custom"), null)));
+ verify(dao, never()).modify(any());
+ }
+
+ @Test
+ void shouldReturnEmptyList() {
+ assertThat(manager.getAll()).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyFilteredList() {
+ assertThat(manager.getAll(x -> true, null)).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyPaginatedList() {
+ assertThat(manager.getAll(1, 1)).isEmpty();
+ }
+ }
+}
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 dc62119209..8e7cb8a70e 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java
@@ -33,10 +33,10 @@ 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;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.hamcrest.Matchers;
@@ -49,16 +49,19 @@ import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
-import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupNames;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryPermission;
+import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.user.User;
import sonia.scm.user.UserTestData;
+import java.util.Collections;
+
import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
@@ -90,6 +93,9 @@ public class DefaultAuthorizationCollectorTest {
@Mock
private SecuritySystem securitySystem;
+ @Mock
+ private RepositoryPermissionProvider repositoryPermissionProvider;
+
private DefaultAuthorizationCollector collector;
@Rule
@@ -101,11 +107,11 @@ public class DefaultAuthorizationCollectorTest {
@Before
public void setUp(){
when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache);
- collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem);
+ collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider);
}
/**
- * Tests {@link AuthorizationCollector#collect()} without user role.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} without user role.
*/
@Test
@SubjectAware
@@ -118,7 +124,7 @@ public class DefaultAuthorizationCollectorTest {
}
/**
- * Tests {@link AuthorizationCollector#collect()} from cache.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} from cache.
*/
@Test
@SubjectAware(
@@ -134,7 +140,7 @@ public class DefaultAuthorizationCollectorTest {
}
/**
- * Tests {@link AuthorizationCollector#collect()} with cache.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with cache.
*/
@Test
@SubjectAware(
@@ -148,7 +154,7 @@ public class DefaultAuthorizationCollectorTest {
}
/**
- * Tests {@link AuthorizationCollector#collect()} without permissions.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} without permissions.
*/
@Test
@SubjectAware(
@@ -165,7 +171,7 @@ public class DefaultAuthorizationCollectorTest {
}
/**
- * Tests {@link AuthorizationCollector#collect()} with repository permissions.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with repository permissions.
*/
@Test
@SubjectAware(
@@ -191,7 +197,76 @@ public class DefaultAuthorizationCollectorTest {
}
/**
- * Tests {@link AuthorizationCollector#collect()} with global permissions.
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} with repository roles.
+ */
+ @Test
+ @SubjectAware(
+ configuration = "classpath:sonia/scm/shiro-001.ini"
+ )
+ public void testCollectWithRepositoryRolePermissions() {
+ when(repositoryPermissionProvider.availableRoles()).thenReturn(
+ asList(
+ new RepositoryRole("user role", singletonList("user"), "xml"),
+ new RepositoryRole("group role", singletonList("group"), "xml"),
+ new RepositoryRole("system role", singletonList("system"), "system")
+ ));
+
+ String group = "heart-of-gold-crew";
+ authenticate(UserTestData.createTrillian(), group);
+ Repository heartOfGold = RepositoryTestData.createHeartOfGold();
+ heartOfGold.setId("one");
+ heartOfGold.setPermissions(Lists.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));
+
+ // 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:user:one",
+ "repository:system:one",
+ "repository:group:two",
+ "user:read:trillian"));
+ }
+
+ /**
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} with repository roles.
+ */
+ @Test(expected = IllegalStateException.class)
+ @SubjectAware(
+ configuration = "classpath:sonia/scm/shiro-001.ini"
+ )
+ public void testCollectWithUnknownRepositoryRole() {
+ when(repositoryPermissionProvider.availableRoles()).thenReturn(
+ singletonList(
+ new RepositoryRole("something", singletonList("something"), "xml")
+ ));
+
+ String group = "heart-of-gold-crew";
+ authenticate(UserTestData.createTrillian(), group);
+ Repository heartOfGold = RepositoryTestData.createHeartOfGold();
+ heartOfGold.setId("one");
+ heartOfGold.setPermissions(singletonList(
+ new RepositoryPermission("trillian", "unknown", false)
+ ));
+ when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold));
+
+ // execute and assert
+ AuthorizationInfo authInfo = collector.collect();
+ }
+
+ /**
+ * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with global permissions.
*/
@Test
@SubjectAware(
diff --git a/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java b/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java
index 8a8d85fdb2..30e264dc6c 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java
@@ -1,72 +1,51 @@
package sonia.scm.security;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import sonia.scm.plugin.PluginLoader;
-import sonia.scm.repository.RepositoryPermissions;
-import sonia.scm.util.ClassLoaders;
+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.RepositoryRole;
+import sonia.scm.repository.RepositoryRoleDAO;
-import java.lang.reflect.Field;
-import java.util.Arrays;
import java.util.Collection;
+import java.util.List;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
-import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+@ExtendWith(MockitoExtension.class)
class RepositoryPermissionProviderTest {
- private RepositoryPermissionProvider repositoryPermissionProvider;
- private String[] allVerbsFromRepositoryClass;
+ @Mock
+ SystemRepositoryPermissionProvider systemRepositoryPermissionProvider;
+ @Mock
+ RepositoryRoleDAO repositoryRoleDAO;
+ @InjectMocks
+ RepositoryPermissionProvider repositoryPermissionProvider;
- @BeforeEach
- void init() {
- PluginLoader pluginLoader = mock(PluginLoader.class);
- when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class));
- repositoryPermissionProvider = new RepositoryPermissionProvider(pluginLoader);
- allVerbsFromRepositoryClass = Arrays.stream(RepositoryPermissions.class.getDeclaredFields())
- .filter(field -> field.getName().startsWith("ACTION_"))
- .filter(field -> !field.getName().equals("ACTION_HEALTHCHECK"))
- .map(this::getString)
- .filter(verb -> !"create".equals(verb))
- .toArray(String[]::new);
+ @Test
+ void shouldReturnVerbsFromSystem() {
+ List expectedVerbs = asList("verb1", "verb2");
+ when(systemRepositoryPermissionProvider.availableVerbs()).thenReturn(expectedVerbs);
+
+ Collection actualVerbs = repositoryPermissionProvider.availableVerbs();
+
+ assertThat(actualVerbs).isEqualTo(expectedVerbs);
}
@Test
- void shouldReadAvailableRoles() {
- assertThat(repositoryPermissionProvider.availableRoles()).isNotEmpty();
- assertThat(repositoryPermissionProvider.availableRoles()).allSatisfy(this::containsOnlyAvailableVerbs);
- }
+ void shouldReturnJoinedRolesFromSystemAndDao() {
+ RepositoryRole systemRole = new RepositoryRole("roleSystem", singletonList("verb1"), "system");
+ RepositoryRole daoRole = new RepositoryRole("roleDao", singletonList("verb1"), "xml");
+ when(systemRepositoryPermissionProvider.availableRoles()).thenReturn(singletonList(systemRole));
+ when(repositoryRoleDAO.getAll()).thenReturn(singletonList(daoRole));
- private void containsOnlyAvailableVerbs(RepositoryRole role) {
- assertThat(role.getVerbs()).isSubsetOf(repositoryPermissionProvider.availableVerbs());
- }
+ Collection actualRoles = repositoryPermissionProvider.availableRoles();
- @Test
- void shouldReadAvailableVerbsFromRepository() {
- assertThat(repositoryPermissionProvider.availableVerbs()).contains(allVerbsFromRepositoryClass);
- }
-
- @Test
- void shouldMergeRepositoryRoles() {
- Collection verbsInMergedRole = repositoryPermissionProvider
- .availableRoles()
- .stream()
- .filter(r -> "READ".equals(r.getName()))
- .findFirst()
- .get()
- .getVerbs();
- assertThat(verbsInMergedRole).contains("read", "pull", "test");
- }
-
- private String getString(Field field) {
- try {
- return (String) field.get(null);
- } catch (IllegalAccessException e) {
- fail(e);
- return null;
- }
+ assertThat(actualRoles).containsExactly(systemRole, daoRole);
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/SystemRepositoryPermissionProviderTest.java b/scm-webapp/src/test/java/sonia/scm/security/SystemRepositoryPermissionProviderTest.java
new file mode 100644
index 0000000000..8d1685b926
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/SystemRepositoryPermissionProviderTest.java
@@ -0,0 +1,73 @@
+package sonia.scm.security;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import sonia.scm.plugin.PluginLoader;
+import sonia.scm.repository.RepositoryPermissions;
+import sonia.scm.repository.RepositoryRole;
+import sonia.scm.util.ClassLoaders;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collection;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SystemRepositoryPermissionProviderTest {
+
+ private SystemRepositoryPermissionProvider repositoryPermissionProvider;
+ private String[] allVerbsFromRepositoryClass;
+
+
+ @BeforeEach
+ void init() {
+ PluginLoader pluginLoader = mock(PluginLoader.class);
+ when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class));
+ repositoryPermissionProvider = new SystemRepositoryPermissionProvider(pluginLoader);
+ allVerbsFromRepositoryClass = Arrays.stream(RepositoryPermissions.class.getDeclaredFields())
+ .filter(field -> field.getName().startsWith("ACTION_"))
+ .filter(field -> !field.getName().equals("ACTION_HEALTHCHECK"))
+ .map(this::getString)
+ .filter(verb -> !"create".equals(verb))
+ .toArray(String[]::new);
+ }
+
+ @Test
+ void shouldReadAvailableRoles() {
+ assertThat(repositoryPermissionProvider.availableRoles()).isNotEmpty();
+ assertThat(repositoryPermissionProvider.availableRoles()).allSatisfy(this::containsOnlyAvailableVerbs);
+ }
+
+ private void containsOnlyAvailableVerbs(RepositoryRole role) {
+ assertThat(role.getVerbs()).isSubsetOf(repositoryPermissionProvider.availableVerbs());
+ }
+
+ @Test
+ void shouldReadAvailableVerbsFromRepository() {
+ assertThat(repositoryPermissionProvider.availableVerbs()).contains(allVerbsFromRepositoryClass);
+ }
+
+ @Test
+ void shouldMergeRepositoryRoles() {
+ Collection verbsInMergedRole = repositoryPermissionProvider
+ .availableRoles()
+ .stream()
+ .filter(r -> "READ".equals(r.getName()))
+ .findFirst()
+ .get()
+ .getVerbs();
+ assertThat(verbsInMergedRole).contains("read", "pull", "test");
+ }
+
+ private String getString(Field field) {
+ try {
+ return (String) field.get(null);
+ } catch (IllegalAccessException e) {
+ fail(e);
+ return null;
+ }
+ }
+}
diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini
index 500325faf3..bf434508bd 100644
--- a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini
+++ b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini
@@ -8,7 +8,7 @@ user = secret, user
[roles]
admin = *
-creator = repository:create
+creator = repository:create,repositoryRole:read
heartOfGold = "repository:read,modify,delete:hof"
puzzle42 = "repository:read,write:p42"
oss = "repository:pull"