diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d7302356..208e2f7d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Tags overview for repository [#1331](https://github.com/scm-manager/scm-manager/pull/1331) +- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335)) ### Fixed - Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328)) diff --git a/docs/de/user/repo/assets/repository-overview.png b/docs/de/user/repo/assets/repository-overview.png index 452af113c7..59cf55e4d2 100644 Binary files a/docs/de/user/repo/assets/repository-overview.png and b/docs/de/user/repo/assets/repository-overview.png differ diff --git a/docs/de/user/repo/index.md b/docs/de/user/repo/index.md index 2c501cf483..89b0b19eae 100644 --- a/docs/de/user/repo/index.md +++ b/docs/de/user/repo/index.md @@ -27,6 +27,8 @@ Icon | Beschreibung ![Repository Sources](assets/repository-overview-sources.png) | Öffnet die Sources-Übersicht für das Repository ![Repository Einstellungen](assets/repository-overview-settings.png) | Öffnet die Einstellungen für das Repository +Zusätzlich können über das Icon rechts neben den Überschriften für die Namespaces weitere Einstellungen auf Namespace-Ebene vorgenommen werden. + ### Repository erstellen Im SCM-Manager können neue Git, Mercurial & Subersion (SVN) Repositories über ein Formular angelegt werden. Dieses kann über den Button "Repository erstellen" aufgerufen werden. Dabei muss ein gültiger Name eingetragen und der Repository-Typ bestimmt werden. diff --git a/docs/de/user/repo/settings.md b/docs/de/user/repo/settings.md index e7f87b241f..124fcd8f0a 100644 --- a/docs/de/user/repo/settings.md +++ b/docs/de/user/repo/settings.md @@ -12,10 +12,12 @@ Innerhalb der Gefahrenzone unten auf der Seite gibt es mit entsprechenden Rechte ![Repository-Settings-General-Git](assets/repository-settings-general-git.png) ### Berechtigungen -Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen. +Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren Rollen oder auf individuellen Einstellungen, Rechte zugewiesen werden. Berechtigungen können global, auf Namespace-Ebene und auf Repository-Ebene vergeben werden. Globale Berechtigungen werden in der Administrations-Oberfläche des SCM-Managers vergeben. Unter diesem Eintrag handelt es sich um Repository-bezogene Berechtigungen. Die Berechtigungen können jeweils für Gruppen und für Benutzer vergeben werden. Dabei gibt es die Möglichkeiten die Berechtigungen über Berechtigungsrollen zu definieren oder jede Berechtigung einzeln zu vergeben. Die Berechtigungsrollen können in der Administrations-Oberfläche definiert werden. +Berechtigungen auf Namespace-Ebene können über die Einstellungen für Namespaces bearbeitet werden. Diese sind über das Einstellungs-Symbol neben den Namespace-Überschriften auf der Repository-Übersicht erreichbar. + ![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png) Für individuelle Berechtigungen kann man über "Erweitert" einen Dialog öffnen, um jede Berechtigung einzeln zu vergeben. diff --git a/docs/en/user/repo/assets/repository-overview.png b/docs/en/user/repo/assets/repository-overview.png index 452af113c7..59cf55e4d2 100644 Binary files a/docs/en/user/repo/assets/repository-overview.png and b/docs/en/user/repo/assets/repository-overview.png differ diff --git a/docs/en/user/repo/index.md b/docs/en/user/repo/index.md index 312cc9f75d..222a879543 100644 --- a/docs/en/user/repo/index.md +++ b/docs/en/user/repo/index.md @@ -25,6 +25,8 @@ Icon | Description ![Repository Sources](assets/repository-overview-sources.png) | Opens the sources overview for the repository ![Repository Settings](assets/repository-overview-settings.png) | Opens the settings for the repository +Clicking the icon on the right-hand side of each namespace caption, you can change additional settings for this namespace. + ### Create a Repository In SCM-Manager new Git, Mercurial & Subversion (SVN) repositories can be created via a form that can be accessed via the "Create Repository" button. A valid name and the repository type are mandatory. diff --git a/docs/en/user/repo/settings.md b/docs/en/user/repo/settings.md index 19d82f45d2..52e7da246e 100644 --- a/docs/en/user/repo/settings.md +++ b/docs/en/user/repo/settings.md @@ -12,10 +12,12 @@ In the danger zone at the bottom you may rename the repository or delete it. If ![Repository-Settings-General-Git](assets/repository-settings-general-git.png) ### Permissions -Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable roles or individual settings. Permissions can be granted globally or repository-specific. Global permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific permissions. +Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable roles or individual settings. Permissions can be granted globally, namespace-wide, or repository-specific. Global permissions are managed in the administration area of SCM-Manager. The following image shows repository-specific permissions. Permissions can be granted to groups or users. It is possible to manage each permission individually or to create roles that contain several permissions. Roles can be defined in the administration area. +Namespace-wide permissions can be configured in the namespace settings. These can be accessed via the settings icon on the right-hand side of the namespace heading in the repository overview. + ![Repository-Settings-PermissionOverview](assets/repository-settings-permissionOverview.png) To manage permissions individually, an "Advanced" dialog can be opened to manage every single permission. diff --git a/scm-core/src/main/java/sonia/scm/repository/Namespace.java b/scm-core/src/main/java/sonia/scm/repository/Namespace.java new file mode 100644 index 0000000000..8ad56c3b90 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/Namespace.java @@ -0,0 +1,130 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import com.github.sdorra.ssp.PermissionObject; +import com.github.sdorra.ssp.StaticPermissions; +import org.apache.commons.lang.builder.EqualsBuilder; +import org.apache.commons.lang.builder.HashCodeBuilder; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import static java.util.Collections.unmodifiableCollection; + +@StaticPermissions( + value = "namespace", + globalPermissions = {"permissionRead", "permissionWrite"}, + permissions = {}, + custom = true, customGlobal = true +) +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "namespaces") +public class Namespace implements PermissionObject, Cloneable { + + private String namespace; + private Set permissions = new HashSet<>(); + + public Namespace(String namespace) { + this.namespace = namespace; + } + + /** + * Constructor for JaxB, only. + */ + Namespace() { + } + + public String getNamespace() { + return namespace; + } + + public Collection getPermissions() { + return unmodifiableCollection(permissions); + } + + public void setPermissions(Collection permissions) { + this.permissions.clear(); + this.permissions.addAll(permissions); + } + + public void addPermission(RepositoryPermission newPermission) { + this.permissions.add(newPermission); + } + + public boolean removePermission(RepositoryPermission permission) { + return this.permissions.remove(permission); + } + + @Override + public String getId() { + return getNamespace(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof Namespace)) return false; + + Namespace namespace1 = (Namespace) o; + + return new EqualsBuilder() + .append(namespace, namespace1.namespace) + .append(permissions, namespace1.permissions) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(namespace) + .append(permissions) + .toHashCode(); + } + + @Override + public String toString() { + return "Namespace{" + + "namespace='" + namespace + '\'' + + ", permissions=" + permissions + + '}'; + } + + @Override + public Namespace clone() { + try { + Namespace clone = (Namespace) super.clone(); + clone.permissions = new HashSet<>(permissions); + return clone; + } catch (CloneNotSupportedException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java new file mode 100644 index 0000000000..8276d81aad --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceEvent.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + + +import sonia.scm.HandlerEventType; +import sonia.scm.event.AbstractHandlerEvent; +import sonia.scm.event.Event; + +/** + * The NamespaceEvent is fired if a {@link Namespace} object changes. + * + * @since 2.6.0 + */ +@Event +public class NamespaceEvent extends AbstractHandlerEvent { + + public NamespaceEvent(HandlerEventType eventType, Namespace namespace) { + super(eventType, namespace); + } + + public NamespaceEvent(HandlerEventType eventType, Namespace namespace, Namespace oldNamespace) { + super(eventType, namespace, oldNamespace); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java new file mode 100644 index 0000000000..01ad2815d6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceManager.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import java.util.Collection; +import java.util.Optional; + +/** + * Manages namespaces. Mind that namespaces do not have a lifecycle on their own, but only do exist through + * repositories. Therefore you cannot create or delete namespaces, but just change related settings like permissions + * associated with them. + * + * @since 2.6.0 + */ +public interface NamespaceManager { + + /** + * Returns the Namespace with the given name. + * + * @param namespace The name of the requested namespace. + * @return Optional with the namespace for the given name, or an empty Optional if there is no such namespace + * (that is, there is no repository with this namespace). + */ + Optional get(String namespace); + + /** + * Returns a {@link java.util.Collection} of all namespaces. + * + * @return all namespaces + */ + Collection getAll(); + + /** + * Modifies the given namespace. + * + * @param namespace The namespace to be modified. + */ + void modify(Namespace namespace); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java new file mode 100644 index 0000000000..e7bd25de1a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceModificationEvent.java @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.HandlerEventType; +import sonia.scm.ModificationHandlerEvent; +import sonia.scm.event.Event; + +/** + * Event which is fired whenever a namespace is modified. + * + * @since 2.6.0 + */ +@Event +public final class NamespaceModificationEvent extends NamespaceEvent implements ModificationHandlerEvent { + + private final Namespace itemBeforeModification; + + public NamespaceModificationEvent(HandlerEventType eventType, Namespace item, Namespace itemBeforeModification) { + super(eventType, item, itemBeforeModification); + this.itemBeforeModification = itemBeforeModification; + } + + @Override + public Namespace getItemBeforeModification() { + return itemBeforeModification; + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java index 1bd2bcd9d2..49ddbd4a27 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java @@ -21,13 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import org.apache.commons.collections.CollectionUtils; import sonia.scm.security.PermissionObject; import javax.xml.bind.annotation.XmlAccessType; @@ -118,12 +119,16 @@ public class RepositoryPermission implements PermissionObject, Serializable final RepositoryPermission other = (RepositoryPermission) obj; return Objects.equal(name, other.name) - && verbs.size() == other.verbs.size() - && verbs.containsAll(other.verbs) + && equalVerbs(other) && Objects.equal(role, other.role) && Objects.equal(groupPermission, other.groupPermission); } + public boolean equalVerbs(RepositoryPermission other) { + return verbs == null && other.verbs == null + || verbs != null && other.verbs != null && CollectionUtils.isEqualCollection(verbs, other.verbs); + } + /** * Returns the hash code value for the {@link RepositoryPermission}. * diff --git a/scm-ui/ui-components/src/CardColumnGroup.tsx b/scm-ui/ui-components/src/CardColumnGroup.tsx index c5c07055f2..d9a769a98c 100644 --- a/scm-ui/ui-components/src/CardColumnGroup.tsx +++ b/scm-ui/ui-components/src/CardColumnGroup.tsx @@ -27,7 +27,7 @@ import classNames from "classnames"; import styled from "styled-components"; type Props = { - name: string; + name: ReactNode; url?: string; elements: ReactNode[]; }; diff --git a/scm-ui/ui-components/src/urls.ts b/scm-ui/ui-components/src/urls.ts index 98bdb7137b..cae061b225 100644 --- a/scm-ui/ui-components/src/urls.ts +++ b/scm-ui/ui-components/src/urls.ts @@ -23,6 +23,7 @@ */ import queryString from "query-string"; +import { RouteComponentProps } from "react-router-dom"; //@ts-ignore export const contextPath = window.ctxPath || ""; @@ -80,3 +81,19 @@ function parsePageNumber(pageAsString: string) { export function getQueryStringFromLocation(location: any) { return location.search ? queryString.parse(location.search).q : undefined; } + +export function stripEndingSlash(url: string) { + if (url.endsWith("/")) { + return url.substring(0, url.length - 1); + } + return url; +} + +export function matchedUrlFromMatch(match: any) { + return stripEndingSlash(match.url); +} + +export function matchedUrl(props: RouteComponentProps) { + const match = props.match; + return matchedUrlFromMatch(match); +} diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 8a7a4fee97..02d7085892 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -58,5 +58,6 @@ export type NamespaceCollection = { export type RepositoryGroup = { name: string; + namespace?: Namespace; repositories: Repository[]; }; diff --git a/scm-ui/ui-webapp/public/locales/de/namespaces.json b/scm-ui/ui-webapp/public/locales/de/namespaces.json new file mode 100644 index 0000000000..c3cfdcb33b --- /dev/null +++ b/scm-ui/ui-webapp/public/locales/de/namespaces.json @@ -0,0 +1,14 @@ +{ + "namespaceRoot": { + "menu": { + "navigationLabel": "Namespace", + "settingsNavLink": "Einstellungen", + "permissionsNavLink": "Berechtigungen" + } + }, + "repositoryOverview": { + "settings": { + "tooltip": "Einstellungen für den Namespace" + } + } +} diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index b9d37f64b8..658cf86d8a 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -234,7 +234,8 @@ "renameRepo": { "button": "Repository umbenennen", "subtitle": "Benennt dieses Repository um", - "description": "Es werden keine Weiterleitung auf den neuen Namen eingerichtet.", + "description1": "Es werden keine Weiterleitung auf den neuen Namen eingerichtet.", + "description2": "Berechtigungen aus dem Namespace werden bei einem Wechsel nicht übernommen.", "modal": { "title": "Repository umbenennen", "label": { diff --git a/scm-ui/ui-webapp/public/locales/en/namespaces.json b/scm-ui/ui-webapp/public/locales/en/namespaces.json new file mode 100644 index 0000000000..a267c03dd7 --- /dev/null +++ b/scm-ui/ui-webapp/public/locales/en/namespaces.json @@ -0,0 +1,14 @@ +{ + "namespaceRoot": { + "menu": { + "navigationLabel": "Namespace", + "settingsNavLink": "Settings", + "permissionsNavLink": "Permissions" + } + }, + "repositoryOverview": { + "settings": { + "tooltip": "Namespace related settings" + } + } +} diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index c917ea3629..ddd889c512 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -234,7 +234,8 @@ "renameRepo": { "button": "Rename Repository", "subtitle": "Renames this repository", - "description": "There will be no redirects to the renamed repository.", + "description1": "There will be no redirects to the renamed repository.", + "description2": "Permissions from the namespace will not be adapted when the namespace is changed.", "modal": { "title": "Rename repository", "label": { diff --git a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx index 505a2760a1..98a308e4e3 100644 --- a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx +++ b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx @@ -45,6 +45,7 @@ import GlobalConfig from "./GlobalConfig"; import RepositoryRoles from "../roles/containers/RepositoryRoles"; import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole"; import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole"; +import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -54,30 +55,17 @@ type Props = RouteComponentProps & }; class Admin extends React.Component { - stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - if (url.includes("role")) { - return url.substring(0, url.length - 2); - } - return url.substring(0, url.length - 1); - } - return url; - }; - - matchedUrl = () => { - return this.stripEndingSlash(this.props.match.url); - }; matchesRoles = (route: any) => { - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const regex = new RegExp(`${url}/role/`); return route.location.pathname.match(regex); }; render() { - const { links, availablePluginsLink, installedPluginsLink, t } = this.props; + const { links, availablePluginsLink, installedPluginsLink, match, t } = this.props; - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { links, url diff --git a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx index 9829e91157..b6cbe6f144 100644 --- a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import { connect } from "react-redux"; -import { Route, withRouter } from "react-router-dom"; +import { Route, RouteComponentProps, withRouter } from "react-router-dom"; import { WithTranslation, withTranslation } from "react-i18next"; import { History } from "history"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; @@ -34,39 +34,29 @@ import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending import PermissionRoleDetail from "../components/PermissionRoleDetails"; import EditRepositoryRole from "./EditRepositoryRole"; import { compose } from "redux"; +import { urls } from "@scm-manager/ui-components"; -type Props = WithTranslation & { - roleName: string; - role: RepositoryRole; - loading: boolean; - error: Error; - repositoryRolesLink: string; - disabled: boolean; +type Props = WithTranslation & + RouteComponentProps & { + roleName: string; + role: RepositoryRole; + loading: boolean; + error: Error; + repositoryRolesLink: string; + disabled: boolean; - // dispatcher function - fetchRoleByName: (p1: string, p2: string) => void; + // dispatcher function + fetchRoleByName: (p1: string, p2: string) => void; - // context objects - match: any; - history: History; -}; + // context objects + 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; @@ -80,7 +70,7 @@ class SingleRepositoryRole extends React.Component { return ; } - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { role, diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 48e6f37a76..e0af6ee867 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -46,6 +46,7 @@ import CreateGroup from "../groups/containers/CreateGroup"; import Admin from "../admin/containers/Admin"; import Profile from "./Profile"; +import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot"; type Props = { me: Me; @@ -80,6 +81,7 @@ class Main extends React.Component { + diff --git a/scm-ui/ui-webapp/src/containers/Profile.tsx b/scm-ui/ui-webapp/src/containers/Profile.tsx index 4fe7bd4b61..d1d8a065f3 100644 --- a/scm-ui/ui-webapp/src/containers/Profile.tsx +++ b/scm-ui/ui-webapp/src/containers/Profile.tsx @@ -44,6 +44,7 @@ import ProfileInfo from "./ProfileInfo"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys"; import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink"; +import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -54,17 +55,6 @@ type Props = RouteComponentProps & }; class Profile extends React.Component { - stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - return url.substring(0, url.length - 2); - } - return url; - }; - - matchedUrl = () => { - return this.stripEndingSlash(this.props.match.url); - }; - mayChangePassword = () => { const { me } = this.props; return !!me?._links?.password; @@ -76,7 +66,7 @@ class Profile extends React.Component { }; render() { - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const { me, t } = this.props; diff --git a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx index b38974cc0e..1b7505f71f 100644 --- a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx @@ -45,6 +45,7 @@ import { Details } from "./../components/table"; import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks"; import EditGroup from "./EditGroup"; import SetPermissions from "../../permissions/components/SetPermissions"; +import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -63,17 +64,6 @@ class SingleGroup extends React.Component { this.props.fetchGroupByName(this.props.groupLink, this.props.name); } - 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, group } = this.props; @@ -85,7 +75,7 @@ class SingleGroup extends React.Component { return ; } - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { group, diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx index 713796cffa..cb2847a712 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx @@ -31,6 +31,7 @@ import { fetchBranch, getBranch, getFetchBranchFailure, isFetchBranchPending } f import { ErrorNotification, Loading, NotFoundError } from "@scm-manager/ui-components"; import { History } from "history"; import queryString from "query-string"; +import { urls } from "@scm-manager/ui-components"; type Props = { repository: Repository; @@ -54,21 +55,10 @@ class BranchRoot extends React.Component { fetchBranch(repository, branchName); } - stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - return url.substring(0, url.length - 1); - } - return url; - }; - - matchedUrl = () => { - return this.stripEndingSlash(this.props.match.url); - }; - render() { const { repository, branch, loading, error, match, location } = this.props; - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); if (error) { if (error instanceof NotFoundError && queryString.parse(location.search).create === "true") { diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx index 146b007cfc..936c5c2ebb 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryGroupEntry.tsx @@ -22,21 +22,37 @@ * SOFTWARE. */ import React from "react"; +import { Link } from "react-router-dom"; import { CardColumnGroup, RepositoryEntry } from "@scm-manager/ui-components"; import { RepositoryGroup } from "@scm-manager/ui-types"; +import { Icon } from "@scm-manager/ui-components"; +import { WithTranslation, withTranslation } from "react-i18next"; -type Props = { +type Props = WithTranslation & { group: RepositoryGroup; }; class RepositoryGroupEntry extends React.Component { render() { - const { group } = this.props; + const { group, t } = this.props; + const settingsLink = group.namespace?._links?.permissions && ( + + + + ); + const namespaceHeader = ( + <> + + {group.name} + {" "} + {settingsLink} + + ); const entries = group.repositories.map((repository, index) => { return ; }); - return ; + return ; } } -export default RepositoryGroupEntry; +export default withTranslation("namespaces")(RepositoryGroupEntry); diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index 2c76c48e45..fc9e539ab2 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -23,20 +23,21 @@ */ import React from "react"; -import { Repository } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository } from "@scm-manager/ui-types"; import groupByNamespace from "./groupByNamespace"; import RepositoryGroupEntry from "./RepositoryGroupEntry"; type Props = { repositories: Repository[]; + namespaces: NamespaceCollection; }; class RepositoryList extends React.Component { render() { - const { repositories } = this.props; + const { repositories, namespaces } = this.props; - const groups = groupByNamespace(repositories); + const groups = groupByNamespace(repositories, namespaces); return (
{groups.map(group => { diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts index e895053f88..4a917d15e4 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.test.ts @@ -73,21 +73,29 @@ it("should group the repositories by their namespace", () => { hitchhikerHeartOfGold, hitchhikerPuzzle42 ]; + const namespaces = { + _embedded: { + namespaces: [{ namespace: "hitchhiker" }, { namespace: "slarti" }, { namespace: "zaphod" }] + } + }; const expected = [ { name: "hitchhiker", + namespace: { namespace: "hitchhiker" }, repositories: [hitchhikerHeartOfGold, hitchhikerPuzzle42, hitchhikerRestand] }, { name: "slarti", + namespace: { namespace: "slarti" }, repositories: [slartiFjords, slartiBlueprintsFjords] }, { name: "zaphod", + namespace: { namespace: "zaphod" }, repositories: [zaphodMarvinFirmware] } ]; - expect(groupByNamespace(repositories)).toEqual(expected); + expect(groupByNamespace(repositories, namespaces)).toEqual(expected); }); diff --git a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts index 6e4cd90177..f8b04a51c2 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts +++ b/scm-ui/ui-webapp/src/repos/components/list/groupByNamespace.ts @@ -22,17 +22,22 @@ * SOFTWARE. */ -import { Repository, RepositoryGroup } from "@scm-manager/ui-types"; +import { NamespaceCollection, Repository, RepositoryGroup } from "@scm-manager/ui-types"; -export default function groupByNamespace(repositories: Repository[]): RepositoryGroup[] { +export default function groupByNamespace( + repositories: Repository[], + namespaces: NamespaceCollection +): RepositoryGroup[] { const groups = {}; for (const repository of repositories) { const groupName = repository.namespace; let group = groups[groupName]; if (!group) { + const namespace = findNamespace(namespaces, groupName); group = { name: groupName, + namespace: namespace, repositories: [] }; groups[groupName] = group; @@ -58,3 +63,7 @@ function sortByName(a, b) { } return 0; } + +function findNamespace(namespaces: NamespaceCollection, namespaceToFind: string) { + return namespaces._embedded.namespaces.find(namespace => namespace.namespace === namespaceToFind); +} diff --git a/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx index 5bc0e0d6e7..62ecd0adb3 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx @@ -28,6 +28,7 @@ import { Repository, Branch } from "@scm-manager/ui-types"; import Changesets from "./Changesets"; import { compose } from "redux"; import CodeActionBar from "../codeSection/components/CodeActionBar"; +import { urls } from "@scm-manager/ui-components"; type Props = WithTranslation & RouteComponentProps & { @@ -38,13 +39,6 @@ type Props = WithTranslation & }; class ChangesetsRoot extends React.Component { - stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - return url.substring(0, url.length - 1); - } - return url; - }; - isBranchAvailable = () => { const { branches, selectedBranch } = this.props; return branches?.filter(b => b.name === selectedBranch).length === 0; @@ -75,7 +69,7 @@ class ChangesetsRoot extends React.Component { return null; } - const url = this.stripEndingSlash(match.url); + const url = urls.stripEndingSlash(match.url); return ( <> diff --git a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx index 3844717487..0b2bda04b9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx @@ -76,7 +76,7 @@ const DangerZone: FC = ({ repository, indexLinks }) => { <>
- {dangerZone.map(entry => entry)} + {dangerZone} ); }; diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index e4d16dcfa8..f2334c5d7e 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -23,7 +23,7 @@ */ import React from "react"; import { connect } from "react-redux"; -import { withRouter } from "react-router-dom"; +import { RouteComponentProps, withRouter } from "react-router-dom"; import RepositoryForm from "../components/form"; import { Repository, Links } from "@scm-manager/ui-types"; import { getModifyRepoFailure, isModifyRepoPending, modifyRepo, modifyRepoReset } from "../modules/repos"; @@ -33,8 +33,9 @@ import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { compose } from "redux"; import DangerZone from "./DangerZone"; import { getLinks } from "../../modules/indexResource"; +import { urls } from "@scm-manager/ui-components"; -type Props = { +type Props = RouteComponentProps & { loading: boolean; error: Error; indexLinks: Links; @@ -45,7 +46,6 @@ type Props = { // context props repository: Repository; history: History; - match: any; }; class EditRepo extends React.Component { @@ -59,21 +59,10 @@ class EditRepo extends React.Component { history.push(`/repo/${repository.namespace}/${repository.name}`); }; - 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 { loading, error, repository, indexLinks } = this.props; - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { repository, diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 28343e57fe..0dae2f03d9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -133,12 +133,12 @@ class Overview extends React.Component { } renderRepositoryList() { - const { collection, page, location, t } = this.props; + const { collection, page, location, namespaces, t } = this.props; if (collection._embedded && collection._embedded.repositories.length > 0) { return ( <> - + ); diff --git a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx index 9254774a6d..0860e84156 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx @@ -159,7 +159,9 @@ const RenameRepository: FC = ({ repository, indexLinks }) => {

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

} right={ diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index f3ef73bf36..467bdcbecf 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -56,6 +56,7 @@ import SourceExtensions from "../sources/containers/SourceExtensions"; import { FileControlFactory, JumpToFileButton } from "@scm-manager/ui-components"; import TagsOverview from "../tags/container/TagsOverview"; import TagRoot from "../tags/container/TagRoot"; +import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -84,31 +85,20 @@ class RepositoryRoot extends React.Component { } } - stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - return url.substring(0, url.length - 1); - } - return url; - }; - - matchedUrl = () => { - return this.stripEndingSlash(this.props.match.url); - }; - matchesBranches = (route: any) => { - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const regex = new RegExp(`${url}/branch/.+/info`); return route.location.pathname.match(regex); }; matchesTags = (route: any) => { - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const regex = new RegExp(`${url}/tag/.+/info`); return route.location.pathname.match(regex); }; matchesCode = (route: any) => { - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const regex = new RegExp(`${url}(/code)/.*`); return route.location.pathname.match(regex); }; @@ -126,7 +116,7 @@ class RepositoryRoot extends React.Component { evaluateDestinationForCodeLink = () => { const { repository } = this.props; - const url = `${this.matchedUrl()}/code`; + const url = `${urls.matchedUrl(this.props)}/code`; if (repository?._links?.sources) { return `${url}/sources/`; } @@ -146,7 +136,7 @@ class RepositoryRoot extends React.Component { return ; } - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { repository, diff --git a/scm-ui/ui-webapp/src/repos/modules/repos.ts b/scm-ui/ui-webapp/src/repos/modules/repos.ts index a4dc9d2e8c..48cb02ba05 100644 --- a/scm-ui/ui-webapp/src/repos/modules/repos.ts +++ b/scm-ui/ui-webapp/src/repos/modules/repos.ts @@ -25,7 +25,7 @@ import { apiClient } from "@scm-manager/ui-components"; import * as types from "../../modules/types"; import { - Action, + Action, Namespace, NamespaceCollection, Repository, RepositoryCollection, @@ -66,6 +66,11 @@ export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`; +export const FETCH_NAMESPACE = "scm/repos/FETCH_NAMESPACE"; +export const FETCH_NAMESPACE_PENDING = `${FETCH_NAMESPACE}_${types.PENDING_SUFFIX}`; +export const FETCH_NAMESPACE_SUCCESS = `${FETCH_NAMESPACE}_${types.SUCCESS_SUFFIX}`; +export const FETCH_NAMESPACE_FAILURE = `${FETCH_NAMESPACE}_${types.FAILURE_SUFFIX}`; + export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2"; export const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy"; @@ -388,6 +393,50 @@ export function deleteRepoFailure(repository: Repository, error: Error): Action }; } +export function fetchNamespace(link: string, namespaceName: string) { + return function(dispatch: any) { + dispatch(fetchNamespacePending(namespaceName)); + return apiClient + .get(link) + .then(response => response.json()) + .then(namespace => { + dispatch(fetchNamespaceSuccess(namespace)); + }) + .catch(err => { + dispatch(fetchNamespaceFailure(namespaceName, err)); + }); + }; +} + +export function fetchNamespacePending(namespaceName: string): Action { + return { + type: FETCH_NAMESPACE_PENDING, + payload: { + namespaceName + }, + itemId: namespaceName + }; +} + +export function fetchNamespaceSuccess(namespace: Namespace): Action { + return { + type: FETCH_NAMESPACE_SUCCESS, + payload: namespace, + itemId: namespace.namespace + }; +} + +export function fetchNamespaceFailure(namespaceName: string, error: Error): Action { + return { + type: FETCH_NAMESPACE_FAILURE, + payload: { + namespaceName, + error + }, + itemId: namespaceName + }; +} + // reducer function createIdentifier(repository: Repository) { @@ -425,6 +474,17 @@ const reducerByNames = (state: object, repository: Repository) => { }; }; +const reducerForNamespace = (state: object, namespace: Namespace) => { + const identifier = namespace.namespace; + return { + ...state, + namespacesByNames: { + ...state.namespacesByNames, + [identifier]: namespace + } + }; +}; + const reducerForNamespaces = (state: object, namespaces: NamespaceCollection) => { return { ...state, @@ -449,6 +509,8 @@ export default function reducer( return reducerForNamespaces(state, action.payload); case FETCH_REPO_SUCCESS: return reducerByNames(state, action.payload); + case FETCH_NAMESPACE_SUCCESS: + return reducerForNamespace(state, action.payload); default: return state; } @@ -497,10 +559,17 @@ export function getFetchNamespacesFailure(state: object) { return getFailure(state, FETCH_NAMESPACES); } -export function getNamespace(state: object, namespace: string) { - if (state.namespaces) { - return state.namespaces[namespace]; - } +export function isFetchNamespacePending(state: object) { + return isPending(state, FETCH_NAMESPACE); +} + +export function getFetchNamespaceFailure(state: object) { + return getFailure(state, FETCH_NAMESPACE); +} + +export function fetchNamespaceByName(link: string, namespaceName: string) { + const namespaceUrl = link.endsWith("/") ? link : link + "/"; + return fetchNamespace(`${namespaceUrl}${namespaceName}`, namespaceName); } export function isFetchRepoPending(state: object, namespace: string, name: string) { @@ -511,6 +580,12 @@ export function getFetchRepoFailure(state: object, namespace: string, name: stri return getFailure(state, FETCH_REPO, namespace + "/" + name); } +export function getNamespace(state: object, namespaceName: string) { + if (state.repos && state.repos.namespacesByNames) { + return state.repos.namespacesByNames[namespaceName]; + } +} + export function isAbleToCreateRepos(state: object) { return !!(state.repos && state.repos.list && state.repos.list._links && state.repos.list._links.create); } @@ -539,7 +614,12 @@ export function getDeleteRepoFailure(state: object, namespace: string, name: str return getFailure(state, DELETE_REPO, namespace + "/" + name); } -export function getPermissionsLink(state: object, namespace: string, name: string) { - const repo = getRepository(state, namespace, name); - return repo && repo._links ? repo._links.permissions.href : undefined; +export function getPermissionsLink(state: object, namespaceName: string, repoName?: string) { + if (repoName) { + const repo = getRepository(state, namespaceName, repoName); + return repo?._links ? repo._links.permissions.href : undefined; + } else { + const namespace = getNamespace(state, namespaceName); + return namespace?._links ? namespace?._links?.permissions?.href : undefined; + } } diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx new file mode 100644 index 0000000000..53917fff18 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/NamespaceRoot.tsx @@ -0,0 +1,137 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { fetchNamespaceByName, getNamespace, isFetchNamespacePending } from "../../modules/repos"; +import { getNamespacesLink } from "../../../modules/indexResource"; +import { Namespace } from "@scm-manager/ui-types"; +import { + CustomQueryFlexWrappedColumns, + ErrorPage, + Loading, + Page, + PrimaryContentColumn, + SecondaryNavigation, + SecondaryNavigationColumn, + StateMenuContextProvider, + SubNavigation +} from "@scm-manager/ui-components"; +import Permissions from "../../permissions/containers/Permissions"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import PermissionsNavLink from "./PermissionsNavLink"; +import { urls } from "@scm-manager/ui-components"; + +type Props = RouteComponentProps & + WithTranslation & { + loading: boolean; + namespaceName: string; + namespacesLink: string; + namespace: Namespace; + error: Error; + + // dispatch functions + fetchNamespace: (link: string, namespace: string) => void; + }; + +class NamespaceRoot extends React.Component { + componentDidMount() { + const { namespacesLink, namespaceName, fetchNamespace } = this.props; + fetchNamespace(namespacesLink, namespaceName); + } + + render() { + const { loading, error, namespaceName, namespace, t } = this.props; + const url = urls.matchedUrl(this.props); + + const extensionProps = { + namespace, + url + }; + + if (error) { + return ( + + ); + } + + if (!namespace || loading) { + return ; + } + + return ( + + + + + + + { + return ; + }} + /> + + + + + + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state: any, ownProps: Props) => { + const { namespaceName } = ownProps.match.params; + const namespacesLink = getNamespacesLink(state); + const namespace = getNamespace(state, namespaceName); + const loading = isFetchNamespacePending(state); + return { namespaceName, namespacesLink, loading, namespace }; +}; + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchNamespace: (link: string, namespaceName: string) => { + dispatch(fetchNamespaceByName(link, namespaceName)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(withTranslation("namespaces")(NamespaceRoot)); diff --git a/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx new file mode 100644 index 0000000000..e251fbbf7e --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/namespaces/containers/PermissionsNavLink.tsx @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from "react"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { Namespace } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = WithTranslation & { + permissionUrl: string; + namespace: Namespace; +}; + +class PermissionsNavLink extends React.Component { + hasPermissionsLink = () => { + return this.props.namespace?._links?.permissions; + }; + render() { + if (!this.hasPermissionsLink()) { + return null; + } + const { permissionUrl, t } = this.props; + return ; + } +} + +export default withTranslation("namespaces")(PermissionsNavLink); diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx index cb0b285aa4..3ab269db50 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/Permissions.tsx @@ -57,13 +57,12 @@ import { getRepositoryVerbsLink, getUserAutoCompleteLink } from "../../../modules/indexResource"; - type Props = WithTranslation & { availablePermissions: boolean; availableRepositoryRoles: RepositoryRole[]; availableVerbs: string[]; namespace: string; - repoName: string; + repoName?: string; loading: boolean; error: Error; permissions: PermissionCollection; @@ -77,17 +76,17 @@ type Props = WithTranslation & { // dispatch functions fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void; - fetchPermissions: (link: string, namespace: string, repoName: string) => void; + fetchPermissions: (link: string, namespace: string, repoName?: string) => void; createPermission: ( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) => void; - createPermissionReset: (p1: string, p2: string) => void; - modifyPermissionReset: (p1: string, p2: string) => void; - deletePermissionReset: (p1: string, p2: string) => void; + createPermissionReset: (namespace: string, repoName?: string) => void; + modifyPermissionReset: (namespace: string, repoName?: string) => void; + deletePermissionReset: (namespace: string, repoName?: string) => void; // context props match: any; @@ -241,7 +240,7 @@ const mapStateToProps = (state: any, ownProps: Props) => { const mapDispatchToProps = (dispatch: any) => { return { - fetchPermissions: (link: string, namespace: string, repoName: string) => { + fetchPermissions: (link: string, namespace: string, repoName?: string) => { dispatch(fetchPermissions(link, namespace, repoName)); }, fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => { @@ -256,13 +255,13 @@ const mapDispatchToProps = (dispatch: any) => { ) => { dispatch(createPermission(link, permission, namespace, repoName, callback)); }, - createPermissionReset: (namespace: string, repoName: string) => { + createPermissionReset: (namespace: string, repoName?: string) => { dispatch(createPermissionReset(namespace, repoName)); }, - modifyPermissionReset: (namespace: string, repoName: string) => { + modifyPermissionReset: (namespace: string, repoName?: string) => { dispatch(modifyPermissionReset(namespace, repoName)); }, - deletePermissionReset: (namespace: string, repoName: string) => { + deletePermissionReset: (namespace: string, repoName?: string) => { dispatch(deletePermissionReset(namespace, repoName)); } }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx index 3be203f7dc..48337446d4 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/containers/SinglePermission.tsx @@ -43,14 +43,14 @@ type Props = WithTranslation & { availableRepositoryRoles: RepositoryRole[]; availableRepositoryVerbs: string[]; submitForm: (p: Permission) => void; - modifyPermission: (permission: Permission, namespace: string, name: string) => void; + modifyPermission: (permission: Permission, namespace: string, name?: string) => void; permission: Permission; namespace: string; - repoName: string; + repoName?: string; match: any; history: History; loading: boolean; - deletePermission: (permission: Permission, namespace: string, name: string) => void; + deletePermission: (permission: Permission, namespace: string, name?: string) => void; deleteLoading: boolean; }; diff --git a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts index 55a446ab80..34852c17de 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts +++ b/scm-ui/ui-webapp/src/repos/permissions/modules/permissions.ts @@ -131,7 +131,7 @@ export function fetchAvailableFailure(error: Error): Action { // fetch permissions -export function fetchPermissions(link: string, namespace: string, repoName: string) { +export function fetchPermissions(link: string, namespace: string, repoName?: string) { return function(dispatch: any) { dispatch(fetchPermissionsPending(namespace, repoName)); return apiClient @@ -146,26 +146,26 @@ export function fetchPermissions(link: string, namespace: string, repoName: stri }; } -export function fetchPermissionsPending(namespace: string, repoName: string): Action { +export function fetchPermissionsPending(namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_PENDING, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName: string): Action { +export function fetchPermissionsSuccess(permissions: any, namespace: string, repoName?: string): Action { return { type: FETCH_PERMISSIONS_SUCCESS, payload: permissions, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function fetchPermissionsFailure(namespace: string, repoName: string, error: Error): Action { +export function fetchPermissionsFailure(namespace: string, repoName?: string, error: Error): Action { return { type: FETCH_PERMISSIONS_FAILURE, payload: { @@ -173,13 +173,13 @@ export function fetchPermissionsFailure(namespace: string, repoName: string, err repoName, error }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // modify permission -export function modifyPermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function modifyPermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(modifyPermissionPending(permission, namespace, repoName)); return apiClient @@ -196,7 +196,7 @@ export function modifyPermission(permission: Permission, namespace: string, repo }; } -export function modifyPermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_PENDING, payload: permission, @@ -204,12 +204,12 @@ export function modifyPermissionPending(permission: Permission, namespace: strin }; } -export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function modifyPermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: MODIFY_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -219,7 +219,7 @@ export function modifyPermissionFailure( permission: Permission, error: Error, namespace: string, - repoName: string + repoName?: string ): Action { return { type: MODIFY_PERMISSION_FAILURE, @@ -240,14 +240,14 @@ function newPermissions(oldPermissions: PermissionCollection, newPermission: Per } } -export function modifyPermissionReset(namespace: string, repoName: string) { +export function modifyPermissionReset(namespace: string, repoName?: string) { return { type: MODIFY_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -256,7 +256,7 @@ export function createPermission( link: string, permission: PermissionCreateEntry, namespace: string, - repoName: string, + repoName?: string, callback?: () => void ) { return function(dispatch: Dispatch) { @@ -281,48 +281,48 @@ export function createPermission( export function createPermissionPending( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_PENDING, payload: permission, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } export function createPermissionSuccess( permission: PermissionCreateEntry, namespace: string, - repoName: string + repoName?: string ): Action { return { type: CREATE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionFailure(error: Error, namespace: string, repoName: string): Action { +export function createPermissionFailure(error: Error, namespace: string, repoName?: string): Action { return { type: CREATE_PERMISSION_FAILURE, payload: error, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } -export function createPermissionReset(namespace: string, repoName: string) { +export function createPermissionReset(namespace: string, repoName?: string) { return { type: CREATE_PERMISSION_RESET, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } // delete permission -export function deletePermission(permission: Permission, namespace: string, repoName: string, callback?: () => void) { +export function deletePermission(permission: Permission, namespace: string, repoName?: string, callback?: () => void) { return function(dispatch: any) { dispatch(deletePermissionPending(permission, namespace, repoName)); return apiClient @@ -339,7 +339,7 @@ export function deletePermission(permission: Permission, namespace: string, repo }; } -export function deletePermissionPending(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionPending(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_PENDING, payload: permission, @@ -347,12 +347,12 @@ export function deletePermissionPending(permission: Permission, namespace: strin }; } -export function deletePermissionSuccess(permission: Permission, namespace: string, repoName: string): Action { +export function deletePermissionSuccess(permission: Permission, namespace: string, repoName?: string): Action { return { type: DELETE_PERMISSION_SUCCESS, payload: { permission, - position: namespace + "/" + repoName + position: createPermissionStateKey(namespace, repoName) }, itemId: createItemId(permission, namespace, repoName) }; @@ -361,7 +361,7 @@ export function deletePermissionSuccess(permission: Permission, namespace: strin export function deletePermissionFailure( permission: Permission, namespace: string, - repoName: string, + repoName?: string, error: Error ): Action { return { @@ -374,14 +374,14 @@ export function deletePermissionFailure( }; } -export function deletePermissionReset(namespace: string, repoName: string) { +export function deletePermissionReset(namespace: string, repoName?: string) { return { type: DELETE_PERMISSION_RESET, payload: { namespace, repoName }, - itemId: namespace + "/" + repoName + itemId: createPermissionStateKey(namespace, repoName) }; } @@ -398,9 +398,9 @@ function deletePermissionFromState(oldPermissions: PermissionCollection, permiss return newPermission; } -function createItemId(permission: Permission, namespace: string, repoName: string) { +function createItemId(permission: Permission, namespace: string, repoName?: string) { const groupPermission = permission.groupPermission ? "@" : ""; - return namespace + "/" + repoName + "/" + groupPermission + permission.name; + return createPermissionStateKey(namespace, repoName) + "/" + groupPermission + permission.name; } // reducer @@ -427,7 +427,7 @@ export default function reducer( createPermission: !!action.payload._links.create } }; - case MODIFY_PERMISSION_SUCCESS: + case MODIFY_PERMISSION_SUCCESS: { const positionOfPermission = action.payload.position; const newPermission = newPermissions(state[action.payload.position].entries, action.payload.permission); return { @@ -437,7 +437,8 @@ export default function reducer( entries: newPermission } }; - case CREATE_PERMISSION_SUCCESS: + } + case CREATE_PERMISSION_SUCCESS: { // return state; const position = action.payload.position; const permissions = state[action.payload.position].entries; @@ -449,9 +450,10 @@ export default function reducer( entries: permissions } }; - case DELETE_PERMISSION_SUCCESS: + } + case DELETE_PERMISSION_SUCCESS: { const permissionPosition = action.payload.position; - const new_Permissions = deletePermissionFromState( + const newPermissions = deletePermissionFromState( state[action.payload.position].entries, action.payload.permission ); @@ -459,9 +461,10 @@ export default function reducer( ...state, [permissionPosition]: { ...state[permissionPosition], - entries: new_Permissions + entries: newPermissions } }; + } default: return state; } @@ -490,9 +493,9 @@ function available(state: object) { return {}; } -export function getPermissionsOfRepo(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) { - return state.permissions[namespace + "/" + repoName].entries; +export function getPermissionsOfRepo(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) { + return state.permissions[createPermissionStateKey(namespace, repoName)].entries; } } @@ -500,52 +503,62 @@ export function isFetchAvailablePermissionsPending(state: object) { return isPending(state, FETCH_AVAILABLE, "available"); } -export function isFetchPermissionsPending(state: object, namespace: string, repoName: string) { - return isPending(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function isFetchPermissionsPending(state: object, namespace: string, repoName?: string) { + return isPending(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } export function getFetchAvailablePermissionsFailure(state: object) { return getFailure(state, FETCH_AVAILABLE, "available"); } -export function getFetchPermissionsFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, FETCH_PERMISSIONS, namespace + "/" + repoName); +export function getFetchPermissionsFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, FETCH_PERMISSIONS, createPermissionStateKey(namespace, repoName)); } -export function isModifyPermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { - return isPending(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function isModifyPermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { + return isPending(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function getModifyPermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { - return getFailure(state, MODIFY_PERMISSION, createItemId(permission, namespace, repoName)); +export function getModifyPermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { + return getFailure(state, MODIFY_PERMISSION, createItemId(permission, createPermissionStateKey(namespace, repoName))); } -export function hasCreatePermission(state: object, namespace: string, repoName: string) { - if (state.permissions && state.permissions[namespace + "/" + repoName]) - return state.permissions[namespace + "/" + repoName].createPermission; +export function hasCreatePermission(state: object, namespace: string, repoName?: string) { + if (state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)]) + return state.permissions[createPermissionStateKey(namespace, repoName)].createPermission; else return null; } -export function isCreatePermissionPending(state: object, namespace: string, repoName: string) { - return isPending(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function isCreatePermissionPending(state: object, namespace: string, repoName?: string) { + return isPending(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function getCreatePermissionFailure(state: object, namespace: string, repoName: string) { - return getFailure(state, CREATE_PERMISSION, namespace + "/" + repoName); +export function getCreatePermissionFailure(state: object, namespace: string, repoName?: string) { + return getFailure(state, CREATE_PERMISSION, createPermissionStateKey(namespace, repoName)); } -export function isDeletePermissionPending(state: object, namespace: string, repoName: string, permission: Permission) { +export function isDeletePermissionPending(state: object, namespace: string, repoName?: string, permission: Permission) { return isPending(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionFailure(state: object, namespace: string, repoName: string, permission: Permission) { +export function getDeletePermissionFailure( + state: object, + namespace: string, + repoName?: string, + permission: Permission +) { return getFailure(state, DELETE_PERMISSION, createItemId(permission, namespace, repoName)); } -export function getDeletePermissionsFailure(state: object, namespace: string, repoName: string) { +export function getDeletePermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -556,10 +569,10 @@ export function getDeletePermissionsFailure(state: object, namespace: string, re return null; } -export function getModifyPermissionsFailure(state: object, namespace: string, repoName: string) { +export function getModifyPermissionsFailure(state: object, namespace: string, repoName?: string) { const permissions = - state.permissions && state.permissions[namespace + "/" + repoName] - ? state.permissions[namespace + "/" + repoName].entries + state.permissions && state.permissions[createPermissionStateKey(namespace, repoName)] + ? state.permissions[createPermissionStateKey(namespace, repoName)].entries : null; if (permissions == null) return undefined; for (let i = 0; i < permissions.length; i++) { @@ -570,6 +583,10 @@ export function getModifyPermissionsFailure(state: object, namespace: string, re return null; } +function createPermissionStateKey(namespace: string, repoName?: string) { + return namespace + (repoName ? "/" + repoName : ""); +} + export function findVerbsForRole(availableRepositoryRoles: RepositoryRole[], roleName: string) { const matchingRole = availableRepositoryRoles.find(role => roleName === role.name); if (matchingRole) { diff --git a/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx b/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx index 056ddd5b18..e0a4e969d1 100644 --- a/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx @@ -28,6 +28,7 @@ import { Redirect, Switch, useLocation, useRouteMatch, Route } from "react-route import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components"; import { useTranslation } from "react-i18next"; import TagView from "../components/TagView"; +import { urls } from "@scm-manager/ui-components"; type Props = { repository: Repository; @@ -65,17 +66,6 @@ const TagRoot: FC = ({ repository, baseUrl }) => { } }, [tags]); - const stripEndingSlash = (url: string) => { - if (url.endsWith("/")) { - return url.substring(0, url.length - 1); - } - return url; - }; - - const matchedUrl = () => { - return stripEndingSlash(match.url); - }; - if (error) { return ; } @@ -84,7 +74,7 @@ const TagRoot: FC = ({ repository, baseUrl }) => { return ; } - const url = matchedUrl(); + const url = urls.matchedUrlFromMatch(match); return ( diff --git a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx index e7d0856545..8faddda5d8 100644 --- a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx +++ b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx @@ -47,6 +47,7 @@ import { mustGetUsersLink } from "../../modules/indexResource"; import SetUserPassword from "../components/SetUserPassword"; import SetPermissions from "../../permissions/components/SetPermissions"; import SetPublicKeys from "../components/publicKeys/SetPublicKeys"; +import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -65,17 +66,6 @@ class SingleUser extends React.Component { this.props.fetchUserByName(this.props.usersLink, this.props.name); } - 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, user } = this.props; @@ -87,7 +77,7 @@ class SingleUser extends React.Component { return ; } - const url = this.matchedUrl(); + const url = urls.matchedUrl(this.props); const extensionProps = { user, diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java new file mode 100644 index 0000000000..3fbb2a6a13 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespacePermissionResource.java @@ -0,0 +1,330 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.NotFoundException; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.Optional; +import java.util.function.Predicate; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; + +@Slf4j +public class NamespacePermissionResource { + + private final RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper; + private final RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper; + private final RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; + private final ResourceLinks resourceLinks; + private final NamespaceManager manager; + + @Inject + public NamespacePermissionResource( + RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper, + RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper, + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper, + ResourceLinks resourceLinks, + NamespaceManager manager) { + this.dtoToModelMapper = dtoToModelMapper; + this.modelToDtoMapper = modelToDtoMapper; + this.repositoryPermissionCollectionToDtoMapper = repositoryPermissionCollectionToDtoMapper; + this.resourceLinks = resourceLinks; + this.manager = manager; + } + + /** + * Adds a new namespace permission for the user or group + * + * @param permission permission to add + * @return a web response with the status code 201 and the url to GET the added permission + */ + @POST + @Path("") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Create namespace-specific permission", description = "Adds a new permission to the namespace for the user or group.", tags = {"Namespace", "Permissions"}) + @ApiResponse( + responseCode = "201", + description = "creates", + headers = @Header( + name = "Location", + description = "uri of the created permission", + schema = @Schema(type = "string") + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "409", description = "conflict") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response create(@PathParam("namespace") String namespaceName, @Valid RepositoryPermissionDto permission) { + log.info("try to add new permission: {}", permission); + Namespace namespace = load(namespaceName); + checkPermissionAlreadyExists(permission, namespace); + namespace.addPermission(dtoToModelMapper.map(permission)); + manager.modify(namespace); + String urlPermissionName = modelToDtoMapper.getUrlPermissionName(permission); + return Response.created(URI.create(resourceLinks.namespacePermission().self(namespaceName, urlPermissionName))).build(); + } + + /** + * Get the searched permission with permission name related to a namespace + * + * @param namespaceName the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace or the permission does not exists + */ + @GET + @Path("{permission-name}") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Get single repository-specific permission", description = "Get the searched permission with permission name related to a repository.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public RepositoryPermissionDto get(@PathParam("namespace") String namespaceName, @PathParam("permission-name") String permissionName) { + Namespace namespace = load(namespaceName); + return + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .map(permission -> modelToDtoMapper.map(permission, namespace)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + } + + /** + * Get all permissions related to a namespace + * + * @param namespaceMame the name of the namespace + * @return the http response with a list of permissionDto objects + * @throws NotFoundException if the namespace does not exists + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "List of namespace-specific permissions", description = "Get all permissions related to a namespace.", tags = {"Repository", "Permissions"}) + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.REPOSITORY_PERMISSION, + schema = @Schema(implementation = RepositoryPermissionDto.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "not found", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public HalRepresentation getAll(@PathParam("namespace") String namespaceMame) { + Namespace namespace = load(namespaceMame); + return repositoryPermissionCollectionToDtoMapper.map(namespace); + } + + /** + * Update a permission to the user or group managed by the repository + * ignore the user input for groupPermission and take it from the path parameter (if the group prefix (@) exists it is a group permission) + * + * @param permission permission to modify + * @param permissionName permission to modify + * @return a web response with the status code 204 + */ + @PUT + @Path("{permission-name}") + @Consumes(VndMediaType.REPOSITORY_PERMISSION) + @Operation(summary = "Update repository-specific permission", description = "Update a permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void update(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName, + @Valid RepositoryPermissionDto permission) { + Namespace namespace = load(namespaceName); + String extractedPermissionName = getPermissionName(permissionName); + if (!isPermissionExist(new RepositoryPermissionDto(extractedPermissionName, isGroupPermission(permissionName)), namespace)) { + throw notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName)); + } + permission.setGroupPermission(isGroupPermission(permissionName)); + if (!extractedPermissionName.equals(permission.getName())) { + checkPermissionAlreadyExists(permission, namespace); + } + + RepositoryPermission existingPermission = namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, permissionName).in(Namespace.class, namespaceName))); + RepositoryPermission newPermission = dtoToModelMapper.map(permission); + if (!namespace.removePermission(existingPermission)) { + throw new IllegalStateException(String.format("could not delete modified permission %s from namespace %s", existingPermission, namespaceName)); + } + namespace.addPermission(newPermission); + manager.modify(namespace); + log.info("the permission with name: {} is updated to {}.", permissionName, permission); + } + + /** + * Update a permission to the user or group managed by the repository + * + * @param permissionName permission to delete + * @return a web response with the status code 204 + */ + @DELETE + @Path("{permission-name}") + @Operation(summary = "Delete repository-specific permission", description = "Delete a permission with the given name.", tags = {"Repository", "Permissions"}) + @ApiResponse(responseCode = "204", description = "delete success or nothing to delete") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public void delete(@PathParam("namespace") String namespaceName, + @PathParam("permission-name") String permissionName) { + log.info("try to delete the permission with name: {}.", permissionName); + Namespace namespace = load(namespaceName); + namespace.getPermissions() + .stream() + .filter(filterPermission(permissionName)) + .findFirst() + .ifPresent(permission -> { + namespace.removePermission(permission); + manager.modify(namespace); + }); + log.info("the permission with name: {} is deleted.", permissionName); + } + + private Predicate filterPermission(String name) { + return permission -> + getPermissionName(name).equals(permission.getName()) + && permission.isGroupPermission() == isGroupPermission(name); + } + + private String getPermissionName(String permissionName) { + return Optional.of(permissionName) + .filter(p -> !isGroupPermission(permissionName)) + .orElse(permissionName.substring(1)); + } + + private boolean isGroupPermission(String permissionName) { + return permissionName.startsWith(GROUP_PREFIX); + } + + private Namespace load(String namespaceMame) { + return manager.get(namespaceMame) + .orElseThrow(() -> notFound(entity("Namespace", namespaceMame))); + } + + private void checkPermissionAlreadyExists(RepositoryPermissionDto permission, Namespace namespace) { + if (isPermissionExist(permission, namespace)) { + throw alreadyExists(entity("Permission", permission.getName()).in(Namespace.class, namespace.getNamespace())); + } + } + + private boolean isPermissionExist(RepositoryPermissionDto permission, Namespace namespace) { + return namespace.getPermissions() + .stream() + .anyMatch(p -> p.getName().equals(permission.getName()) && p.isGroupPermission() == permission.isGroupPermission()); + } +} + + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java index 8c7fdc0a05..1f0c828ca7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceResource.java @@ -32,6 +32,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -44,11 +45,13 @@ public class NamespaceResource { private final RepositoryManager manager; private final NamespaceToNamespaceDtoMapper namespaceMapper; + private final Provider namespacePermissionResource; @Inject - public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper) { + public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper, Provider namespacePermissionResource) { this.manager = manager; this.namespaceMapper = namespaceMapper; + this.namespacePermissionResource = namespacePermissionResource; } /** @@ -97,4 +100,8 @@ public class NamespaceResource { .orElseThrow(() -> notFound(entity("Namespace", namespace))); } + @Path("permissions") + public NamespacePermissionResource permissions() { + return namespacePermissionResource.get(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java index 7a464299ea..70b85374ce 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NamespaceToNamespaceDtoMapper.java @@ -24,6 +24,9 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Links; +import sonia.scm.repository.NamespacePermissions; + import javax.inject.Inject; import static de.otto.edison.hal.Link.link; @@ -39,12 +42,15 @@ class NamespaceToNamespaceDtoMapper { } NamespaceDto map(String namespace) { - return new NamespaceDto( - namespace, - linkingTo() - .self(links.namespace().self(namespace)) - .single(link("repositories", links.repositoryCollection().forNamespace(namespace))) - .build() - ); + Links.Builder linkingTo = linkingTo(); + linkingTo + .self(links.namespace().self(namespace)) + .single(link("repositories", links.repositoryCollection().forNamespace(namespace))); + + if (NamespacePermissions.permissionRead().isPermitted() || NamespacePermissions.permissionWrite().isPermitted()) { + linkingTo + .single(link("permissions", links.namespacePermission().all(namespace))); + } + return new NamespaceDto(namespace, linkingTo.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java index 765ffe8168..6928dec872 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java @@ -21,13 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -57,6 +59,14 @@ public class RepositoryPermissionCollectionToDtoMapper { return new HalRepresentation(createLinks(repository), embedDtos(repositoryPermissionDtoList)); } + public HalRepresentation map(Namespace namespace) { + List repositoryPermissionDtoList = namespace.getPermissions() + .stream() + .map(permission -> repositoryPermissionToRepositoryPermissionDtoMapper.map(permission, namespace)) + .collect(toList()); + return new HalRepresentation(createLinks(namespace), embedDtos(repositoryPermissionDtoList)); + } + private Links createLinks(Repository repository) { RepositoryPermissions.permissionRead(repository).check(); Links.Builder linksBuilder = linkingTo() @@ -67,6 +77,18 @@ public class RepositoryPermissionCollectionToDtoMapper { return linksBuilder.build(); } + private Links createLinks(Namespace namespace) { + if (!NamespacePermissions.permissionWrite().isPermitted()) { + NamespacePermissions.permissionRead().check(); + } + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(resourceLinks.namespacePermission().all(namespace.getNamespace())).build()); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("create", resourceLinks.namespacePermission().create(namespace.getNamespace()))); + } + return linksBuilder.build(); + } + private Embedded embedDtos(List repositoryPermissionDtoList) { return embeddedBuilder() .with("permissions", repositoryPermissionDtoList) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java index 11fd658c33..fea730bf9a 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java @@ -21,18 +21,19 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.Links; import org.mapstruct.AfterMapping; -import org.mapstruct.BeforeMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; -import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespacePermissions; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryPermissions; import javax.inject.Inject; @@ -51,18 +52,9 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Repository repository); + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Namespace namespace); - @BeforeMapping - void validatePermissions(@Context Repository repository) { - RepositoryPermissions.permissionRead(repository).check(); - } - - /** - * Add the self, update and delete links. - * - * @param target the mapped dto - * @param repository the repository - */ @AfterMapping void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) { String permissionName = getUrlPermissionName(target); @@ -75,6 +67,18 @@ public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { target.add(linksBuilder.build()); } + @AfterMapping + void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Namespace namespace) { + String permissionName = getUrlPermissionName(target); + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.namespacePermission().self(namespace.getNamespace(), permissionName)); + if (NamespacePermissions.permissionWrite().isPermitted()) { + linksBuilder.single(link("update", resourceLinks.namespacePermission().update(namespace.getNamespace(), permissionName))); + linksBuilder.single(link("delete", resourceLinks.namespacePermission().delete(namespace.getNamespace(), permissionName))); + } + target.add(linksBuilder.build()); + } + public String getUrlPermissionName(RepositoryPermissionDto repositoryPermissionDto) { return Optional.of(repositoryPermissionDto.getName()) .filter(p -> !repositoryPermissionDto.isGroupPermission()) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3dd2180ede..99a2058249 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -916,4 +916,40 @@ class ResourceLinks { return namespaceLinkBuilder.method("getNamespaceResource").parameters().method("get").parameters(namespace).href(); } } + + public NamespacePermissionLinks namespacePermission() { + return new NamespacePermissionLinks(scmPathInfoStore.get()); + } + + static class NamespacePermissionLinks { + private final LinkBuilder permissionLinkBuilder; + + NamespacePermissionLinks(ScmPathInfo pathInfo) { + permissionLinkBuilder = new LinkBuilder(pathInfo, NamespaceRootResource.class, NamespaceResource.class, NamespacePermissionResource.class); + } + + String all(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("getAll").parameters().href(); + } + + String create(String namespace) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method("create").parameters().href(); + } + + String self(String namespace, String permissionName) { + return getLink(namespace, permissionName, "get"); + } + + String update(String namespace, String permissionName) { + return getLink(namespace, permissionName, "update"); + } + + String delete(String namespace, String permissionName) { + return getLink(namespace, permissionName, "delete"); + } + + private String getLink(String namespace, String permissionName, String methodName) { + return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method(methodName).parameters(permissionName).href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index ec7499aa02..776c196190 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -64,10 +64,12 @@ import sonia.scm.net.ahc.XmlContentTransformer; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; +import sonia.scm.repository.DefaultNamespaceManager; import sonia.scm.repository.DefaultRepositoryManager; import sonia.scm.repository.DefaultRepositoryProvider; import sonia.scm.repository.DefaultRepositoryRoleManager; import sonia.scm.repository.HealthCheckContextListener; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.NamespaceStrategyProvider; import sonia.scm.repository.Repository; @@ -191,6 +193,7 @@ class ScmServletModule extends ServletModule { bindDecorated(GroupManager.class, DefaultGroupManager.class, GroupManagerProvider.class); bind(GroupDisplayManager.class, DefaultGroupDisplayManager.class); + bind(NamespaceManager.class, DefaultNamespaceManager.class); bind(GroupCollector.class, DefaultGroupCollector.class); bind(CGIExecutorFactory.class, DefaultCGIExecutorFactory.class); diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java new file mode 100644 index 0000000000..6d6ef8963f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceManager.java @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import com.github.legman.Subscribe; +import sonia.scm.HandlerEventType; +import sonia.scm.event.ScmEventBus; + +import javax.inject.Inject; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class DefaultNamespaceManager implements NamespaceManager { + + private final RepositoryManager repositoryManager; + private final NamespaceDao dao; + private final ScmEventBus eventBus; + + @Inject + public DefaultNamespaceManager(RepositoryManager repositoryManager, NamespaceDao dao, ScmEventBus eventBus) { + this.repositoryManager = repositoryManager; + this.dao = dao; + this.eventBus = eventBus; + } + + @Override + public Optional get(String namespace) { + return repositoryManager + .getAllNamespaces() + .stream() + .filter(n -> n.equals(namespace)) + .map(this::createNamespaceForName) + .findFirst(); + } + + @Override + public Collection getAll() { + return repositoryManager + .getAllNamespaces() + .stream() + .map(this::createNamespaceForName) + .collect(Collectors.toList()); + } + + @Override + public void modify(Namespace namespace) { + NamespacePermissions.permissionWrite().check(); + Namespace oldNamespace = get(namespace.getNamespace()) + .orElseThrow(() -> notFound(entity(Namespace.class, namespace.getNamespace()))); + fireEvent(HandlerEventType.BEFORE_MODIFY, namespace, oldNamespace); + dao.add(namespace); + fireEvent(HandlerEventType.MODIFY, namespace, oldNamespace); + } + + @Subscribe + public void cleanupDeletedNamespaces(RepositoryEvent repositoryEvent) { + if (namespaceRelevantChange(repositoryEvent)) { + Collection allNamespaces = repositoryManager.getAllNamespaces(); + String oldNamespace = getOldNamespace(repositoryEvent); + if (!allNamespaces.contains(oldNamespace)) { + dao.delete(oldNamespace); + } + } + } + + public boolean namespaceRelevantChange(RepositoryEvent repositoryEvent) { + HandlerEventType eventType = repositoryEvent.getEventType(); + return eventType == HandlerEventType.DELETE + || eventType == HandlerEventType.MODIFY && !repositoryEvent.getItem().getNamespace().equals(repositoryEvent.getOldItem().getNamespace()); + } + + public String getOldNamespace(RepositoryEvent repositoryEvent) { + if (repositoryEvent.getEventType() == HandlerEventType.DELETE) { + return repositoryEvent.getItem().getNamespace(); + } else { + return repositoryEvent.getOldItem().getNamespace(); + } + } + + private Namespace createNamespaceForName(String namespace) { + if (NamespacePermissions.permissionRead().isPermitted() || NamespacePermissions.permissionWrite().isPermitted()) { + return dao.get(namespace) + .map(Namespace::clone) + .orElse(new Namespace(namespace)); + } else { + return new Namespace(namespace); + } + } + + protected void fireEvent(HandlerEventType event, Namespace namespace, Namespace oldNamespace) { + eventBus.post(new NamespaceModificationEvent(event, namespace, oldNamespace)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index c75f54c3fb..075fcca82a 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -331,6 +331,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { return getAll(null, start, limit); } + /** + * @deprecated Use {@link NamespaceManager#getAll()} instead. + */ + @Deprecated @Override public Collection getAllNamespaces() { return getAll().stream() diff --git a/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java new file mode 100644 index 0000000000..cc90f2c350 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/NamespaceDao.java @@ -0,0 +1,53 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.store.DataStore; +import sonia.scm.store.DataStoreFactory; + +import javax.inject.Inject; +import java.util.Optional; + +public class NamespaceDao { + + private final DataStore store; + + @Inject + NamespaceDao(DataStoreFactory storeFactory) { + this.store = storeFactory.withType(Namespace.class).withName("namespaces").build(); + } + + public Optional get(String namespace) { + return store.getOptional(namespace); + } + + public void add(Namespace namespace) { + store.put(namespace.getNamespace(), namespace); + } + + public void delete(String namespace) { + store.remove(namespace); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index a198dafb24..c05fe849bb 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.github.legman.Subscribe; @@ -35,14 +35,19 @@ import sonia.scm.event.ScmEventBus; import sonia.scm.group.Group; import sonia.scm.group.GroupEvent; import sonia.scm.group.GroupModificationEvent; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceEvent; +import sonia.scm.repository.NamespaceModificationEvent; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryEvent; import sonia.scm.repository.RepositoryModificationEvent; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserEvent; import sonia.scm.user.UserModificationEvent; import javax.inject.Singleton; +import java.util.Collection; /** * Receives all kinds of events, which affects authorization relevant data and fires an @@ -146,23 +151,50 @@ public class AuthorizationChangedEventProducer { } } + @Subscribe + public void onEvent(NamespaceEvent event) { + if (event.getEventType().isPost() && isModificationEvent(event)) { + handleNamespaceModificationEvent((NamespaceModificationEvent) event); + } + } + private void handleRepositoryModificationEvent(RepositoryModificationEvent event) { Repository repository = event.getItem(); - if (isAuthorizationDataModified(repository, event.getItemBeforeModification())) { + if (isAuthorizationDataModified(repository.getPermissions(), event.getItemBeforeModification().getPermissions())) { logger.debug( - "fire authorization changed event, because a relevant field of repository {} has changed", repository.getName() + "fire authorization changed event, because the permissions of repository {}/{} have changed", repository.getNamespace(), repository.getName() + ); + fireEventForEveryUser(); + } else if (!event.getItem().getNamespace().equals(event.getItemBeforeModification().getNamespace())) { + logger.debug( + "fire authorization changed event, because the namespace of repository {}/{} has changed", repository.getNamespace(), repository.getName() ); fireEventForEveryUser(); } else { logger.debug( - "authorization changed event is not fired, because non relevant field of repository {} has changed", - repository.getName() + "authorization changed event is not fired, because non relevant field of repository {}/{} has changed", + repository.getNamespace(), repository.getName() ); } } - private boolean isAuthorizationDataModified(Repository repository, Repository beforeModification) { - return !(repository.getPermissions().containsAll(beforeModification.getPermissions()) && beforeModification.getPermissions().containsAll(repository.getPermissions())); + private void handleNamespaceModificationEvent(NamespaceModificationEvent event) { + Namespace namespace = event.getItem(); + if (isAuthorizationDataModified(namespace.getPermissions(), event.getItemBeforeModification().getPermissions())) { + logger.debug( + "fire authorization changed event, because a relevant field of namespace {} has changed", namespace.getNamespace() + ); + fireEventForEveryUser(); + } else { + logger.debug( + "authorization changed event is not fired, because non relevant field of namespace {} has changed", + namespace.getNamespace() + ); + } + } + + private boolean isAuthorizationDataModified(Collection newPermissions, Collection permissionsBeforeModification) { + return !(newPermissions.containsAll(permissionsBeforeModification) && permissionsBeforeModification.containsAll(newPermissions)); } private void fireEventForEveryUser() { diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index 6d115db0f3..ddc65a8c0d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.security; import com.github.legman.Subscribe; import com.google.common.annotations.VisibleForTesting; @@ -46,17 +44,19 @@ import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.user.UserPermissions; -import sonia.scm.util.Util; import java.util.Collection; +import java.util.Optional; import java.util.Set; -//~--- JDK imports ------------------------------------------------------------ +import static java.util.Collections.emptySet; /** * @@ -85,16 +85,18 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * @param securitySystem * @param repositoryPermissionProvider * @param groupCollector + * @param namespaceDao */ @Inject public DefaultAuthorizationCollector(CacheManager cacheManager, - RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector) + RepositoryDAO repositoryDAO, SecuritySystem securitySystem, RepositoryPermissionProvider repositoryPermissionProvider, GroupCollector groupCollector, NamespaceDao namespaceDao) { this.cache = cacheManager.getCache(CACHE_NAME); this.repositoryDAO = repositoryDAO; this.securitySystem = securitySystem; this.repositoryPermissionProvider = repositoryPermissionProvider; this.groupCollector = groupCollector; + this.namespaceDao = namespaceDao; } //~--- methods -------------------------------------------------------------- @@ -186,28 +188,27 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private void collectRepositoryPermissions(Builder builder, Repository repository, User user, Set groups) { - Collection repositoryPermissions = repository.getPermissions(); + Optional namespace = namespaceDao.get(repository.getNamespace()); - if (Util.isNotEmpty(repositoryPermissions)) + boolean hasPermission = false; + for (RepositoryPermission permission : repository.getPermissions()) { - boolean hasPermission = false; - for (RepositoryPermission permission : repositoryPermissions) - { - hasPermission = isUserPermitted(user, groups, permission); - if (hasPermission) { - addRepositoryPermission(builder, repository, user, permission); - } - } - - if (!hasPermission && logger.isTraceEnabled()) - { - logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); } } - else if (logger.isTraceEnabled()) + for (RepositoryPermission permission : namespace.map(Namespace::getPermissions).orElse(emptySet())) { - logger.trace("repository {} has no permission entries", - repository.getName()); + hasPermission = isUserPermitted(user, groups, permission); + if (hasPermission) { + addRepositoryPermission(builder, repository, user, permission); + } + } + + if (!hasPermission && logger.isTraceEnabled()) + { + logger.trace("no permission for user {} defined at repository {}", user.getName(), repository.getNamespaceAndName()); } } @@ -371,4 +372,5 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private final RepositoryPermissionProvider repositoryPermissionProvider; private final GroupCollector groupCollector; + private final NamespaceDao namespaceDao; } diff --git a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml index bc50fbd3da..0889341d25 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -44,6 +44,12 @@ repository:create + + namespace:permissionRead + + + namespace:permissionWrite + user:* diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index c5ab04d950..f3372e2d85 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -93,6 +93,16 @@ "description": "Darf alle installierten und verfügbaren Plugins lesen und verwalten" } }, + "namespace": { + "permissionRead": { + "displayName": "Berechtigungen auf Namespaces lesen", + "description": "Darf die Berechtigungen auf Namespace-Ebene sehen" + }, + "permissionWrite": { + "displayName": "Berechtigungen auf Namespaces modifizieren", + "description": "Darf die Berechtigungen auf Namespace-Ebene lesen und bearbeiten" + } + }, "unknown": "Unbekannte Berechtigung" }, "verbs": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 7338d53267..3dfeef2300 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -93,6 +93,16 @@ "description": "May see and manage all installed and available plugins" } }, + "namespace": { + "permissionRead": { + "displayName": "Read permissions on namespaces", + "description": "May see the permissions set for namespaces" + }, + "permissionWrite": { + "displayName": "Modify permissions on namespaces", + "description": "May read and modify the permissions set for namespaces" + } + }, "unknown": "Unknown permission" }, "verbs": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java index 669cc70544..a7219ea02b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NamespaceRootResourceTest.java @@ -24,83 +24,334 @@ package sonia.scm.api.v2.resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.RestDispatcher; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import static com.google.inject.util.Providers.of; import static java.util.Arrays.asList; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class NamespaceRootResourceTest { @Mock RepositoryManager repositoryManager; + @Mock + NamespaceManager namespaceManager; + @Mock + Subject subject; RestDispatcher dispatcher = new RestDispatcher(); MockHttpResponse response = new MockHttpResponse(); ResourceLinks links = ResourceLinksMock.createMock(URI.create("/")); + @InjectMocks + RepositoryPermissionToRepositoryPermissionDtoMapperImpl repositoryPermissionToRepositoryPermissionDtoMapper; + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + @BeforeEach void setUpResources() { NamespaceToNamespaceDtoMapper namespaceMapper = new NamespaceToNamespaceDtoMapper(links); NamespaceCollectionToDtoMapper namespaceCollectionToDtoMapper = new NamespaceCollectionToDtoMapper(namespaceMapper, links); + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(repositoryPermissionToRepositoryPermissionDtoMapper, links); + RepositoryPermissionDtoToRepositoryPermissionMapperImpl dtoToModelMapper = new RepositoryPermissionDtoToRepositoryPermissionMapperImpl(); + NamespaceCollectionResource namespaceCollectionResource = new NamespaceCollectionResource(repositoryManager, namespaceCollectionToDtoMapper); - NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper); + NamespacePermissionResource namespacePermissionResource = new NamespacePermissionResource(dtoToModelMapper, repositoryPermissionToRepositoryPermissionDtoMapper, repositoryPermissionCollectionToDtoMapper, links, namespaceManager); + NamespaceResource namespaceResource = new NamespaceResource(repositoryManager, namespaceMapper, of(namespacePermissionResource)); dispatcher.addSingletonResource(new NamespaceRootResource(of(namespaceCollectionResource), of(namespaceResource))); } - @Test - void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); - - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - - dispatcher.invoke(request, response); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") - .contains("\"_embedded\"") - .contains("\"namespace\":\"hitchhiker\"") - .contains("\"namespace\":\"space\""); + @BeforeEach + void mockExistingNamespaces() { + lenient().when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + Namespace hitchhikerNamespace = new Namespace("hitchhiker"); + hitchhikerNamespace.setPermissions(singleton(new RepositoryPermission("humans", "READ", true))); + Namespace spaceNamespace = new Namespace("space"); + lenient().when(namespaceManager.getAll()).thenReturn(asList(hitchhikerNamespace, spaceNamespace)); + lenient().when(namespaceManager.get("hitchhiker")).thenReturn(Optional.of(hitchhikerNamespace)); + lenient().when(namespaceManager.get("space")).thenReturn(Optional.of(spaceNamespace)); } - @Test - void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithoutSpecialPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + @BeforeEach + void mockNoPermissions() { + lenient().when(subject.isPermitted(anyString())).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionRead"); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldReturnAllNamespaces() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()) - .contains("\"namespace\":\"space\"") - .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/\"}") + .contains("\"_embedded\"") + .contains("\"namespace\":\"hitchhiker\"") + .contains("\"namespace\":\"space\""); + } + + @Test + void shouldReturnSingleNamespace() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"namespace\":\"space\"") + .contains("\"self\":{\"href\":\"/v2/namespaces/space\"}") + .doesNotContain("permissions"); + } + + @Test + void shouldHandleUnknownNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldNotReturnPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } } - @Test - void shouldHandleUnknownNamespace() throws URISyntaxException, UnsupportedEncodingException { - when(repositoryManager.getAllNamespaces()).thenReturn(asList("hitchhiker", "space")); + @Nested + class WithReadPermission { - MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "unknown"); + @BeforeEach + void grantReadPermission() { + lenient().when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } - dispatcher.invoke(request, response); + @Test + void shouldContainPermissionLinkWhenPermitted() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space"); - assertThat(response.getStatus()).isEqualTo(404); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"permissions\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldReturnPermissions() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("create"); + } + + @Test + void shouldReturnSinglePermission() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"self\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"}") + .contains("{\"name\":\"humans\",\"verbs\":[],\"role\":\"READ\",\"groupPermission\":true,\"") + .doesNotContain("update") + .doesNotContain("delete"); + } + + @Test + void shouldHandleMissingNamespace() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "no_such_namespace/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Nested + class WithWritePermission { + + @BeforeEach + void grantWritePermission() { + lenient().when(subject.isPermitted("namespace:permissionWrite")).thenReturn(true); + lenient().doNothing().when(subject).checkPermission("namespace:permissionWrite"); + } + + @Test + void shouldContainCreateLink() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"create\":{\"href\":\"/v2/namespaces/space/permissions\"}"); + } + + @Test + void shouldContainModificationLinks() throws UnsupportedEncodingException, URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()) + .contains("\"update\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\"") + .contains("\"delete\":{\"href\":\"/v2/namespaces/hitchhiker/permissions/@humans\""); + } + + @Test + void shouldCreateNewPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "space/permissions") + .content("{\"name\":\"dent\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":false}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(201); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("dent"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isFalse(); + return true; + }) + ); + assertThat(response.getOutputHeaders().get("Location")) + .containsExactly(URI.create("/v2/namespaces/space/permissions/dent")); + } + + @Test + void shouldUpdatePermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).hasSize(1); + RepositoryPermission permission = namespace.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("humans"); + assertThat(permission.getRole()).isEqualTo("WRITE"); + assertThat(permission.getVerbs()).isEmpty(); + assertThat(permission.isGroupPermission()).isTrue(); + return true; + }) + ); + } + + @Test + void shouldHandleNotExistingPermissionOnUpdate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void shouldHandleExistingPermissionOnCreate() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions") + .content("{\"name\":\"humans\",\"verbs\":[],\"role\":\"WRITE\",\"groupPermission\":true}".getBytes()) + .header("Content-Type", "application/vnd.scmm-repositoryPermission+json;v=2"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + verify(namespaceManager, never()).modify(any()); + } + + @Test + void shouldDeleteExistingPermission() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/@humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager) + .modify(argThat( + namespace -> { + assertThat(namespace.getPermissions()).isEmpty(); + return true; + }) + ); + } + + @Test + void shouldHandleRedundantDeleteIdempotent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + NamespaceRootResource.NAMESPACE_PATH_V2 + "hitchhiker/permissions/humans"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(namespaceManager, never()).modify(any()); + } + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 38a1055211..f2d6e85710 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -78,6 +78,7 @@ public class ResourceLinksMock { lenient().when(resourceLinks.annotate()).thenReturn(new ResourceLinks.AnnotateLinks(pathInfo)); lenient().when(resourceLinks.namespace()).thenReturn(new ResourceLinks.NamespaceLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); + lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java new file mode 100644 index 0000000000..9d95dc928a --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceManagerTest.java @@ -0,0 +1,217 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.HandlerEventType; +import sonia.scm.event.ScmEventBus; +import sonia.scm.store.InMemoryDataStore; +import sonia.scm.store.InMemoryDataStoreFactory; + +import java.util.Collection; +import java.util.Optional; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.HandlerEventType.DELETE; +import static sonia.scm.HandlerEventType.MODIFY; + + +@ExtendWith(MockitoExtension.class) +class DefaultNamespaceManagerTest { + + @Mock + RepositoryManager repositoryManager; + @Mock + ScmEventBus eventBus; + @Mock + Subject subject; + + Namespace life; + + NamespaceDao dao; + DefaultNamespaceManager manager; + private Namespace universe; + private Namespace rest; + + @BeforeEach + void mockExistingNamespaces() { + dao = new NamespaceDao(new InMemoryDataStoreFactory(new InMemoryDataStore())); + + when(repositoryManager.getAllNamespaces()).thenReturn(asList("life", "universe", "rest")); + + life = new Namespace("life"); + RepositoryPermission lifePermission = new RepositoryPermission("humans", "OWNER", true); + life.addPermission(lifePermission); + dao.add(life); + + universe = new Namespace("universe"); + rest = new Namespace("rest"); + + manager = new DefaultNamespaceManager(repositoryManager, dao, eventBus); + } + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldCreateEmptyOptionalIfNamespaceDoesNotExist() { + Optional namespace = manager.get("dolphins"); + + assertThat(namespace).isEmpty(); + } + + @Test + void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasDeleted() { + when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest")); + + manager.cleanupDeletedNamespaces(new RepositoryEvent(DELETE, new Repository("1", "git", "life", "earth"))); + + assertThat(dao.get("life")).isEmpty(); + } + + @Test + void shouldCleanUpPermissionWhenLastRepositoryOfNamespaceWasRenamed() { + when(repositoryManager.getAllNamespaces()).thenReturn(asList("universe", "rest", "highway")); + + manager.cleanupDeletedNamespaces( + new RepositoryModificationEvent( + MODIFY, + new Repository("1", "git", "highway", "earth"), + new Repository("1", "git", "life", "earth"))); + + assertThat(dao.get("life")).isEmpty(); + } + + @Nested + class WithPermissionToReadPermissions { + + @BeforeEach + void grantReadPermission() { + when(subject.isPermitted("namespace:permissionRead")).thenReturn(true); + } + + @Test + void shouldCreateNewNamespaceObjectIfNotInStore() { + Namespace namespace = manager.get("universe").orElse(null); + + assertThat(namespace).isEqualTo(universe); + assertThat(namespace.getPermissions()).isEmpty(); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissions() { + Namespace namespace = manager.get("life").orElse(null); + + assertThat(namespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldEnrichExistingNamespaceWithPermissionsInGetAll() { + Collection namespaces = manager.getAll(); + + assertThat(namespaces).containsExactly( + life, + universe, + rest + ); + Namespace foundLifeNamespace = namespaces.stream().filter(namespace -> namespace.getNamespace().equals("life")).findFirst().get(); + assertThat( + foundLifeNamespace.getPermissions()).containsExactly(life.getPermissions().toArray(new RepositoryPermission[0])); + } + + @Test + void shouldModifyExistingNamespaceWithPermissions() { + Namespace modifiedNamespace = manager.get("life").get(); + + modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false))); + manager.modify(modifiedNamespace); + + Namespace newLife = manager.get("life").get(); + + assertThat(newLife).isEqualTo(modifiedNamespace); + verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent) event).getEventType() == HandlerEventType.BEFORE_MODIFY)); + verify(eventBus).post(argThat(event -> ((NamespaceModificationEvent) event).getEventType() == HandlerEventType.MODIFY)); + } + } + + @Nested + class WithoutPermissionToReadOrWritePermissions { + + @BeforeEach + void grantReadPermission() { + when(subject.isPermitted("namespace:permissionRead")).thenReturn(false); + lenient().doThrow(AuthorizationException.class).when(subject).checkPermission("namespace:permissionWrite"); + } + + @Test + void shouldNotEnrichExistingNamespaceWithPermissions() { + Namespace namespace = manager.get("life").orElse(null); + + assertThat(namespace.getPermissions()).isEmpty(); + } + + @Test + void shouldNotEnrichExistingNamespaceWithPermissionsInGetAll() { + Collection namespaces = manager.getAll(); + + assertThat(namespaces).containsExactly( + new Namespace("life"), + universe, + rest + ); + } + + @Test + void shouldNotModifyExistingNamespaceWithPermissions() { + Namespace modifiedNamespace = manager.get("life").get(); + + modifiedNamespace.setPermissions(asList(new RepositoryPermission("Arthur Dent", "READ", false))); + + Assertions.assertThrows(AuthorizationException.class, () -> manager.modify(modifiedNamespace)); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java index 30aa0d8091..f76d3e5a0f 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Lists; @@ -31,6 +31,8 @@ import sonia.scm.HandlerEventType; import sonia.scm.group.Group; import sonia.scm.group.GroupEvent; import sonia.scm.group.GroupModificationEvent; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceModificationEvent; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryEvent; import sonia.scm.repository.RepositoryModificationEvent; @@ -218,6 +220,18 @@ public class AuthorizationChangedEventProducerTest { assertEventIsNotFired(); } + @Test + public void testOnRepositoryNamespaceChanged() + { + Repository repositoryModified = RepositoryTestData.createHeartOfGold(); + repositoryModified.setName("test123"); + Repository repository = RepositoryTestData.createHeartOfGold(); + + repositoryModified.setNamespace("new_namespace"); + producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); + assertGlobalEventIsFired(); + } + private void resetStoredEvent(){ producer.event = null; } @@ -251,6 +265,55 @@ public class AuthorizationChangedEventProducerTest { assertUserEventIsFired("trillian"); } + @Test + public void testOnNamespaceModificationEvent() + { + Namespace namespaceModified = new Namespace("hitchhiker"); + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + + Namespace namespace = new Namespace("hitchhiker"); + namespace.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.BEFORE_CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + + namespaceModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test123", singletonList("read"), false))); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), true)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertGlobalEventIsFired(); + + resetStoredEvent(); + namespace.setPermissions(Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false))); + + namespaceModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", asList("write", "read"), false)) + ); + producer.onEvent(new NamespaceModificationEvent(HandlerEventType.CREATE, namespaceModified, namespace)); + assertEventIsNotFired(); + } + private static class StoringAuthorizationChangedEventProducer extends AuthorizationChangedEventProducer { private AuthorizationChangedEvent event; diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index b351459fa8..30a6e42d10 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -21,13 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.subject.PrincipalCollection; @@ -45,6 +44,8 @@ import sonia.scm.SCMContext; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.group.GroupCollector; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceDao; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.repository.RepositoryPermission; @@ -53,8 +54,10 @@ import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserTestData; +import static com.google.common.collect.Lists.newArrayList; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; +import static java.util.Optional.of; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; @@ -92,6 +95,9 @@ public class DefaultAuthorizationCollectorTest { @Mock private GroupCollector groupCollector; + @Mock + private NamespaceDao namespaceDao; + private DefaultAuthorizationCollector collector; @Rule @@ -103,7 +109,7 @@ public class DefaultAuthorizationCollectorTest { @Before public void setUp(){ when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache); - collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector); + collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem, repositoryPermissionProvider, groupCollector, namespaceDao); } /** @@ -195,12 +201,44 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + heartOfGold.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, asList("read", "pull", "push"), true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + + // execute and assert + AuthorizationInfo authInfo = collector.collect(); + assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); + assertThat(authInfo.getObjectPermissions(), nullValue()); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); + } + + /** + * Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with repository permissions. + */ + @Test + @SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini" + ) + public void testCollectWithNamespacePermissions() { + String group = "heart-of-gold-crew"; + authenticate(UserTestData.createTrillian(), group); + Repository heartOfGold = RepositoryTestData.createHeartOfGold(); + heartOfGold.setId("one"); + Namespace heartOfGoldNamespace = new Namespace(heartOfGold.getNamespace()); + heartOfGoldNamespace.setPermissions(newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); + + Repository puzzle42 = RepositoryTestData.create42Puzzle(); + puzzle42.setNamespace("guide"); + puzzle42.setId("two"); + Namespace puzzleNamespace = new Namespace(puzzle42.getNamespace()); + puzzleNamespace.setPermissions(newArrayList(new RepositoryPermission(group, asList("read", "pull", "push"), true))); + + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); + when(namespaceDao.get(heartOfGold.getNamespace())).thenReturn(of(heartOfGoldNamespace)); + when(namespaceDao.get(puzzle42.getNamespace())).thenReturn(of(puzzleNamespace)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -228,15 +266,15 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList( + heartOfGold.setPermissions(newArrayList( new RepositoryPermission("trillian", "user role", false), new RepositoryPermission("trillian", "system role", false) )); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); RepositoryPermission permission = new RepositoryPermission(group, "group role", true); - puzzle42.setPermissions(Lists.newArrayList(permission)); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); + puzzle42.setPermissions(newArrayList(permission)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold, puzzle42)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -272,7 +310,7 @@ public class DefaultAuthorizationCollectorTest { heartOfGold.setPermissions(singletonList( new RepositoryPermission("trillian", "unknown", false) )); - when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold)); + when(repositoryDAO.getAll()).thenReturn(newArrayList(heartOfGold)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -290,7 +328,7 @@ public class DefaultAuthorizationCollectorTest { StoredAssignedPermission p1 = new StoredAssignedPermission("one", new AssignedPermission("one", "one:one")); StoredAssignedPermission p2 = new StoredAssignedPermission("two", new AssignedPermission("two", "two:two")); - when(securitySystem.getPermissions(any())).thenReturn(Lists.newArrayList(p1, p2)); + when(securitySystem.getPermissions(any())).thenReturn(newArrayList(p1, p2)); // execute and assert AuthorizationInfo authInfo = collector.collect();