Merge remote-tracking branch 'origin/develop' into bugfix/branchselector_overflow

This commit is contained in:
Florian Scholdei
2020-09-21 11:06:31 +02:00
63 changed files with 2129 additions and 345 deletions

View File

@@ -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))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -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.

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<RepositoryPermission> permissions = new HashSet<>();
public Namespace(String namespace) {
this.namespace = namespace;
}
/**
* Constructor for JaxB, only.
*/
Namespace() {
}
public String getNamespace() {
return namespace;
}
public Collection<RepositoryPermission> getPermissions() {
return unmodifiableCollection(permissions);
}
public void setPermissions(Collection<RepositoryPermission> 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);
}
}
}

View File

@@ -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<Namespace> {
public NamespaceEvent(HandlerEventType eventType, Namespace namespace) {
super(eventType, namespace);
}
public NamespaceEvent(HandlerEventType eventType, Namespace namespace, Namespace oldNamespace) {
super(eventType, namespace, oldNamespace);
}
}

View File

@@ -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<Namespace> get(String namespace);
/**
* Returns a {@link java.util.Collection} of all namespaces.
*
* @return all namespaces
*/
Collection<Namespace> getAll();
/**
* Modifies the given namespace.
*
* @param namespace The namespace to be modified.
*/
void modify(Namespace namespace);
}

View File

@@ -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<Namespace> {
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;
}
}

View File

@@ -28,6 +28,7 @@ package sonia.scm.repository;
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}.
*

View File

@@ -27,7 +27,7 @@ import classNames from "classnames";
import styled from "styled-components";
type Props = {
name: string;
name: ReactNode;
url?: string;
elements: ReactNode[];
};

View File

@@ -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);
}

View File

@@ -58,5 +58,6 @@ export type NamespaceCollection = {
export type RepositoryGroup = {
name: string;
namespace?: Namespace;
repositories: Repository[];
};

View File

@@ -0,0 +1,14 @@
{
"namespaceRoot": {
"menu": {
"navigationLabel": "Namespace",
"settingsNavLink": "Einstellungen",
"permissionsNavLink": "Berechtigungen"
}
},
"repositoryOverview": {
"settings": {
"tooltip": "Einstellungen für den Namespace"
}
}
}

View File

@@ -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": {

View File

@@ -0,0 +1,14 @@
{
"namespaceRoot": {
"menu": {
"navigationLabel": "Namespace",
"settingsNavLink": "Settings",
"permissionsNavLink": "Permissions"
}
},
"repositoryOverview": {
"settings": {
"tooltip": "Namespace related settings"
}
}
}

View File

@@ -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": {

View File

@@ -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<Props> {
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

View File

@@ -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<Props> {
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<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
role,

View File

@@ -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<Props> {
<ProtectedRoute exact path="/repos/:namespace" component={Overview} authenticated={authenticated} />
<ProtectedRoute exact path="/repos/:namespace/:page" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />
<ProtectedRoute path="/namespace/:namespaceName" component={NamespaceRoot} authenticated={authenticated} />
<Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} />
<ProtectedRoute path="/users/create" component={CreateUser} authenticated={authenticated} />

View File

@@ -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<Props> {
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<Props> {
};
render() {
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const { me, t } = this.props;

View File

@@ -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<Props> {
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<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
group,

View File

@@ -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<Props> {
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") {

View File

@@ -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<Props> {
render() {
const { group } = this.props;
const { group, t } = this.props;
const settingsLink = group.namespace?._links?.permissions && (
<Link to={`/namespace/${group.name}/settings`}>
<Icon color={"is-link"} name={"cog"} title={t("repositoryOverview.settings.tooltip")} />
</Link>
);
const namespaceHeader = (
<>
<Link to={`/repos/${group.name}/`} className={"has-text-dark"}>
{group.name}
</Link>{" "}
{settingsLink}
</>
);
const entries = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
});
return <CardColumnGroup name={group.name} url={`/repos/${group.name}/`} elements={entries} />;
return <CardColumnGroup name={namespaceHeader} elements={entries} />;
}
}
export default RepositoryGroupEntry;
export default withTranslation("namespaces")(RepositoryGroupEntry);

View File

@@ -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<Props> {
render() {
const { repositories } = this.props;
const { repositories, namespaces } = this.props;
const groups = groupByNamespace(repositories);
const groups = groupByNamespace(repositories, namespaces);
return (
<div className="content">
{groups.map(group => {

View File

@@ -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);
});

View File

@@ -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);
}

View File

@@ -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<Props> {
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<Props> {
return null;
}
const url = this.stripEndingSlash(match.url);
const url = urls.stripEndingSlash(match.url);
return (
<>

View File

@@ -76,7 +76,7 @@ const DangerZone: FC<Props> = ({ repository, indexLinks }) => {
<>
<hr />
<Subtitle subtitle={t("repositoryForm.dangerZone")} />
<DangerZoneContainer>{dangerZone.map(entry => entry)}</DangerZoneContainer>
<DangerZoneContainer>{dangerZone}</DangerZoneContainer>
</>
);
};

View File

@@ -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<Props> {
@@ -59,21 +59,10 @@ class EditRepo extends React.Component<Props> {
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,

View File

@@ -133,12 +133,12 @@ class Overview extends React.Component<Props> {
}
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 (
<>
<RepositoryList repositories={collection._embedded.repositories} />
<RepositoryList repositories={collection._embedded.repositories} namespaces={namespaces} />
<LinkPaginator collection={collection} page={page} filter={urls.getQueryStringFromLocation(location)} />
</>
);

View File

@@ -159,7 +159,9 @@ const RenameRepository: FC<Props> = ({ repository, indexLinks }) => {
<p>
<strong>{t("renameRepo.subtitle")}</strong>
<br />
{t("renameRepo.description")}
{t("renameRepo.description1")}
<br />
{t("renameRepo.description2")}
</p>
}
right={

View File

@@ -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<Props> {
}
}
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<Props> {
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<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
repository,

View File

@@ -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;
}
}

View File

@@ -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<Props> {
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 (
<ErrorPage title={t("namespaceRoot.errorTitle")} subtitle={t("namespaceRoot.errorSubtitle")} error={error} />
);
}
if (!namespace || loading) {
return <Loading />;
}
return (
<StateMenuContextProvider>
<Page title={namespaceName}>
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>
<Redirect exact from={`${url}/settings`} to={`${url}/settings/permissions`} />
<Route
path={`${url}/settings/permissions`}
render={() => {
return <Permissions namespace={namespaceName} />;
}}
/>
</Switch>
</PrimaryContentColumn>
<SecondaryNavigationColumn>
<SecondaryNavigation label={t("namespaceRoot.menu.navigationLabel")}>
<ExtensionPoint name="namespace.navigation.topLevel" props={extensionProps} renderAll={true} />
<ExtensionPoint name="namespace.route" props={extensionProps} renderAll={true} />
<SubNavigation
to={`${url}/settings`}
label={t("namespaceRoot.menu.settingsNavLink")}
title={t("namespaceRoot.menu.settingsNavLink")}
>
<PermissionsNavLink permissionUrl={`${url}/settings/permissions`} namespace={namespace} />
<ExtensionPoint name="namespace.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
</SecondaryNavigation>
</SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns>
</Page>
</StateMenuContextProvider>
);
}
}
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));

View File

@@ -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<Props> {
hasPermissionsLink = () => {
return this.props.namespace?._links?.permissions;
};
render() {
if (!this.hasPermissionsLink()) {
return null;
}
const { permissionUrl, t } = this.props;
return <NavLink to={permissionUrl} label={t("namespaceRoot.menu.permissionsNavLink")} />;
}
}
export default withTranslation("namespaces")(PermissionsNavLink);

View File

@@ -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));
}
};

View File

@@ -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;
};

View File

@@ -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) {

View File

@@ -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<Props> = ({ 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 <ErrorNotification error={error} />;
}
@@ -84,7 +74,7 @@ const TagRoot: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />;
}
const url = matchedUrl();
const url = urls.matchedUrlFromMatch(match);
return (
<Switch>

View File

@@ -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<Props> {
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<Props> {
return <Loading />;
}
const url = this.matchedUrl();
const url = urls.matchedUrl(this.props);
const extensionProps = {
user,

View File

@@ -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<RepositoryPermission> 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());
}
}

View File

@@ -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> namespacePermissionResource;
@Inject
public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper) {
public NamespaceResource(RepositoryManager manager, NamespaceToNamespaceDtoMapper namespaceMapper, Provider<NamespacePermissionResource> 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();
}
}

View File

@@ -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());
}
}

View File

@@ -28,6 +28,8 @@ 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<RepositoryPermissionDto> 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<RepositoryPermissionDto> repositoryPermissionDtoList) {
return embeddedBuilder()
.with("permissions", repositoryPermissionDtoList)

View File

@@ -26,13 +26,14 @@ 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())

View File

@@ -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();
}
}
}

View File

@@ -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);

View File

@@ -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<Namespace> get(String namespace) {
return repositoryManager
.getAllNamespaces()
.stream()
.filter(n -> n.equals(namespace))
.map(this::createNamespaceForName)
.findFirst();
}
@Override
public Collection<Namespace> 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<String> 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));
}
}

View File

@@ -331,6 +331,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
return getAll(null, start, limit);
}
/**
* @deprecated Use {@link NamespaceManager#getAll()} instead.
*/
@Deprecated
@Override
public Collection<String> getAllNamespaces() {
return getAll().stream()

View File

@@ -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<Namespace> store;
@Inject
NamespaceDao(DataStoreFactory storeFactory) {
this.store = storeFactory.withType(Namespace.class).withName("namespaces").build();
}
public Optional<Namespace> 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);
}
}

View File

@@ -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<RepositoryPermission> newPermissions, Collection<RepositoryPermission> permissionsBeforeModification) {
return !(newPermissions.containsAll(permissionsBeforeModification) && permissionsBeforeModification.containsAll(newPermissions));
}
private void fireEventForEveryUser() {

View File

@@ -24,8 +24,6 @@
package sonia.scm.security;
//~--- non-JDK imports --------------------------------------------------------
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Objects;
@@ -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<String> builder,
Repository repository, User user, Set<String> groups)
{
Collection<RepositoryPermission> repositoryPermissions = repository.getPermissions();
Optional<Namespace> 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;
}

View File

@@ -44,6 +44,12 @@
<permission>
<value>repository:create</value>
</permission>
<permission>
<value>namespace:permissionRead</value>
</permission>
<permission>
<value>namespace:permissionWrite</value>
</permission>
<permission>
<value>user:*</value>
</permission>

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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());
}
}
}
}

View File

@@ -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;
}

View File

@@ -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<Namespace>()));
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> 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<Namespace> 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<Namespace> 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));
}
}
}

View File

@@ -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;

View File

@@ -27,7 +27,6 @@ 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();