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. 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) ![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. 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) ![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

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.group; package sonia.scm.group;
import java.util.Set; import java.util.Set;
@@ -31,4 +31,13 @@ public interface GroupCollector {
String AUTHENTICATED = "_authenticated"; String AUTHENTICATED = "_authenticated";
Set<String> collect(String principal); 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

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.group; package sonia.scm.group;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
@@ -30,13 +30,14 @@ import sonia.scm.Manager;
import sonia.scm.search.Searchable; import sonia.scm.search.Searchable;
import java.util.Collection; import java.util.Collection;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
/** /**
* The central class for managing {@link Group}s. * The central class for managing {@link Group}s.
* This class is a singleton and is available via injection. * This class is a singleton and is available via injection.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
public interface GroupManager public interface GroupManager
@@ -51,5 +52,12 @@ public interface GroupManager
* *
* @return all groups assigned to the given member * @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

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.group; package sonia.scm.group;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
@@ -30,6 +30,7 @@ import sonia.scm.ManagerDecorator;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
import java.util.Collection; import java.util.Collection;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -100,7 +101,10 @@ public class GroupManagerDecorator
return decorated.getGroupsForMember(member); return decorated.getGroupsForMember(member);
} }
//~--- fields --------------------------------------------------------------- @Override
public Set<String> getAllNames() {
return decorated.getAllNames();
}
/** Field description */ /** Field description */
private final GroupManager decorated; 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 javax.xml.bind.Marshaller;
import java.nio.file.Path; import java.nio.file.Path;
import static sonia.scm.store.CopyOnWrite.compute;
public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> { public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class); private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class);
@@ -54,13 +56,15 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
public Repository read(Path path) { public Repository read(Path path) {
LOG.trace("read repository metadata from {}", path); LOG.trace("read repository metadata from {}", path);
try { return compute(() -> {
return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); try {
} catch (JAXBException ex) { return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile());
throw new InternalRepositoryException( } catch (JAXBException ex) {
ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex throw new InternalRepositoryException(
); ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex
} );
}
}).withLockedFile(path);
} }
void write(Path path, Repository repository) { void write(Path path, Repository repository) {

View File

@@ -43,6 +43,8 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Map; import java.util.Map;
import static sonia.scm.store.CopyOnWrite.execute;
class PathDatabase { class PathDatabase {
private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class); private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class);
@@ -122,27 +124,29 @@ class PathDatabase {
void read(OnRepositories onRepositories, OnRepository onRepository) { void read(OnRepositories onRepositories, OnRepository onRepository) {
LOG.trace("read repository path database from {}", storePath); LOG.trace("read repository path database from {}", storePath);
try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) { execute(() -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) {
while (reader.hasNext()) { while (reader.hasNext()) {
int eventType = reader.next(); int eventType = reader.next();
if (eventType == XMLStreamConstants.START_ELEMENT) { if (eventType == XMLStreamConstants.START_ELEMENT) {
String element = reader.getLocalName(); String element = reader.getLocalName();
if (ELEMENT_REPOSITORIES.equals(element)) { if (ELEMENT_REPOSITORIES.equals(element)) {
readRepositories(reader, onRepositories); readRepositories(reader, onRepositories);
} else if (ELEMENT_REPOSITORY.equals(element)) { } else if (ELEMENT_REPOSITORY.equals(element)) {
readRepository(reader, onRepository); readRepository(reader, onRepository);
}
} }
} }
} catch (XMLStreamException | IOException ex) {
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(),
"failed to read repository path database",
ex
);
} }
} catch (XMLStreamException | IOException ex) { }).withLockedFile(storePath);
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(),
"failed to read repository path database",
ex
);
}
} }
private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException { 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.locks.Lock; 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 * CopyOnWrite creates a copy of the target file, before it is modified. This should prevent empty or incomplete files
@@ -46,21 +48,54 @@ public final class CopyOnWrite {
private static final Logger LOG = LoggerFactory.getLogger(CopyOnWrite.class); 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() { private CopyOnWrite() {
} }
public static void withTemporaryFile(FileWriter writer, Path targetFile) { public static void withTemporaryFile(FileWriter writer, Path targetFile) {
validateInput(targetFile); validateInput(targetFile);
Lock lock = concurrencyLock.get(targetFile.toString()); execute(() -> {
try {
lock.lock();
Path temporaryFile = createTemporaryFile(targetFile); Path temporaryFile = createTemporaryFile(targetFile);
executeCallback(writer, targetFile, temporaryFile); executeCallback(writer, targetFile, temporaryFile);
replaceOriginalFile(targetFile, temporaryFile); replaceOriginalFile(targetFile, temporaryFile);
} finally { }).withLockedFile(targetFile);
lock.unlock(); }
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();
}
} }
} }

View File

@@ -44,6 +44,7 @@ import java.util.Map.Entry;
import java.util.function.Predicate; import java.util.function.Predicate;
import static javax.xml.stream.XMLStreamConstants.END_ELEMENT; import static javax.xml.stream.XMLStreamConstants.END_ELEMENT;
import static sonia.scm.store.CopyOnWrite.execute;
public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V> { public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V> {
@@ -70,19 +71,21 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
this.type = type; this.type = type;
this.context = context; this.context = context;
// initial load // initial load
if (file.exists()) { execute(() -> {
load(); if (file.exists()) {
} load();
}
}).withLockedFile(file);
} }
@Override @Override
public void clear() { public void clear() {
LOG.debug("clear configuration store"); LOG.debug("clear configuration store");
synchronized (file) { execute(() -> {
entries.clear(); entries.clear();
store(); store();
} }).withLockedFile(file);
} }
@Override @Override
@@ -98,20 +101,20 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
public void put(String id, V item) { public void put(String id, V item) {
LOG.debug("put item {} to configuration store", id); LOG.debug("put item {} to configuration store", id);
synchronized (file) { execute(() -> {
entries.put(id, item); entries.put(id, item);
store(); store();
} }).withLockedFile(file);
} }
@Override @Override
public void remove(String id) { public void remove(String id) {
LOG.debug("remove item {} from configuration store", id); LOG.debug("remove item {} from configuration store", id);
synchronized (file) { execute(() -> {
entries.remove(id); entries.remove(id);
store(); store();
} }).withLockedFile(file);
} }
@Override @Override
@@ -135,47 +138,47 @@ public class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V
private void load() { private void load() {
LOG.debug("load configuration from {}", file); LOG.debug("load configuration from {}", file);
execute(() ->
context.withUnmarshaller(u -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) {
context.withUnmarshaller(u -> { // configuration
try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) {
// configuration
reader.nextTag();
// entry start
reader.nextTag();
while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) {
// read key
reader.nextTag(); reader.nextTag();
String key = reader.getElementText(); // entry start
// read value
reader.nextTag(); reader.nextTag();
JAXBElement<V> element = u.unmarshal(reader, type); while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) {
if (!element.isNil()) { // read key
V v = element.getValue();
LOG.trace("add element {} to configuration entry store", v);
entries.put(key, v);
} else {
LOG.warn("could not unmarshall object of entry store");
}
// closed or new entry tag
if (reader.nextTag() == END_ELEMENT) {
// fixed format, start new entry
reader.nextTag(); reader.nextTag();
String key = reader.getElementText();
// read value
reader.nextTag();
JAXBElement<V> element = u.unmarshal(reader, type);
if (!element.isNil()) {
V v = element.getValue();
LOG.trace("add element {} to configuration entry store", v);
entries.put(key, v);
} else {
LOG.warn("could not unmarshall object of entry store");
}
// closed or new entry tag
if (reader.nextTag() == END_ELEMENT) {
// fixed format, start new entry
reader.nextTag();
}
} }
} }
} })).withLockedFile(file);
});
} }
private void store() { private void store() {

View File

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

View File

@@ -24,7 +24,7 @@
import { ApiResult, useRequiredIndexLink } from "./base"; import { ApiResult, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query"; 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 { apiClient } from "./apiclient";
import { createQueryString } from "./utils"; import { createQueryString } from "./utils";
import { concat } from "./urls"; 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) => { const createUser = (link: string) => {
return (user: UserCreation) => { return (user: UserCreation) => {
return apiClient return apiClient

View File

@@ -48,3 +48,20 @@ export type UserCreation = User;
export type UserCollection = PagedCollection<{ export type UserCollection = PagedCollection<{
users: User[]; 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.", "noUsers": "Keine Benutzer gefunden.",
"createButton": "Benutzer erstellen" "createButton": "Benutzer erstellen"
}, },
"overview": {
"filterUser": "Benutzer filtern"
},
"singleUser": { "singleUser": {
"errorTitle": "Fehler", "errorTitle": "Fehler",
"errorSubtitle": "Unbekannter Benutzer Fehler", "errorSubtitle": "Unbekannter Benutzer Fehler",
@@ -47,6 +44,9 @@
"setApiKeyNavLink": "API Schlüssel" "setApiKeyNavLink": "API Schlüssel"
} }
}, },
"overview": {
"filterUser": "Benutzer filtern"
},
"createUser": { "createUser": {
"title": "Benutzer erstellen", "title": "Benutzer erstellen",
"subtitle": "Erstellen eines neuen Benutzers", "subtitle": "Erstellen eines neuen Benutzers",
@@ -172,5 +172,27 @@
"submit": "Ja", "submit": "Ja",
"cancel": "Nein" "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", "submit": "Yes",
"cancel": "No" "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; loading?: boolean;
group?: Group; group?: Group;
loadUserSuggestions: (p: string) => Promise<SelectValue[]>; 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 [t] = useTranslation("groups");
const [groupState, setGroupState] = useState({ const [groupState, setGroupState] = useState({
name: "", name: transmittedName,
description: "", description: "",
_embedded: { _embedded: {
members: [] as Member[] members: [] as Member[]
@@ -54,7 +56,7 @@ const GroupForm: FC<Props> = ({ submitForm, loading, group, loadUserSuggestions
_links: {}, _links: {},
members: [] as string[], members: [] as string[],
type: "", type: "",
external: false external: transmittedExternal
}); });
const [nameValidationError, setNameValidationError] = useState(false); const [nameValidationError, setNameValidationError] = useState(false);

View File

@@ -22,9 +22,9 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Redirect } from "react-router-dom"; import { Redirect, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next"; 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 { Page } from "@scm-manager/ui-components";
import GroupForm from "../components/GroupForm"; import GroupForm from "../components/GroupForm";
@@ -32,6 +32,7 @@ const CreateGroup: FC = () => {
const [t] = useTranslation("groups"); const [t] = useTranslation("groups");
const { isLoading, create, error, group } = useCreateGroup(); const { isLoading, create, error, group } = useCreateGroup();
const userSuggestions = useUserSuggestions(); const userSuggestions = useUserSuggestions();
const location = useLocation();
if (group) { if (group) {
return <Redirect to={`/group/${group.name}`} />; return <Redirect to={`/group/${group.name}`} />;
@@ -40,7 +41,13 @@ const CreateGroup: FC = () => {
return ( return (
<Page title={t("addGroup.title")} subtitle={t("addGroup.subtitle")} error={error || undefined}> <Page title={t("addGroup.title")} subtitle={t("addGroup.subtitle")} error={error || undefined}>
<div> <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> </div>
</Page> </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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC, useState } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation, WithTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types"; 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 & { type Props = WithTranslation & {
user: User; user: User;
}; };
class Details extends React.Component<Props> { const Details: FC<Props> = ({ user }) => {
render() { const [t] = useTranslation("users");
const { user, t } = this.props; const [collapsed, setCollapsed] = useState(true);
return ( 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> <InfoTable>
<tbody> <tbody>
<tr> <tr>
@@ -76,8 +104,9 @@ class Details extends React.Component<Props> {
</tr> </tr>
</tbody> </tbody>
</InfoTable> </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(); 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) { public String publicKeys(String name) {
return publicKeyLinkBuilder.method("findAll").parameters(name).href(); 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.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.user.PermissionOverviewCollector;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -44,30 +45,36 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
public class UserResource { public class UserResource {
private final UserDtoToUserMapper dtoToUserMapper; private final UserDtoToUserMapper dtoToUserMapper;
private final UserToUserDtoMapper userToDtoMapper; private final UserToUserDtoMapper userToDtoMapper;
private final PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper;
private final IdResourceManagerAdapter<User, UserDto> adapter; private final IdResourceManagerAdapter<User, UserDto> adapter;
private final UserManager userManager; private final UserManager userManager;
private final PasswordService passwordService; private final PasswordService passwordService;
private final UserPermissionResource userPermissionResource; private final UserPermissionResource userPermissionResource;
private final PermissionOverviewCollector permissionOverviewCollector;
@Inject @Inject
public UserResource( public UserResource(UserDtoToUserMapper dtoToUserMapper,
UserDtoToUserMapper dtoToUserMapper, UserToUserDtoMapper userToDtoMapper,
UserToUserDtoMapper userToDtoMapper, PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper, UserManager manager,
UserManager manager, PasswordService passwordService,
PasswordService passwordService, UserPermissionResource userPermissionResource) { UserPermissionResource userPermissionResource,
PermissionOverviewCollector permissionOverviewCollector) {
this.dtoToUserMapper = dtoToUserMapper; this.dtoToUserMapper = dtoToUserMapper;
this.userToDtoMapper = userToDtoMapper; this.userToDtoMapper = userToDtoMapper;
this.permissionOverviewMapper = permissionOverviewMapper;
this.adapter = new IdResourceManagerAdapter<>(manager, User.class); this.adapter = new IdResourceManagerAdapter<>(manager, User.class);
this.userManager = manager; this.userManager = manager;
this.passwordService = passwordService; this.passwordService = passwordService;
this.userPermissionResource = userPermissionResource; this.userPermissionResource = userPermissionResource;
this.permissionOverviewCollector = permissionOverviewCollector;
} }
/** /**
@@ -298,6 +305,13 @@ public class UserResource {
return Response.noContent().build(); 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") @Path("permissions")
public UserPermissionResource permissions() { public UserPermissionResource permissions() {
return userPermissionResource; return userPermissionResource;

View File

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

View File

@@ -34,11 +34,15 @@ import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
import sonia.scm.security.Authentications; import sonia.scm.security.Authentications;
import sonia.scm.security.LogoutEvent; import sonia.scm.security.LogoutEvent;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.UserEvent; import sonia.scm.user.UserEvent;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream;
/** /**
* Collect groups for a certain principal. * Collect groups for a certain principal.
@@ -56,11 +60,14 @@ public class DefaultGroupCollector implements GroupCollector {
private final Cache<String, Set<String>> cache; private final Cache<String, Set<String>> cache;
private final Set<GroupResolver> groupResolvers; private final Set<GroupResolver> groupResolvers;
private final ConfigurationStoreFactory configurationStoreFactory;
@Inject @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.groupDAO = groupDAO;
this.cache = cacheManager.getCache(CACHE_NAME); this.cache = cacheManager.getCache(CACHE_NAME);
this.groupResolvers = groupResolvers; this.groupResolvers = groupResolvers;
this.configurationStoreFactory = configurationStoreFactory;
} }
@Override @Override
@@ -79,9 +86,30 @@ public class DefaultGroupCollector implements GroupCollector {
Set<String> groups = builder.build(); Set<String> groups = builder.build();
LOG.debug("collected following groups for principal {}: {}", principal, groups); 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; 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) @Subscribe(async = false)
public void clearCacheOnLogOut(LogoutEvent event) { public void clearCacheOnLogOut(LogoutEvent event) {
String principal = event.getPrimaryPrincipal(); 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) { private void appendInternalGroups(String principal, ImmutableSet.Builder<String> builder) {
for (Group group : groupDAO.getAll()) { computeInternalGroups(principal).forEach(builder::add);
if (group.isMember(principal)) {
builder.add(group.getName());
}
}
} }
private Set<String> resolveExternalGroups(String principal) { private Set<String> resolveExternalGroups(String principal) {

View File

@@ -37,7 +37,6 @@ import sonia.scm.HandlerEventType;
import sonia.scm.ManagerDaoAdapter; import sonia.scm.ManagerDaoAdapter;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.TransformFilter;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil; import sonia.scm.search.SearchUtil;
import sonia.scm.util.CollectionAppender; import sonia.scm.util.CollectionAppender;
@@ -50,8 +49,11 @@ import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.function.Predicate; import java.util.function.Predicate;
import static java.util.stream.Collectors.toSet;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
/** /**
@@ -346,7 +348,11 @@ public class DefaultGroupManager extends AbstractGroupManager
return groupDAO.getLastModified(); 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. * 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.nio.file.Path;
import java.util.stream.Stream; import java.util.stream.Stream;
import static sonia.scm.store.CopyOnWrite.compute;
abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep { abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep {
private static final Logger LOG = LoggerFactory.getLogger(DifferentiateBetweenConfigAndConfigEntryUpdateStep.class); private static final Logger LOG = LoggerFactory.getLogger(DifferentiateBetweenConfigAndConfigEntryUpdateStep.class);
@@ -90,7 +92,7 @@ abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep {
private void updateSingleFile(Path configFile) { private void updateSingleFile(Path configFile) {
LOG.info("Updating config entry file: {}", 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"); 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.ContextEntry;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.group.GroupManager;
import sonia.scm.security.ApiKeyService; import sonia.scm.security.ApiKeyService;
import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor; import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.ChangePasswordNotAllowedException; import sonia.scm.user.ChangePasswordNotAllowedException;
import sonia.scm.user.PermissionOverview;
import sonia.scm.user.PermissionOverviewCollector;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.web.RestDispatcher; import sonia.scm.web.RestDispatcher;
@@ -58,6 +61,8 @@ import java.net.URL;
import java.util.Collection; import java.util.Collection;
import java.util.function.Predicate; 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 java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -95,6 +100,16 @@ public class UserRootResourceTest {
private ApiKeyService apiKeyService; private ApiKeyService apiKeyService;
@Mock @Mock
private PermissionAssigner permissionAssigner; private PermissionAssigner permissionAssigner;
@Mock
private PermissionOverviewCollector permissionOverviewCollector;
@Mock
private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper;
@Mock
private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper;
@Mock
private GroupManager groupManager;
@Mock
private GroupToGroupDtoMapper groupToGroupDtoMapper;
@InjectMocks @InjectMocks
private UserDtoToUserMapperImpl dtoToUserMapper; private UserDtoToUserMapperImpl dtoToUserMapper;
@InjectMocks @InjectMocks
@@ -103,6 +118,8 @@ public class UserRootResourceTest {
private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper;
@InjectMocks @InjectMocks
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
@InjectMocks
private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewMapper;
@Captor @Captor
private ArgumentCaptor<User> userCaptor; private ArgumentCaptor<User> userCaptor;
@@ -110,6 +127,7 @@ public class UserRootResourceTest {
private ArgumentCaptor<Predicate<User>> filterCaptor; private ArgumentCaptor<Predicate<User>> filterCaptor;
private User originalUser; private User originalUser;
private MockHttpResponse response = new MockHttpResponse();
@Before @Before
public void prepareEnvironment() { public void prepareEnvironment() {
@@ -125,7 +143,7 @@ public class UserRootResourceTest {
UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper, UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper,
userCollectionToDtoMapper, resourceLinks, passwordService); userCollectionToDtoMapper, resourceLinks, passwordService);
UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); 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); ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks); UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks);
UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource), UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource),
@@ -137,7 +155,6 @@ public class UserRootResourceTest {
@Test @Test
public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException { public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -155,7 +172,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2) .post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(userJson.getBytes()); .content(userJson.getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -177,7 +193,6 @@ public class UserRootResourceTest {
@SubjectAware(username = "unpriv") @SubjectAware(username = "unpriv")
public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException { public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -195,7 +210,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE) .contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123");
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -213,7 +227,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE) .contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("passwordChange", "-"), "xml")).when(userManager).overwritePassword(any(), any()); 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") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE) .contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
doThrow(new NotFoundException("Test", "x")).when(userManager).overwritePassword(any(), any()); doThrow(new NotFoundException("Test", "x")).when(userManager).overwritePassword(any(), any());
@@ -249,7 +261,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password")
.contentType(VndMediaType.PASSWORD_OVERWRITE) .contentType(VndMediaType.PASSWORD_OVERWRITE)
.content(content.getBytes()); .content(content.getBytes());
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123");
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -267,7 +278,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2) .post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(userJson); .content(userJson);
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123");
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -287,7 +297,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo")
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(userJson); .content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -303,7 +312,6 @@ public class UserRootResourceTest {
.post("/" + UserRootResource.USERS_PATH_V2) .post("/" + UserRootResource.USERS_PATH_V2)
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(new byte[]{}); .content(new byte[]{});
MockHttpResponse response = new MockHttpResponse();
when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123");
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -314,7 +322,6 @@ public class UserRootResourceTest {
@Test @Test
public void shouldGetNotFoundForNotExistentUser() throws URISyntaxException { public void shouldGetNotFoundForNotExistentUser() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "nosuchuser"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "nosuchuser");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -324,7 +331,6 @@ public class UserRootResourceTest {
@Test @Test
public void shouldDeleteUser() throws Exception { public void shouldDeleteUser() throws Exception {
MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -342,7 +348,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Other") .put("/" + UserRootResource.USERS_PATH_V2 + "Other")
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(userJson); .content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -360,7 +365,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo")
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content(userJson); .content(userJson);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -373,7 +377,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(1); PageResult<User> singletonPageResult = createSingletonPageResult(1);
when(userManager.getPage(any(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); when(userManager.getPage(any(), any(), eq(0), eq(10))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -389,7 +392,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(3); PageResult<User> singletonPageResult = createSingletonPageResult(3);
when(userManager.getPage(any(), any(), eq(1), eq(1))).thenReturn(singletonPageResult); when(userManager.getPage(any(), any(), eq(1), eq(1))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -407,7 +409,6 @@ public class UserRootResourceTest {
PageResult<User> singletonPageResult = createSingletonPageResult(1); PageResult<User> singletonPageResult = createSingletonPageResult(1);
when(userManager.getPage(filterCaptor.capture(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); when(userManager.getPage(filterCaptor.capture(), any(), eq(0), eq(10))).thenReturn(singletonPageResult);
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?q=One"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?q=One");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -427,7 +428,6 @@ public class UserRootResourceTest {
@Test @Test
public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException { public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException {
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -440,7 +440,6 @@ public class UserRootResourceTest {
public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException { public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException {
when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*"))); when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*")));
MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions"); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -455,7 +454,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions")
.contentType(VndMediaType.PERMISSION_COLLECTION) .contentType(VndMediaType.PERMISSION_COLLECTION)
.content("{\"permissions\":[\"other:*\"]}".getBytes()); .content("{\"permissions\":[\"other:*\"]}".getBytes());
MockHttpResponse response = new MockHttpResponse();
ArgumentCaptor<Collection<PermissionDescriptor>> captor = ArgumentCaptor.forClass(Collection.class); ArgumentCaptor<Collection<PermissionDescriptor>> captor = ArgumentCaptor.forClass(Collection.class);
doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture()); doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture());
@@ -466,6 +464,19 @@ public class UserRootResourceTest {
assertEquals("other:*", captor.getValue().iterator().next().getValue()); 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 @Test
public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException { public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException {
when(passwordService.encryptPassword(anyString())).thenReturn("abc"); when(passwordService.encryptPassword(anyString())).thenReturn("abc");
@@ -474,7 +485,6 @@ public class UserRootResourceTest {
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal")
.contentType(VndMediaType.USER) .contentType(VndMediaType.USER)
.content("{\"newPassword\":\"trillian\"}".getBytes()); .content("{\"newPassword\":\"trillian\"}".getBytes());
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -492,7 +502,6 @@ public class UserRootResourceTest {
MockHttpRequest request = MockHttpRequest MockHttpRequest request = MockHttpRequest
.put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external") .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external")
.contentType(VndMediaType.USER); .contentType(VndMediaType.USER);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
@@ -43,7 +43,6 @@ import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
@@ -177,4 +176,16 @@ public class UserToUserDtoMapperTest {
assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref()); 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.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType; import sonia.scm.HandlerEventType;
import sonia.scm.cache.MapCache; import sonia.scm.cache.MapCache;
import sonia.scm.cache.MapCacheManager; import sonia.scm.cache.MapCacheManager;
import sonia.scm.security.LogoutEvent; import sonia.scm.security.LogoutEvent;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserEvent; import sonia.scm.user.UserEvent;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -59,6 +63,11 @@ class DefaultGroupCollectorTest {
@Mock @Mock
private GroupResolver groupResolver; 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 MapCacheManager mapCacheManager;
private Set<GroupResolver> groupResolvers; private Set<GroupResolver> groupResolvers;
@@ -69,7 +78,14 @@ class DefaultGroupCollectorTest {
void initCollector() { void initCollector() {
groupResolvers = new HashSet<>(); groupResolvers = new HashSet<>();
mapCacheManager = new MapCacheManager(); 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 @Test
@@ -141,7 +157,16 @@ class DefaultGroupCollectorTest {
verify(groupDAO, never()).getAll(); 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 @Nested
class WithGroupsFromDao { class WithGroupsFromDao {
@@ -169,5 +194,23 @@ class DefaultGroupCollectorTest {
Iterable<String> groupNames = collector.collect("trillian"); Iterable<String> groupNames = collector.collect("trillian");
assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); 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);
}
}
}