Permission Overview

Adds an overview of the permissions of a user including its groups. To do so, a new cache is introduced that stores the groups of a user, when the user is authenticated. In doing so, SCM-Manager can also list groups assigned by external authentication plugins such as LDAP. On the other hand, the user has to have been logged in at least once to get external groups, and even then the cached groups may be out of date when the overview is created. Internal groups will always be added correctly, nonetheless.

Due to the cache, another problem arised: On some logins, the xml dao for the cache failed to be read, because it was read and written at the same time. To fix this, a more thorough synchronization of the stores has been implemented.

Committed-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Rene Pfeuffer
2023-02-09 10:29:05 +01:00
committed by SCM-Manager
parent 864ed9072d
commit e1b107849e
39 changed files with 1748 additions and 151 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -25,3 +25,19 @@ Die Detailseite eines Benutzers zeigt die Informationen zu diesem an.
Die Checkbox `Extern` zeigt an, ob es sich um einen internen Benutzer handelt oder der Benutzer von einem Fremdsystem verwaltet wird.
![Benutzer Informationen](assets/user-information.png)
### Berechtigungsübersicht
Am unteren Ende der Detailseite kann für einen Benutzer eine Berechtigunsübersicht geladen werden.
Diese Übersicht listet alle Gruppen, denen der Benutzer im SCM-Manager zugewiesen ist.
Hat sich der Benutzer bereits mindestens einmal angemeldet, so werden darüber hinaus auch alle
Gruppen berücksichtigt, die durch externe Berechtigungssysteme (wie z. B. LDAP oder CAS) mitgegeben
wurden. Gruppen mit konfigurierten Berechtigungen sind mit einem Haken markiert.
Externe Gruppen, die im SCM-Manager noch nicht angelegt sind, können separat gelistet werden.
Darunter werden alle Namespaces und Repositories gelistet, bei denen für den Benutzer oder
eine seiner Gruppen eine Berechtigung konfiguriert ist.
Die einzelnen Einstellungsseiten für die Berechtigungen können direkt über die Stifte angesprungen
werden. Bei bisher noch nicht bekannten Gruppen können diese direkt erstellt werden.
![Benutzer Informationen](assets/user-permission-overview.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -22,3 +22,20 @@ The user details page shows the information about the user.
The active box shows whether the user is able to use SCM-Manager. The external box shows if it is an internal user or whether it is managed by an external system.
![User-Information](assets/user-information.png)
### Permission Overview
At the bottom of the detail page, a permission overview can be opened.
This overview lists all groups, the user has been assigned to in SCM-Manager. If the user has
been logged in at least once, also groups assigned by external authorization systems (like LDAP or CAS)
will be listed. Groups with configured permissions are marked with a checkmark.
External groups that have not been created in SCM-Manager can be seen in an extra table.
Below, all namespaces and repositories are listed, for whom permissions for the user or any of its groups
have been configured.
The single permission configurations can be accessed directly using the edit icons. Currently unknown
groups can be created directly.
![Benutzer Informationen](assets/user-permission-overview.png)

View File

@@ -31,4 +31,13 @@ public interface GroupCollector {
String AUTHENTICATED = "_authenticated";
Set<String> collect(String principal);
/**
* Returns the groups of the user that had been assigned at the last login (including all
* external groups) and the current internal groups associated to the user. If the
* user had not logged in before, only the current internal groups will be returned.
*
* @since 2.42.0
*/
Set<String> fromLastLoginPlusInternal(String principal);
}

View File

@@ -30,6 +30,7 @@ import sonia.scm.Manager;
import sonia.scm.search.Searchable;
import java.util.Collection;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------
@@ -51,5 +52,12 @@ public interface GroupManager
*
* @return all groups assigned to the given member
*/
public Collection<Group> getGroupsForMember(String member);
Collection<Group> getGroupsForMember(String member);
/**
* Returns a {@link Set} of all group names.
*
* @since 2.42.0
*/
Set<String> getAllNames();
}

View File

@@ -30,6 +30,7 @@ import sonia.scm.ManagerDecorator;
import sonia.scm.search.SearchRequest;
import java.util.Collection;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------
@@ -100,7 +101,10 @@ public class GroupManagerDecorator
return decorated.getGroupsForMember(member);
}
//~--- fields ---------------------------------------------------------------
@Override
public Set<String> getAllNames() {
return decorated.getAllNames();
}
/** Field description */
private final GroupManager decorated;

View File

@@ -0,0 +1,74 @@
/*
* 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.xml;
import sonia.scm.util.Util;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class XmlMapMultiStringAdapter
extends XmlAdapter<XmlMapMultiStringElement[], Map<String, Set<String>>> {
@Override
public XmlMapMultiStringElement[] marshal(Map<String, Set<String>> map) throws Exception {
XmlMapMultiStringElement[] elements;
if (Util.isNotEmpty(map)) {
int i = 0;
int s = map.size();
elements = new XmlMapMultiStringElement[s];
for (Map.Entry<String, Set<String>> e : map.entrySet()) {
elements[i] = new XmlMapMultiStringElement(e.getKey(), e.getValue());
i++;
}
} else {
elements = new XmlMapMultiStringElement[0];
}
return elements;
}
@Override
public Map<String, Set<String>> unmarshal(XmlMapMultiStringElement[] elements)
throws Exception
{
Map<String, Set<String>> map = new HashMap<>();
if (elements != null)
{
for (XmlMapMultiStringElement e : elements)
{
map.put(e.getKey(), e.getValue());
}
}
return map;
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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.xml;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.util.Set;
@XmlRootElement(name = "element")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlMapMultiStringElement {
private String key;
@XmlJavaTypeAdapter(XmlSetStringAdapter.class)
private Set<String> value;
public XmlMapMultiStringElement() {}
public XmlMapMultiStringElement(String key, Set<String> value) {
this.key = key;
this.value = value;
}
public String getKey() {
return key;
}
public Set<String> getValue() {
return value;
}
public void setKey(String key) {
this.key = key;
}
public void setValue(Set<String> value) {
this.value = value;
}
}

View File

@@ -38,6 +38,8 @@ import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import java.nio.file.Path;
import static sonia.scm.store.CopyOnWrite.compute;
public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class);
@@ -54,6 +56,7 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
public Repository read(Path path) {
LOG.trace("read repository metadata from {}", path);
return compute(() -> {
try {
return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile());
} catch (JAXBException ex) {
@@ -61,6 +64,7 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex
);
}
}).withLockedFile(path);
}
void write(Path path, Repository repository) {

View File

@@ -43,6 +43,8 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import static sonia.scm.store.CopyOnWrite.execute;
class PathDatabase {
private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class);
@@ -122,6 +124,7 @@ class PathDatabase {
void read(OnRepositories onRepositories, OnRepository onRepository) {
LOG.trace("read repository path database from {}", storePath);
execute(() -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) {
while (reader.hasNext()) {
@@ -143,6 +146,7 @@ class PathDatabase {
ex
);
}
}).withLockedFile(storePath);
}
private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException {

View File

@@ -28,11 +28,13 @@ import com.google.common.util.concurrent.Striped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
/**
* CopyOnWrite creates a copy of the target file, before it is modified. This should prevent empty or incomplete files
@@ -46,23 +48,56 @@ public final class CopyOnWrite {
private static final Logger LOG = LoggerFactory.getLogger(CopyOnWrite.class);
private static final Striped<Lock> concurrencyLock = Striped.lock(10);
private static final Striped<Lock> concurrencyLock = Striped.lock(20);
private CopyOnWrite() {
}
public static void withTemporaryFile(FileWriter writer, Path targetFile) {
validateInput(targetFile);
Lock lock = concurrencyLock.get(targetFile.toString());
try {
lock.lock();
execute(() -> {
Path temporaryFile = createTemporaryFile(targetFile);
executeCallback(writer, targetFile, temporaryFile);
replaceOriginalFile(targetFile, temporaryFile);
}).withLockedFile(targetFile);
}
public static <R> FileLocker<R> compute(Supplier<R> supplier) {
return new FileLocker<>(supplier);
}
public static FileLocker<Void> execute(Runnable runnable) {
return new FileLocker<>(() -> {
runnable.run();
return null;
});
}
public static class FileLocker<R> {
private final Supplier<R> supplier;
public FileLocker(Supplier<R> supplier) {
this.supplier = supplier;
}
public R withLockedFile(Path file) {
return withLockedFile(file.toAbsolutePath().toString());
}
public R withLockedFile(File file) {
return withLockedFile(file.getPath());
}
public R withLockedFile(String file) {
Lock lock = concurrencyLock.get(file);
lock.lock();
try {
return supplier.get();
} finally {
lock.unlock();
}
}
}
@SuppressWarnings("squid:S3725") // performance of Files#isDirectory
private static void validateInput(Path targetFile) {

View File

@@ -44,6 +44,7 @@ import java.util.Map.Entry;
import java.util.function.Predicate;
import static javax.xml.stream.XMLStreamConstants.END_ELEMENT;
import static sonia.scm.store.CopyOnWrite.execute;
public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V> {
@@ -70,19 +71,21 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
this.type = type;
this.context = context;
// initial load
execute(() -> {
if (file.exists()) {
load();
}
}).withLockedFile(file);
}
@Override
public void clear() {
LOG.debug("clear configuration store");
synchronized (file) {
execute(() -> {
entries.clear();
store();
}
}).withLockedFile(file);
}
@Override
@@ -98,20 +101,20 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
public void put(String id, V item) {
LOG.debug("put item {} to configuration store", id);
synchronized (file) {
execute(() -> {
entries.put(id, item);
store();
}
}).withLockedFile(file);
}
@Override
public void remove(String id) {
LOG.debug("remove item {} from configuration store", id);
synchronized (file) {
execute(() -> {
entries.remove(id);
store();
}
}).withLockedFile(file);
}
@Override
@@ -135,7 +138,7 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
private void load() {
LOG.debug("load configuration from {}", file);
execute(() ->
context.withUnmarshaller(u -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) {
@@ -175,7 +178,7 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
}
}
}
});
})).withLockedFile(file);
}
private void store() {

View File

@@ -32,6 +32,9 @@ import java.io.File;
import java.io.IOException;
import java.util.function.BooleanSupplier;
import static sonia.scm.store.CopyOnWrite.compute;
import static sonia.scm.store.CopyOnWrite.execute;
/**
* JAXB implementation of {@link ConfigurationStore}.
*
@@ -69,11 +72,15 @@ public class JAXBConfigurationStore<T> extends AbstractStore<T> {
protected T readObject() {
LOG.debug("load {} from store {}", type, configFile);
return compute(
() -> {
if (configFile.exists()) {
return context.unmarshall(configFile);
}
return null;
}
).withLockedFile(configFile);
}
@Override
protected void writeObject(T object) {
@@ -87,10 +94,12 @@ public class JAXBConfigurationStore<T> extends AbstractStore<T> {
@Override
protected void deleteObject() {
LOG.debug("deletes {}", configFile.getPath());
execute(() -> {
try {
IOUtil.delete(configFile);
} catch (IOException e) {
throw new StoreException("Failed to delete store object " + configFile.getPath(), e);
}
}).withLockedFile(configFile);
}
}

View File

@@ -35,6 +35,8 @@ import javax.xml.bind.Marshaller;
import java.io.File;
import java.util.Map;
import static sonia.scm.store.CopyOnWrite.compute;
/**
* Jaxb implementation of {@link DataStore}.
*
@@ -106,10 +108,12 @@ public class JAXBDataStore<T> extends FileBasedStore<T> implements DataStore<T>
@Override
protected T read(File file) {
return compute(() -> {
if (file.exists()) {
LOG.trace("try to read {}", file);
return context.unmarshall(file);
}
return null;
}).withLockedFile(file);
}
}

View File

@@ -24,7 +24,7 @@
import { ApiResult, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, Me, User, UserCollection, UserCreation } from "@scm-manager/ui-types";
import { Link, Me, PermissionOverview, User, UserCollection, UserCreation } from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { concat } from "./urls";
@@ -65,6 +65,13 @@ export const useUser = (name: string): ApiResult<User> => {
);
};
export const useUserPermissionOverview = (user: User): ApiResult<PermissionOverview> => {
const overviewLink = user._links.permissionOverview as Link;
return useQuery<PermissionOverview, Error>(["user", user.name, "permissionOverview"], () =>
apiClient.get(overviewLink.href).then((response) => response.json())
);
};
const createUser = (link: string) => {
return (user: UserCreation) => {
return apiClient

View File

@@ -48,3 +48,20 @@ export type UserCreation = User;
export type UserCollection = PagedCollection<{
users: User[];
}>;
export type PermissionOverview = HalRepresentation & {
relevantGroups: PermissionOverviewGroupEntry[];
relevantNamespaces: string[];
relevantRepositories: PermissionOverviewRepositoryEntry[];
};
export type PermissionOverviewGroupEntry = {
name: string;
permissions: boolean;
externalOnly: boolean;
};
export type PermissionOverviewRepositoryEntry = {
namespace: string;
name: string;
};

View File

@@ -30,9 +30,6 @@
"noUsers": "Keine Benutzer gefunden.",
"createButton": "Benutzer erstellen"
},
"overview": {
"filterUser": "Benutzer filtern"
},
"singleUser": {
"errorTitle": "Fehler",
"errorSubtitle": "Unbekannter Benutzer Fehler",
@@ -47,6 +44,9 @@
"setApiKeyNavLink": "API Schlüssel"
}
},
"overview": {
"filterUser": "Benutzer filtern"
},
"createUser": {
"title": "Benutzer erstellen",
"subtitle": "Erstellen eines neuen Benutzers",
@@ -172,5 +172,27 @@
"submit": "Ja",
"cancel": "Nein"
}
},
"permissionOverview": {
"title": "Berechtigungsübersicht",
"help": "Nach einer Anmeldung dieses Kontos basiert diese Übersicht auf den Gruppen, die diesem Konto bei der letzten Anmeldung zugeordnet wurden. Daher beinhaltet sie auch solche Gruppen, die nicht im SCM-Manager eingerichtet sind, sondern von externen Systemen bereitgestellt wurden (wie z. B. LDAP). Da solche externen Daten auf der letzten Anmeldung basieren, stellt diese Übersicht unter Umständen nicht den aktuellen Stand dar. Gab es noch keine Anmeldung mit diesem Konto, so werden nur die intern zugewiesenen Gruppen aufgelistet.",
"groups": {
"noRepositoriesFound": "Keine Namespaces oder Repositories mit Berechtigungen vorhanden.",
"showGroupsWithoutPermission": "Noch nicht in SCM-Manager angelegte fremde Gruppen anzeigen",
"showGroupsWithoutPermissionHelp": "Diese Option zeigt fremde Gruppen an. Dies sind Gruppen, die im SCM-Manager nicht bekannt sind, sondern von externen Systemen stammen. Um diese Gruppen zu berechtigen, müssen sie zunächst im SCM-Manager (als externe Gruppen) angelegt werden.",
"noGroupsFound": "Keine Gruppen vorhanden.",
"noUnknownGroupsFound": "Keine weiteren Gruppen vorhanden.",
"groupName": "Name",
"permissionsConfigured": "Berechtigungen gesetzt",
"createGroup": "Erstellen",
"editPermissions": "Bearbeiten"
},
"repositories": {
"subtitle": "Namespaces / Repositories",
"namespaceName": "Namespace / Name",
"permissionsConfigured": "Berechtigungen gesetzt",
"editPermissions": "Bearbeiten"
},
"edit": "bearbeiten"
}
}

View File

@@ -172,5 +172,27 @@
"submit": "Yes",
"cancel": "No"
}
},
"permissionOverview": {
"title": "Permission Overview",
"help": "If the user has logged in at leas once, this overview is based on the groups assigned to the user at the last login. Therefor this also includes groups, that have not been set up in SCM-Manager but were provided by external systems (such as LDAP). Because this view is based on the last login of the user, this might not reflect the current state of the external groups if the user would log in now. If the user has never logged in before, only the internal groups for this user will be listed.",
"groups": {
"noRepositoriesFound": "No namespaces/repositories available.",
"showGroupsWithoutPermission": "Show external groups not yet created in SCM-Manager",
"showGroupsWithoutPermissionHelp": "This option shows foreign groups. These are groups, that have come from external systems and are unknown to SCM-Manager. The assign permissions for these groups, they have to be created in SCM-Manager first (as external groups).",
"noGroupsFound": "No groups available.",
"noUnknownGroupsFound": "No further groups available.",
"groupName": "Name",
"permissionsConfigured": "Permissions set",
"createGroup": "Create",
"editPermissions": "Edit"
},
"repositories": {
"subtitle": "Namespaces / Repositories",
"namespaceName": "Namespace / Name",
"permissionsConfigured": "Permissions set",
"editPermissions": "Edit"
},
"edit": "edit"
}
}

View File

@@ -41,12 +41,14 @@ type Props = {
loading?: boolean;
group?: Group;
loadUserSuggestions: (p: string) => Promise<SelectValue[]>;
transmittedName?: string;
transmittedExternal?: boolean;
};
const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions }) => {
const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions, transmittedName = "", transmittedExternal = false }) => {
const [t] = useTranslation("groups");
const [groupState, setGroupState] = useState({
name: "",
name: transmittedName,
description: "",
_embedded: {
members: [] as Member[]
@@ -54,7 +56,7 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions
_links: {},
members: [] as string[],
type: "",
external: false
external: transmittedExternal
});
const [nameValidationError, setNameValidationError] = useState(false);

View File

@@ -22,9 +22,9 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Redirect } from "react-router-dom";
import { Redirect, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useCreateGroup, useUserSuggestions } from "@scm-manager/ui-api";
import { useCreateGroup, useUserSuggestions, urls } from "@scm-manager/ui-api";
import { Page } from "@scm-manager/ui-components";
import GroupForm from "../components/GroupForm";
@@ -32,6 +32,7 @@ const CreateGroup: FC = () => {
const [t] = useTranslation("groups");
const { isLoading, create, error, group } = useCreateGroup();
const userSuggestions = useUserSuggestions();
const location = useLocation();
if (group) {
return <Redirect to={`/group/${group.name}`} />;
@@ -40,7 +41,13 @@ const CreateGroup: FC = () => {
return (
<Page title={t("addGroup.title")} subtitle={t("addGroup.subtitle")} error={error || undefined}>
<div>
<GroupForm submitForm={create} loading={isLoading} loadUserSuggestions={userSuggestions} />
<GroupForm
submitForm={create}
loading={isLoading}
loadUserSuggestions={userSuggestions}
transmittedName={urls.getValueStringFromLocationByKey(location, "name")}
transmittedExternal={urls.getValueStringFromLocationByKey(location, "external") === "true"}
/>
</div>
</Page>
);

View File

@@ -0,0 +1,295 @@
/*
* 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, { FC, useState } from "react";
import { Checkbox, ErrorNotification, Icon, Loading, Notification } from "@scm-manager/ui-components";
import {
Group,
Link as HalLink,
Links,
Namespace,
PermissionOverview as Data,
PermissionOverviewGroupEntry,
Repository,
User,
} from "@scm-manager/ui-types";
import styled from "styled-components";
import { useUserPermissionOverview } from "@scm-manager/ui-api";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
const NamespaceColumn = styled.th`
width: 1rem;
`;
const EditIcon: FC = () => {
const [t] = useTranslation("users");
return <Icon alt={t("permissionOverview.edit")} name="pen" />;
};
const EditLink: FC<{ links?: Links; to: string }> = ({ links, to }) => {
if (!links?.permissions) {
return null;
}
return (
<Link to={to}>
<EditIcon />
</Link>
);
};
const ElementLink: FC<{ link?: HalLink; to: string }> = ({ link, to, children }) => {
if (!link) {
return <>{children}</>;
}
return <Link to={to}>{children}</Link>;
};
const GroupRow: FC<{ entry: PermissionOverviewGroupEntry; group?: Group }> = ({ entry, group }) => (
<tr>
<td>
<ElementLink to={`/group/${entry.name}/`} link={group?._links?.self as HalLink}>
{entry.name}
</ElementLink>
</td>
<td align="center">{entry.permissions && <Icon name="check" />}</td>
<td align="center">
<EditLink to={`/group/${entry.name}/settings/permissions`} links={group?._links} />
</td>
</tr>
);
const NotCreatedGroupRow: FC<{ entry: PermissionOverviewGroupEntry }> = ({ entry }) => (
<tr>
<td>{entry.name}</td>
<td align="center">
<Link to={`/groups/create/?name=${entry.name}&external=true`}>
<EditIcon />
</Link>
</td>
</tr>
);
const RepositoryNamespaceRows: FC<{
entry: { namespace: Namespace; repositories: Repository[] };
relevant: boolean;
}> = ({ entry, relevant }) => (
<>
<NamespaceRow key={entry.namespace.namespace} namespace={entry.namespace} relevant={relevant} />
{entry.repositories.map((repository) => (
<RepositoryRow key={`${repository.namespace}/${repository.name}`} entry={repository} />
))}
</>
);
const NamespaceRow: FC<{ namespace: Namespace; relevant: boolean }> = ({ namespace, relevant }) => (
<tr>
<td colSpan={2}>
<Link to={`/repos/${namespace.namespace}/`}>{namespace.namespace}</Link>
</td>
<td align="center">{relevant && <Icon name="check" />}</td>
<td align="center">
<EditLink links={namespace._links} to={`/namespace/${namespace.namespace}/settings/permissions`} />
</td>
</tr>
);
const RepositoryRow: FC<{ entry: Repository }> = ({ entry }) => (
<tr>
<td />
<td>
<Link to={`/repo/${entry.namespace}/${entry.name}/`}>{entry.name}</Link>
</td>
<td align="center">
<Icon name="check" />
</td>
<td align="center">
<EditLink links={entry._links} to={`/repo/${entry.namespace}/${entry.name}/settings/permissions`} />
</td>
</tr>
);
const GroupTable: FC<{ data: Data }> = ({ data }) => {
const [t] = useTranslation("users");
if (data.relevantGroups.find((entry) => !entry.externalOnly)) {
return (
<table>
<thead>
<tr>
<th>{t("permissionOverview.groups.groupName")}</th>
<th align="center">{t("permissionOverview.groups.permissionsConfigured")}</th>
<th align="center">{t("permissionOverview.groups.editPermissions")}</th>
</tr>
</thead>
<tbody>
{data.relevantGroups
.filter((entry) => !entry.externalOnly)
.map((entry) => (
<GroupRow
key={entry.name}
entry={entry}
group={(data._embedded?.groups as Group[]).find((group) => group.name === entry.name)}
/>
))}
</tbody>
</table>
);
} else {
return <Notification type="info">{t("permissionOverview.groups.noGroupsFound")}</Notification>;
}
};
const GroupsWithoutPermissionTable: FC<{ data: Data }> = ({ data }) => {
const [t] = useTranslation("users");
const [external, setExternal] = useState(false);
let content;
if (!external) {
content = null;
} else if (data.relevantGroups.find((entry) => entry.externalOnly)) {
content = (
<table>
<thead>
<tr>
<th>{t("permissionOverview.groups.groupName")}</th>
<th align="center">{t("permissionOverview.groups.createGroup")}</th>
</tr>
</thead>
<tbody>
{data.relevantGroups
.filter((entry) => entry.externalOnly)
.map((entry) => (
<NotCreatedGroupRow key={entry.name} entry={entry} />
))}
</tbody>
</table>
);
} else {
content = <Notification type="info">{t("permissionOverview.groups.noUnknownGroupsFound")}</Notification>;
}
return (
<>
<Checkbox
label={t("permissionOverview.groups.showGroupsWithoutPermission")}
onChange={setExternal}
helpText={t("permissionOverview.groups.showGroupsWithoutPermissionHelp")}
/>
{content}
</>
);
};
const RepositoryTable: FC<{ data: Data }> = ({ data }) => {
const [t] = useTranslation("users");
if ((!data.relevantNamespaces || data.relevantNamespaces.length === 0) && !data.relevantRepositories) {
return <Notification type="info">{t("permissionOverview.groups.noRepositoriesFound")}</Notification>;
}
data.relevantRepositories.sort((r1, r2) =>
r1.namespace === r2.namespace ? (r1.name < r2.name ? -1 : +1) : r1.namespace < r2.namespace ? -1 : +1
);
const findRelevantNamespace = (namespace: string) =>
(data._embedded?.relevantNamespaces as Namespace[]).find((n: Namespace) => n.namespace === namespace);
const findOtherNamespace = (namespace: string) =>
(data._embedded?.otherNamespaces as Namespace[]).find((n: Namespace) => n.namespace === namespace);
const allNamespaces = new Set<string>();
data.relevantRepositories.forEach((repo) => allNamespaces.add(repo.namespace));
data.relevantNamespaces.forEach((namespace) => allNamespaces.add(namespace));
const sortedNamespaces: string[] = Array.from(allNamespaces).sort();
const repositoriesForNamespace = (namespace: string) =>
data.relevantRepositories
.filter((repo) => repo.namespace === namespace)
.map(
(repo) =>
(data._embedded?.repositories as Repository[]).find(
(r: Repository) => r.namespace === repo.namespace && r.name === repo.name
) || ({ ...repo, _links: {} } as Repository)
);
const reposInNamespaces = sortedNamespaces.map((namespace) => {
return {
namespace: findRelevantNamespace(namespace) ||
findOtherNamespace(namespace) || { namespace: namespace, _links: {} },
repositories: repositoriesForNamespace(namespace),
};
});
return (
<table>
<thead>
<tr>
<NamespaceColumn colSpan={2}>{t("permissionOverview.repositories.namespaceName")}</NamespaceColumn>
<th align="center">{t("permissionOverview.repositories.permissionsConfigured")}</th>
<th align="center">{t("permissionOverview.repositories.editPermissions")}</th>
</tr>
</thead>
<tbody>
{reposInNamespaces.map((entry) => (
<RepositoryNamespaceRows
key={entry.namespace.namespace}
entry={entry}
relevant={!!findRelevantNamespace(entry.namespace.namespace)}
/>
))}
</tbody>
</table>
);
};
const PermissionOverview: FC<{ user: User }> = ({ user }) => {
const { data, isLoading, error } = useUserPermissionOverview(user);
const [t] = useTranslation("users");
if (isLoading || !data) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
// To test the table with the "not created" groups, you can mock such data
// with the following statement and assign this in the GroupsWithoutPermissionTable:
// const mockedData = {
// ...data,
// relevantGroups: [...data.relevantGroups, { name: "hitchhiker", permissions: false, externalOnly: true }],
// };
return (
<>
<GroupTable data={data} />
<GroupsWithoutPermissionTable data={data} />
<h4>{t("permissionOverview.repositories.subtitle")}</h4>
<RepositoryTable data={data} />
</>
);
};
export default PermissionOverview;

View File

@@ -21,19 +21,47 @@
* 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 React, { FC, useState } from "react";
import { useTranslation, WithTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
import { Checkbox, createAttributesForTesting, DateFromNow, InfoTable, MailLink } from "@scm-manager/ui-components";
import {
Checkbox,
createAttributesForTesting,
DateFromNow,
Help,
InfoTable,
MailLink
} from "@scm-manager/ui-components";
import { Icon } from "@scm-manager/ui-components";
import PermissionOverview from "../PermissionOverview";
type Props = WithTranslation & {
user: User;
};
class Details extends React.Component<Props> {
render() {
const { user, t } = this.props;
const Details: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [collapsed, setCollapsed] = useState(true);
const toggleCollapse = () => setCollapsed(!collapsed);
let permissionOverview;
if (user._links.permissionOverview) {
let icon = <Icon name="angle-right" color="inherit" alt={t("diff.showContent")} />;
if (!collapsed) {
icon = <Icon name="angle-down" color="inherit" alt={t("diff.hideContent")} />;
}
permissionOverview = (
<div className="content">
<h3 className="is-clickable" onClick={toggleCollapse}>
{icon} {t("permissionOverview.title")} <Help message={t("permissionOverview.help")} />
</h3>
{!collapsed && <PermissionOverview user={user} />}
</div>
);
}
return (
<>
<InfoTable>
<tbody>
<tr>
@@ -76,8 +104,9 @@ class Details extends React.Component<Props> {
</tr>
</tbody>
</InfoTable>
{permissionOverview}
</>
);
}
}
};
export default withTranslation("users")(Details);
export default Details;

View File

@@ -0,0 +1,62 @@
/*
* 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.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.Setter;
import java.util.Collection;
@Getter
@Setter
@SuppressWarnings("java:S2160") // no equals needed in dto
class PermissionOverviewDto extends HalRepresentation {
private Collection<PermissionOverviewDto.GroupEntryDto> relevantGroups;
private Collection<String> relevantNamespaces;
private Collection<RepositoryEntry> relevantRepositories;
PermissionOverviewDto(Links links, Embedded embedded) {
super(links, embedded);
}
@Getter
@Setter
static class GroupEntryDto {
private String name;
private boolean permissions;
private boolean externalOnly;
}
@Getter
@Setter
static class RepositoryEntry {
private String namespace;
private String name;
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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.Embedded;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.group.GroupManager;
import sonia.scm.repository.Repository;
import sonia.scm.user.PermissionOverview;
import javax.inject.Inject;
import java.util.List;
import static de.otto.edison.hal.Links.linkingTo;
import static java.util.stream.Collectors.toList;
@Mapper
abstract class PermissionOverviewToPermissionOverviewDtoMapper {
@Inject
private ResourceLinks resourceLinks;
@Inject
private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper;
@Inject
private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper;
@Inject
private GroupManager groupManager;
@Inject
private GroupToGroupDtoMapper groupToGroupDtoMapper;
abstract PermissionOverviewDto toDto(PermissionOverview permissionOverview, @Context String userName);
abstract PermissionOverviewDto.GroupEntryDto toDto(PermissionOverview.GroupEntry groupEntry);
abstract PermissionOverviewDto.RepositoryEntry toDto(Repository repository);
@ObjectFactory
PermissionOverviewDto createDto(PermissionOverview permissionOverview, @Context String userName) {
List<NamespaceDto> relevantNamespaces = permissionOverview
.getRelevantNamespaces()
.stream()
.map(namespaceToNamespaceDtoMapper::map)
.collect(toList());
List<NamespaceDto> otherNamespaces = permissionOverview
.getRelevantRepositories()
.stream()
.map(Repository::getNamespace)
.distinct()
.filter(namespace -> !permissionOverview.getRelevantNamespaces().contains(namespace))
.map(namespaceToNamespaceDtoMapper::map)
.collect(toList());
List<RepositoryDto> repositories = permissionOverview
.getRelevantRepositories()
.stream()
.map(repositoryToRepositoryDtoMapper::map)
.collect(toList());
List<GroupDto> groups = permissionOverview
.getRelevantGroups()
.stream()
.map(PermissionOverview.GroupEntry::getName)
.map(groupManager::get)
.map(groupToGroupDtoMapper::map)
.collect(toList());
Embedded.Builder embedded = new Embedded.Builder()
.with("relevantNamespaces", relevantNamespaces)
.with("otherNamespaces", otherNamespaces)
.with("repositories", repositories)
.with("groups", groups);
return new PermissionOverviewDto(
linkingTo().self(resourceLinks.user().permissionOverview(userName)).build(),
embedded.build()
);
}
}

View File

@@ -148,6 +148,10 @@ class ResourceLinks {
return userLinkBuilder.method("getUserResource").parameters(name).method("toInternal").parameters().href();
}
public String permissionOverview(String name) {
return userLinkBuilder.method("getUserResource").parameters(name).method("permissionOverview").parameters().href();
}
public String publicKeys(String name) {
return publicKeyLinkBuilder.method("findAll").parameters(name).href();
}

View File

@@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.user.PermissionOverviewCollector;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType;
@@ -44,30 +45,36 @@ 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.MediaType;
import javax.ws.rs.core.Response;
public class UserResource {
private final UserDtoToUserMapper dtoToUserMapper;
private final UserToUserDtoMapper userToDtoMapper;
private final PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper;
private final IdResourceManagerAdapter<User, UserDto> adapter;
private final UserManager userManager;
private final PasswordService passwordService;
private final UserPermissionResource userPermissionResource;
private final PermissionOverviewCollector permissionOverviewCollector;
@Inject
public UserResource(
UserDtoToUserMapper dtoToUserMapper,
public UserResource(UserDtoToUserMapper dtoToUserMapper,
UserToUserDtoMapper userToDtoMapper,
UserManager manager,
PasswordService passwordService, UserPermissionResource userPermissionResource) {
PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper, UserManager manager,
PasswordService passwordService,
UserPermissionResource userPermissionResource,
PermissionOverviewCollector permissionOverviewCollector) {
this.dtoToUserMapper = dtoToUserMapper;
this.userToDtoMapper = userToDtoMapper;
this.permissionOverviewMapper = permissionOverviewMapper;
this.adapter = new IdResourceManagerAdapter<>(manager, User.class);
this.userManager = manager;
this.passwordService = passwordService;
this.userPermissionResource = userPermissionResource;
this.permissionOverviewCollector = permissionOverviewCollector;
}
/**
@@ -298,6 +305,13 @@ public class UserResource {
return Response.noContent().build();
}
@GET
@Path("permissionOverview")
@Produces(MediaType.APPLICATION_JSON)
public PermissionOverviewDto permissionOverview(@PathParam("id") String name) {
return permissionOverviewMapper.toDto(permissionOverviewCollector.create(name), name);
}
@Path("permissions")
public UserPermissionResource permissions() {
return userPermissionResource;

View File

@@ -29,6 +29,7 @@ import de.otto.edison.hal.Links;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ObjectFactory;
import sonia.scm.group.GroupPermissions;
import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
@@ -76,6 +77,9 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
}
if (PermissionPermissions.read().isPermitted()) {
linksBuilder.single(link("permissions", resourceLinks.userPermissions().permissions(user.getName())));
if (GroupPermissions.list().isPermitted()) {
linksBuilder.single(link("permissionOverview", resourceLinks.user().permissionOverview(user.getName())));
}
}
Embedded.Builder embeddedBuilder = embeddedBuilder();

View File

@@ -34,11 +34,15 @@ import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
import sonia.scm.security.Authentications;
import sonia.scm.security.LogoutEvent;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.UserEvent;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;
/**
* Collect groups for a certain principal.
@@ -56,11 +60,14 @@ public class DefaultGroupCollector implements GroupCollector {
private final Cache<String, Set<String>> cache;
private final Set<GroupResolver> groupResolvers;
private final ConfigurationStoreFactory configurationStoreFactory;
@Inject
public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set<GroupResolver> groupResolvers) {
public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set<GroupResolver> groupResolvers, ConfigurationStoreFactory configurationStoreFactory) {
this.groupDAO = groupDAO;
this.cache = cacheManager.getCache(CACHE_NAME);
this.groupResolvers = groupResolvers;
this.configurationStoreFactory = configurationStoreFactory;
}
@Override
@@ -79,9 +86,30 @@ public class DefaultGroupCollector implements GroupCollector {
Set<String> groups = builder.build();
LOG.debug("collected following groups for principal {}: {}", principal, groups);
ConfigurationStore<UserGroupCache> store = createStore();
UserGroupCache persistentCache = getPersistentCache(store);
persistentCache.put(principal, groups);
store.set(persistentCache);
return groups;
}
@Override
public Set<String> fromLastLoginPlusInternal(String principal) {
Set<String> cached = new HashSet<>(getPersistentCache(createStore()).get(principal));
computeInternalGroups(principal).forEach(cached::add);
return cached;
}
private static UserGroupCache getPersistentCache(ConfigurationStore<UserGroupCache> store) {
return store.getOptional().orElseGet(UserGroupCache::new);
}
private ConfigurationStore<UserGroupCache> createStore() {
return configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build();
}
@Subscribe(async = false)
public void clearCacheOnLogOut(LogoutEvent event) {
String principal = event.getPrimaryPrincipal();
@@ -95,12 +123,12 @@ public class DefaultGroupCollector implements GroupCollector {
}
}
private Stream<String> computeInternalGroups(String principal) {
return groupDAO.getAll().stream().filter(group -> group.isMember(principal)).map(Group::getName);
}
private void appendInternalGroups(String principal, ImmutableSet.Builder<String> builder) {
for (Group group : groupDAO.getAll()) {
if (group.isMember(principal)) {
builder.add(group.getName());
}
}
computeInternalGroups(principal).forEach(builder::add);
}
private Set<String> resolveExternalGroups(String principal) {

View File

@@ -37,7 +37,6 @@ import sonia.scm.HandlerEventType;
import sonia.scm.ManagerDaoAdapter;
import sonia.scm.NotFoundException;
import sonia.scm.SCMContextProvider;
import sonia.scm.TransformFilter;
import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil;
import sonia.scm.util.CollectionAppender;
@@ -50,8 +49,11 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import static java.util.stream.Collectors.toSet;
//~--- JDK imports ------------------------------------------------------------
/**
@@ -346,7 +348,11 @@ public class DefaultGroupManager extends AbstractGroupManager
return groupDAO.getLastModified();
}
//~--- methods --------------------------------------------------------------
@Override
public Set<String> getAllNames() {
GroupPermissions.list().check();
return groupDAO.getAll().stream().map(Group::getName).collect(toSet());
}
/**
* Remove duplicate members from group.

View File

@@ -0,0 +1,58 @@
/*
* 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.group;
import sonia.scm.xml.XmlMapMultiStringAdapter;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import static java.util.Collections.emptySet;
@XmlRootElement(name = "user-group-cache")
@XmlAccessorType(XmlAccessType.FIELD)
class UserGroupCache {
@XmlJavaTypeAdapter(XmlMapMultiStringAdapter.class)
private Map<String, Set<String>> cache;
Set<String> get(String user) {
if (cache == null) {
return emptySet();
}
return cache.getOrDefault(user, emptySet());
}
void put(String user, Set<String> groups) {
if (cache == null) {
cache = new HashMap<>();
}
cache.put(user, groups);
}
}

View File

@@ -49,6 +49,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static sonia.scm.store.CopyOnWrite.compute;
abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep {
private static final Logger LOG = LoggerFactory.getLogger(DifferentiateBetweenConfigAndConfigEntryUpdateStep.class);
@@ -90,7 +92,7 @@ abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep {
private void updateSingleFile(Path configFile) {
LOG.info("Updating config entry file: {}", configFile);
Document configEntryDocument = readAsXmlDocument(configFile);
Document configEntryDocument = compute(() -> readAsXmlDocument(configFile)).withLockedFile(configFile);
configEntryDocument.getDocumentElement().setAttribute("type", "config-entry");

View File

@@ -0,0 +1,77 @@
/*
* 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.user;
import lombok.Getter;
import sonia.scm.repository.Repository;
import java.util.Collection;
import static java.util.Collections.unmodifiableCollection;
/**
* The permission overview aggregates groups a user is a member of and all namespaces
* and repositories that have permissions configured for this user or one of its groups.
* This is the result of {@link PermissionOverviewCollector#create(String)}.
*
* @since 2.42.0
*/
public class PermissionOverview {
private final Collection<GroupEntry> relevantGroups;
private final Collection<String> relevantNamespaces;
private final Collection<Repository> relevantRepositories;
public PermissionOverview(Collection<GroupEntry> relevantGroups, Collection<String> relevantNamespaces, Collection<Repository> relevantRepositories) {
this.relevantGroups = relevantGroups;
this.relevantNamespaces = relevantNamespaces;
this.relevantRepositories = relevantRepositories;
}
public Collection<GroupEntry> getRelevantGroups() {
return unmodifiableCollection(relevantGroups);
}
public Collection<String> getRelevantNamespaces() {
return unmodifiableCollection(relevantNamespaces);
}
public Collection<Repository> getRelevantRepositories() {
return unmodifiableCollection(relevantRepositories);
}
@Getter
public static class GroupEntry {
private final String name;
private final boolean permissions;
private final boolean externalOnly;
public GroupEntry(String name, boolean permissions, boolean externalOnly) {
this.name = name;
this.permissions = permissions;
this.externalOnly = externalOnly;
}
}
}

View File

@@ -0,0 +1,114 @@
/*
* 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.user;
import sonia.scm.group.GroupCollector;
import sonia.scm.group.GroupManager;
import sonia.scm.repository.Namespace;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.repository.RepositoryPermissionHolder;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.PermissionOverview.GroupEntry;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static java.util.stream.Collectors.toList;
public class PermissionOverviewCollector {
private final GroupCollector groupCollector;
private final PermissionAssigner permissionAssigner;
private final GroupManager groupManager;
private final RepositoryManager repositoryManager;
private final NamespaceManager namespaceManager;
@Inject
public PermissionOverviewCollector(GroupCollector groupCollector, PermissionAssigner permissionAssigner, GroupManager groupManager, RepositoryManager repositoryManager, NamespaceManager namespaceManager) {
this.groupCollector = groupCollector;
this.permissionAssigner = permissionAssigner;
this.groupManager = groupManager;
this.repositoryManager = repositoryManager;
this.namespaceManager = namespaceManager;
}
public PermissionOverview create(String userId) {
PermissionPermissions.read().check();
Collection<String> groupsFromLastLogin = groupCollector.fromLastLoginPlusInternal(userId);
return new PermissionOverview(
collectGroups(groupsFromLastLogin),
collectNamespaces(userId, groupsFromLastLogin),
collectRepositories(userId, groupsFromLastLogin)
);
}
private Collection<GroupEntry> collectGroups(Collection<String> groupsFromLastLogin) {
Collection<GroupEntry> groupEntries = new ArrayList<>();
Collection<String> allGroups = groupManager.getAllNames();
groupsFromLastLogin.forEach(groupName -> {
Collection<PermissionDescriptor> permissionDescriptors = permissionAssigner.readPermissionsForGroup(groupName);
groupEntries.add(
new GroupEntry(
groupName,
!permissionDescriptors.isEmpty(),
!allGroups.contains(groupName)));
});
return groupEntries;
}
private Collection<String> collectNamespaces(String userId, Collection<String> groupsFromLastLogin) {
return namespaceManager
.getAll()
.stream()
.filter(namespace -> isRelevant(userId, groupsFromLastLogin, namespace))
.map(Namespace::getNamespace)
.collect(toList());
}
private List<Repository> collectRepositories(String userId, Collection<String> groupsFromLastLogin) {
return repositoryManager
.getAll()
.stream()
.filter(repo -> isRelevant(userId, groupsFromLastLogin, repo))
.collect(toList());
}
private static boolean isRelevant(String userId, Collection<String> groupsFromLastLogin, RepositoryPermissionHolder permissionHolder) {
return permissionHolder.getPermissions().stream().anyMatch(permission -> isRelevant(userId, groupsFromLastLogin, permission));
}
private static boolean isRelevant(String userId, Collection<String> groupsFromLastLogin, RepositoryPermission permission) {
return permission.isGroupPermission() && groupsFromLastLogin.contains(permission.getName())
|| !permission.isGroupPermission() && userId.equals(permission.getName());
}
}

View File

@@ -0,0 +1,166 @@
/*
* 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.Links;
import org.junit.jupiter.api.BeforeEach;
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.group.Group;
import sonia.scm.group.GroupManager;
import sonia.scm.repository.Repository;
import sonia.scm.user.PermissionOverview;
import java.net.URI;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PermissionOverviewToPermissionOverviewDtoMapperTest {
public static final Repository REPOSITORY_1 = new Repository("1", "git", "hog", "marvin");
public static final Repository REPOSITORY_2 = new Repository("1", "git", "vogon", "jeltz");
public static final PermissionOverview PERMISSION_OVERVIEW = new PermissionOverview(
asList(
new PermissionOverview.GroupEntry("hitchhiker", true, false),
new PermissionOverview.GroupEntry("vogons", false, true)
),
asList("hog", "earth"),
asList(
REPOSITORY_1,
REPOSITORY_2
)
);
@Mock
private ResourceLinks resourceLinks;
@Mock
private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper;
@Mock
private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper;
@Mock
private GroupManager groupManager;
@Mock
private GroupToGroupDtoMapper groupToGroupDtoMapper;
@InjectMocks
private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewToPermissionOverviewDtoMapper;
@BeforeEach
void initResourceLinks() {
when(resourceLinks.user())
.thenReturn(new ResourceLinks.UserLinks(() -> URI.create("/")));
}
@BeforeEach
void initRepositoryMapper() {
when(repositoryToRepositoryDtoMapper.map(any()))
.thenAnswer(invocation -> {
Repository repository = invocation.getArgument(0, Repository.class);
RepositoryDto repositoryDto = new RepositoryDto();
repositoryDto.setNamespace(repository.getNamespace());
repositoryDto.setName(repository.getName());
return repositoryDto;
});
}
@BeforeEach
void initNamespaceMapper() {
when(namespaceToNamespaceDtoMapper.map(any()))
.thenAnswer(invocation -> new NamespaceDto(invocation.getArgument(0, String.class), Links.emptyLinks()));
}
@BeforeEach
void initGroupMapper() {
when(groupManager.get(anyString()))
.thenAnswer(invocation -> new Group("xml", invocation.getArgument(0, String.class)));
when(groupToGroupDtoMapper.map(any()))
.thenAnswer(invocation -> {
GroupDto groupDto = new GroupDto();
groupDto.setName(invocation.getArgument(0, Group.class).getName());
return groupDto;
});
}
@Test
void shouldMapRepositories() {
PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper
.toDto(PERMISSION_OVERVIEW, "Neo");
assertThat(dto.getRelevantRepositories())
.extracting("namespace")
.contains("hog", "vogon");
assertThat(dto.getRelevantRepositories())
.extracting("name")
.contains("marvin", "jeltz");
assertThat(
dto.
getEmbedded()
.getItemsBy("repositories")
).hasSize(2);
}
@Test
void shouldMapNamespaces() {
PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper
.toDto(PERMISSION_OVERVIEW, "Neo");
assertThat(dto.getRelevantNamespaces())
.contains("hog", "earth");
assertThat(dto.getEmbedded().getItemsBy("relevantNamespaces"))
.hasSize(2)
.extracting("namespace")
.contains("hog", "earth");
assertThat(dto.getEmbedded().getItemsBy("otherNamespaces"))
.hasSize(1)
.extracting("namespace")
.contains("vogon");
}
@Test
void shouldMapGroups() {
PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper
.toDto(PERMISSION_OVERVIEW, "Neo");
assertThat(dto.getRelevantGroups())
.extracting("name")
.contains("hitchhiker", "vogons");
assertThat(dto.getEmbedded().getItemsBy("groups"))
.hasSize(2)
.extracting("name")
.contains("hitchhiker", "vogons");
}
}

View File

@@ -41,10 +41,13 @@ import org.mockito.Mock;
import sonia.scm.ContextEntry;
import sonia.scm.NotFoundException;
import sonia.scm.PageResult;
import sonia.scm.group.GroupManager;
import sonia.scm.security.ApiKeyService;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.ChangePasswordNotAllowedException;
import sonia.scm.user.PermissionOverview;
import sonia.scm.user.PermissionOverviewCollector;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
import sonia.scm.web.RestDispatcher;
@@ -58,6 +61,8 @@ import java.net.URL;
import java.util.Collection;
import java.util.function.Predicate;
import static de.otto.edison.hal.Links.emptyLinks;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
@@ -95,6 +100,16 @@ public class UserRootResourceTest {
private ApiKeyService apiKeyService;
@Mock
private PermissionAssigner permissionAssigner;
@Mock
private PermissionOverviewCollector permissionOverviewCollector;
@Mock
private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper;
@Mock
private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper;
@Mock
private GroupManager groupManager;
@Mock
private GroupToGroupDtoMapper groupToGroupDtoMapper;
@InjectMocks
private UserDtoToUserMapperImpl dtoToUserMapper;
@InjectMocks
@@ -103,6 +118,8 @@ public class UserRootResourceTest {
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
@InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
@InjectMocks
private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewMapper;
@Captor
private ArgumentCaptor<User> userCaptor;
@@ -110,6 +127,7 @@ public class UserRootResourceTest {
private ArgumentCaptor<Predicate<User>> filterCaptor;
private User originalUser;
private MockHttpResponse response = new MockHttpResponse();
@Before
public void prepareEnvironment() {
@@ -125,7 +143,7 @@ public class UserRootResourceTest {
UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper,
userCollectionToDtoMapper, resourceLinks, passwordService);
UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper);
UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource);
UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, permissionOverviewMapper, userManager, passwordService, userPermissionResource, permissionOverviewCollector);
ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks);
UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource),
@@ -137,7 +155,6 @@ public class UserRootResourceTest {
@Test
public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -155,7 +172,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER)
.content(userJson.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -177,7 +193,6 @@ public class UserRootResourceTest {
@SubjectAware(username = "unpriv")
public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -195,7 +210,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123");
dispatcher.invoke(request, response);
@@ -213,7 +227,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("passwordChange", "-"), "xml")).when(userManager).overwritePassword(any(), any());
@@ -231,7 +244,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new NotFoundException("Test", "x")).when(userManager).overwritePassword(any(), any());
@@ -249,7 +261,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123");
dispatcher.invoke(request, response);
@@ -267,7 +278,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER)
.content(userJson);
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123");
dispatcher.invoke(request, response);
@@ -287,7 +297,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo")
.contentType(VndMediaType.USER)
.content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -303,7 +312,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER)
.content(new byte[]{});
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123");
dispatcher.invoke(request, response);
@@ -314,7 +322,6 @@ public class UserRootResourceTest {
@Test
public void shouldGetNotFoundForNotExistentUser() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "nosuchuser");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -324,7 +331,6 @@ public class UserRootResourceTest {
@Test
public void shouldDeleteUser() throws Exception {
MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -342,7 +348,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Other")
.contentType(VndMediaType.USER)
.content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -360,7 +365,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo")
.contentType(VndMediaType.USER)
.content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -373,7 +377,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(1);
when(userManager.getPage(any(), any(), eq(0), eq(10))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -389,7 +392,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(3);
when(userManager.getPage(any(), any(), eq(1), eq(1))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -407,7 +409,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(1);
when(userManager.getPage(filterCaptor.capture(), any(), eq(0), eq(10))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?q=One");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -427,7 +428,6 @@ public class UserRootResourceTest {
@Test
public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -440,7 +440,6 @@ public class UserRootResourceTest {
public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException {
when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*")));
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -455,7 +454,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions")
.contentType(VndMediaType.PERMISSION_COLLECTION)
.content("{\"permissions\":[\"other:*\"]}".getBytes());
MockHttpResponse response = new MockHttpResponse();
ArgumentCaptor<Collection<PermissionDescriptor>> captor = ArgumentCaptor.forClass(Collection.class);
doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture());
@@ -466,6 +464,19 @@ public class UserRootResourceTest {
assertEquals("other:*", captor.getValue().iterator().next().getValue());
}
@Test
public void shouldGetPermissionsOverviewWithNamespaces() throws URISyntaxException, UnsupportedEncodingException {
when(permissionOverviewCollector.create("Neo")).thenReturn(new PermissionOverview(emptyList(), singletonList("hog"), emptyList()));
when(namespaceToNamespaceDtoMapper.map("hog")).thenReturn(new NamespaceDto("hog", emptyLinks()));
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissionOverview");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(response.getContentAsString()).contains("hog");
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/Neo/permissionOverview\"}");
}
@Test
public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException {
when(passwordService.encryptPassword(anyString())).thenReturn("abc");
@@ -474,7 +485,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal")
.contentType(VndMediaType.USER)
.content("{\"newPassword\":\"trillian\"}".getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -492,7 +502,6 @@ public class UserRootResourceTest {
MockHttpRequest request = MockHttpRequest
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external")
.contentType(VndMediaType.USER);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);

View File

@@ -43,7 +43,6 @@ import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
@@ -177,4 +176,16 @@ public class UserToUserDtoMapperTest {
assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref());
}
@Test
public void shouldMapLinks_forPermissionOverview() {
User user = createDefaultUser();
when(subject.isPermitted("permission:read")).thenReturn(true);
when(subject.isPermitted("group:list")).thenReturn(true);
UserDto userDto = mapper.map(user);
assertEquals("expected permissions link", expectedBaseUri.resolve("abc/permissions").toString(), userDto.getLinks().getLinkBy("permissions").get().getHref());
assertEquals("expected permission overview link", expectedBaseUri.resolve("abc/permissionOverview").toString(), userDto.getLinks().getLinkBy("permissionOverview").get().getHref());
}
}

View File

@@ -30,17 +30,21 @@ 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.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.cache.MapCache;
import sonia.scm.cache.MapCacheManager;
import sonia.scm.security.LogoutEvent;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.User;
import sonia.scm.user.UserEvent;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -59,6 +63,11 @@ class DefaultGroupCollectorTest {
@Mock
private GroupResolver groupResolver;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ConfigurationStoreFactory configurationStoreFactory;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ConfigurationStore configurationStore;
private MapCacheManager mapCacheManager;
private Set<GroupResolver> groupResolvers;
@@ -69,7 +78,14 @@ class DefaultGroupCollectorTest {
void initCollector() {
groupResolvers = new HashSet<>();
mapCacheManager = new MapCacheManager();
collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers);
collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers, configurationStoreFactory);
}
@BeforeEach
void initStore() {
when(configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build())
.thenReturn(configurationStore);
when(configurationStore.getOptional()).thenReturn(Optional.empty());
}
@Test
@@ -141,7 +157,16 @@ class DefaultGroupCollectorTest {
verify(groupDAO, never()).getAll();
}
@Test
void shouldGetCachedGroupsFromLastLogin() {
UserGroupCache cache = new UserGroupCache();
cache.put("trillian", Set.of("hog"));
when(configurationStore.getOptional()).thenReturn(Optional.of(cache));
Set<String> cachedGroups = collector.fromLastLoginPlusInternal("trillian");
assertThat(cachedGroups).contains("hog");
}
@Nested
class WithGroupsFromDao {
@@ -169,5 +194,23 @@ class DefaultGroupCollectorTest {
Iterable<String> groupNames = collector.collect("trillian");
assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible");
}
@Test
void shouldGetScmGroupsForLastLoginWhenNothingCached() {
Set<String> cachedGroups = collector.fromLastLoginPlusInternal("trillian");
assertThat(cachedGroups).contains("heartOfGold", "fjordsOfAfrican");
}
@Test
void shouldGetCachedGroupsFromLastLoginWithInternalGroups() {
UserGroupCache cache = new UserGroupCache();
cache.put("trillian", Set.of("earth"));
when(configurationStore.getOptional()).thenReturn(Optional.of(cache));
Set<String> cachedGroups = collector.fromLastLoginPlusInternal("trillian");
assertThat(cachedGroups).contains("earth", "heartOfGold", "fjordsOfAfrican");
}
}
}

View File

@@ -0,0 +1,250 @@
/*
* 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.user;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
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.group.Group;
import sonia.scm.group.GroupCollector;
import sonia.scm.group.GroupManager;
import sonia.scm.repository.Namespace;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PermissionOverviewCollectorTest {
@Mock
private GroupCollector groupCollector;
@Mock
private PermissionAssigner permissionAssigner;
@Mock
private GroupManager groupManager;
@Mock
private RepositoryManager repositoryManager;
@Mock
private NamespaceManager namespaceManager;
@InjectMocks
private PermissionOverviewCollector permissionOverviewCollector;
private final String unknownGroupName = "hog";
private final String knownGroupName = "earth";
@BeforeEach
void mockGroups() {
when(groupCollector.fromLastLoginPlusInternal("trillian"))
.thenReturn(Set.of(unknownGroupName, knownGroupName));
}
@BeforeEach
void mockSubject() {
Subject subject = mock(Subject.class);
ThreadContext.bind(subject);
}
@AfterEach
void clearContext() {
ThreadContext.unbindSubject();
}
@Nested
class WithGroups {
@Test
void shouldCollectGroupsFromGroupCollector() {
when(groupManager.getAllNames()).thenReturn(singleton(knownGroupName));
mockUnknownGroup(unknownGroupName);
mockKnownGroup(knownGroupName);
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
Collection<PermissionOverview.GroupEntry> relevantGroups = permissionOverview.getRelevantGroups();
assertThat(relevantGroups)
.extracting("name")
.contains(unknownGroupName, knownGroupName);
assertThat(relevantGroups)
.extracting("permissions")
.contains(false, true);
assertThat(relevantGroups)
.extracting("externalOnly")
.contains(false, true);
}
private void mockKnownGroup(String knownGroupName) {
when(permissionAssigner.readPermissionsForGroup(knownGroupName))
.thenReturn(singleton(new PermissionDescriptor()));
}
private void mockUnknownGroup(String unknownGroupName) {
when(permissionAssigner.readPermissionsForGroup(unknownGroupName))
.thenReturn(Collections.emptyList());
}
}
@Nested
class WithNamespaces {
private Namespace namespace = new Namespace("git");
@BeforeEach
void mockNamespace() {
when(namespaceManager.getAll())
.thenReturn(singletonList(namespace));
}
@Test
void shouldFindNamespaces() {
namespace.addPermission(new RepositoryPermission("trillian", "read", false));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantNamespaces())
.contains("git");
}
@Test
void shouldFindNamespacesWithPermissionForUser() {
namespace.addPermission(new RepositoryPermission("trillian", "read", false));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantNamespaces())
.contains("git");
}
@Test
void shouldFindNamespaceWithPermissionForGroupOfUser() {
namespace.addPermission(new RepositoryPermission(knownGroupName, "read", true));
when(groupCollector.fromLastLoginPlusInternal("trillian"))
.thenReturn(singleton(knownGroupName));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantNamespaces())
.contains("git");
}
@Test
void shouldIgnoreNamespaceWithPermissionForOtherUser() {
namespace.addPermission(new RepositoryPermission("arthur", "read", false));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantNamespaces())
.doesNotContain("git");
}
@Test
void shouldIgnoreRepositoryWithPermissionForOtherGroups() {
namespace.addPermission(new RepositoryPermission("vogons", "read", true));
when(groupCollector.fromLastLoginPlusInternal("trillian"))
.thenReturn(singleton(knownGroupName));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantNamespaces())
.doesNotContain("git");
}
}
@Nested
class WithRepositories {
private final Repository repository = RepositoryTestData.create42Puzzle();
@BeforeEach
void mockRepository() {
when(repositoryManager.getAll())
.thenReturn(singletonList(repository));
}
@Test
void shouldFindRepositoryWithPermissionForUser() {
repository.addPermission(new RepositoryPermission("trillian", "read", false));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantRepositories())
.contains(repository);
}
@Test
void shouldFindRepositoryWithPermissionForGroupOfUser() {
repository.addPermission(new RepositoryPermission(knownGroupName, "read", true));
when(groupCollector.fromLastLoginPlusInternal("trillian"))
.thenReturn(singleton(knownGroupName));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantRepositories())
.contains(repository);
}
@Test
void shouldIgnoreRepositoryWithPermissionForOtherUser() {
repository.addPermission(new RepositoryPermission("arthur", "read", false));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantRepositories())
.doesNotContain(repository);
}
@Test
void shouldIgnoreRepositoryWithPermissionForOtherGroups() {
repository.addPermission(new RepositoryPermission("vogons", "read", true));
when(groupCollector.fromLastLoginPlusInternal("trillian"))
.thenReturn(singleton(knownGroupName));
PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian");
assertThat(permissionOverview.getRelevantRepositories())
.doesNotContain(repository);
}
}
}