diff --git a/docs/de/user/user/assets/user-permission-overview.png b/docs/de/user/user/assets/user-permission-overview.png new file mode 100644 index 0000000000..422ef7ec3c Binary files /dev/null and b/docs/de/user/user/assets/user-permission-overview.png differ diff --git a/docs/de/user/user/index.md b/docs/de/user/user/index.md index d20309ed0a..cb49a0024d 100644 --- a/docs/de/user/user/index.md +++ b/docs/de/user/user/index.md @@ -25,3 +25,19 @@ Die Detailseite eines Benutzers zeigt die Informationen zu diesem an. Die Checkbox `Extern` zeigt an, ob es sich um einen internen Benutzer handelt oder der Benutzer von einem Fremdsystem verwaltet wird. ![Benutzer Informationen](assets/user-information.png) + +### Berechtigungsübersicht +Am unteren Ende der Detailseite kann für einen Benutzer eine Berechtigunsübersicht geladen werden. +Diese Übersicht listet alle Gruppen, denen der Benutzer im SCM-Manager zugewiesen ist. +Hat sich der Benutzer bereits mindestens einmal angemeldet, so werden darüber hinaus auch alle +Gruppen berücksichtigt, die durch externe Berechtigungssysteme (wie z. B. LDAP oder CAS) mitgegeben +wurden. Gruppen mit konfigurierten Berechtigungen sind mit einem Haken markiert. +Externe Gruppen, die im SCM-Manager noch nicht angelegt sind, können separat gelistet werden. + +Darunter werden alle Namespaces und Repositories gelistet, bei denen für den Benutzer oder +eine seiner Gruppen eine Berechtigung konfiguriert ist. + +Die einzelnen Einstellungsseiten für die Berechtigungen können direkt über die Stifte angesprungen +werden. Bei bisher noch nicht bekannten Gruppen können diese direkt erstellt werden. + +![Benutzer Informationen](assets/user-permission-overview.png) diff --git a/docs/en/user/user/assets/user-permission-overview.png b/docs/en/user/user/assets/user-permission-overview.png new file mode 100644 index 0000000000..333ab36f58 Binary files /dev/null and b/docs/en/user/user/assets/user-permission-overview.png differ diff --git a/docs/en/user/user/index.md b/docs/en/user/user/index.md index 927a656c5a..5019a3d614 100644 --- a/docs/en/user/user/index.md +++ b/docs/en/user/user/index.md @@ -22,3 +22,20 @@ The user details page shows the information about the user. The active box shows whether the user is able to use SCM-Manager. The external box shows if it is an internal user or whether it is managed by an external system. ![User-Information](assets/user-information.png) + +### Permission Overview +At the bottom of the detail page, a permission overview can be opened. + +This overview lists all groups, the user has been assigned to in SCM-Manager. If the user has +been logged in at least once, also groups assigned by external authorization systems (like LDAP or CAS) +will be listed. Groups with configured permissions are marked with a checkmark. +External groups that have not been created in SCM-Manager can be seen in an extra table. + +Below, all namespaces and repositories are listed, for whom permissions for the user or any of its groups +have been configured. + +The single permission configurations can be accessed directly using the edit icons. Currently unknown +groups can be created directly. + +![Benutzer Informationen](assets/user-permission-overview.png) + diff --git a/scm-core/src/main/java/sonia/scm/group/GroupCollector.java b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java index 3feecb8f12..3f21d39685 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupCollector.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; import java.util.Set; @@ -31,4 +31,13 @@ public interface GroupCollector { String AUTHENTICATED = "_authenticated"; Set 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 fromLastLoginPlusInternal(String principal); } diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManager.java b/scm-core/src/main/java/sonia/scm/group/GroupManager.java index e91e3d8bc4..c743a79c3c 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManager.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManager.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; //~--- non-JDK imports -------------------------------------------------------- @@ -30,13 +30,14 @@ import sonia.scm.Manager; import sonia.scm.search.Searchable; import java.util.Collection; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ /** * The central class for managing {@link Group}s. * This class is a singleton and is available via injection. - * + * * @author Sebastian Sdorra */ public interface GroupManager @@ -51,5 +52,12 @@ public interface GroupManager * * @return all groups assigned to the given member */ - public Collection getGroupsForMember(String member); + Collection getGroupsForMember(String member); + + /** + * Returns a {@link Set} of all group names. + * + * @since 2.42.0 + */ + Set getAllNames(); } diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java index 41d4bea486..430f0348ca 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; //~--- non-JDK imports -------------------------------------------------------- @@ -30,6 +30,7 @@ import sonia.scm.ManagerDecorator; import sonia.scm.search.SearchRequest; import java.util.Collection; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -100,7 +101,10 @@ public class GroupManagerDecorator return decorated.getGroupsForMember(member); } - //~--- fields --------------------------------------------------------------- + @Override + public Set getAllNames() { + return decorated.getAllNames(); + } /** Field description */ private final GroupManager decorated; diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java new file mode 100644 index 0000000000..d8a41266cc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java @@ -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>> { + + @Override + public XmlMapMultiStringElement[] marshal(Map> map) throws Exception { + XmlMapMultiStringElement[] elements; + + if (Util.isNotEmpty(map)) { + int i = 0; + int s = map.size(); + + elements = new XmlMapMultiStringElement[s]; + + for (Map.Entry> e : map.entrySet()) { + elements[i] = new XmlMapMultiStringElement(e.getKey(), e.getValue()); + i++; + } + } else { + elements = new XmlMapMultiStringElement[0]; + } + + return elements; + } + + @Override + public Map> unmarshal(XmlMapMultiStringElement[] elements) + throws Exception + { + Map> map = new HashMap<>(); + + if (elements != null) + { + for (XmlMapMultiStringElement e : elements) + { + map.put(e.getKey(), e.getValue()); + } + } + + return map; + } +} diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java new file mode 100644 index 0000000000..1ffe490f9e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java @@ -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 value; + + public XmlMapMultiStringElement() {} + + public XmlMapMultiStringElement(String key, Set value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public Set getValue() { + return value; + } + + public void setKey(String key) { + this.key = key; + } + + public void setValue(Set value) { + this.value = value; + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java index 486003d8be..d461f2d3c7 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java @@ -38,6 +38,8 @@ import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import java.nio.file.Path; +import static sonia.scm.store.CopyOnWrite.compute; + public class MetadataStore implements UpdateStepRepositoryMetadataAccess { private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class); @@ -54,13 +56,15 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess { public Repository read(Path path) { LOG.trace("read repository metadata from {}", path); - try { - return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); - } catch (JAXBException ex) { - throw new InternalRepositoryException( - ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex - ); - } + return compute(() -> { + try { + return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); + } catch (JAXBException 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) { diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java index 79210fb0cb..b8a27d5279 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java @@ -43,6 +43,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import static sonia.scm.store.CopyOnWrite.execute; + class PathDatabase { private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class); @@ -122,27 +124,29 @@ class PathDatabase { void read(OnRepositories onRepositories, OnRepository onRepository) { LOG.trace("read repository path database from {}", storePath); - try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) { + execute(() -> { + try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) { - while (reader.hasNext()) { - int eventType = reader.next(); + while (reader.hasNext()) { + int eventType = reader.next(); - if (eventType == XMLStreamConstants.START_ELEMENT) { - String element = reader.getLocalName(); - if (ELEMENT_REPOSITORIES.equals(element)) { - readRepositories(reader, onRepositories); - } else if (ELEMENT_REPOSITORY.equals(element)) { - readRepository(reader, onRepository); + if (eventType == XMLStreamConstants.START_ELEMENT) { + String element = reader.getLocalName(); + if (ELEMENT_REPOSITORIES.equals(element)) { + readRepositories(reader, onRepositories); + } else if (ELEMENT_REPOSITORY.equals(element)) { + 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) { - throw new InternalRepositoryException( - ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(), - "failed to read repository path database", - ex - ); - } + }).withLockedFile(storePath); } private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException { diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java index 03807e3fb0..3539406597 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java @@ -28,11 +28,13 @@ import com.google.common.util.concurrent.Striped; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.locks.Lock; +import java.util.function.Supplier; /** * CopyOnWrite creates a copy of the target file, before it is modified. This should prevent empty or incomplete files @@ -46,21 +48,54 @@ public final class CopyOnWrite { private static final Logger LOG = LoggerFactory.getLogger(CopyOnWrite.class); - private static final Striped concurrencyLock = Striped.lock(10); + private static final Striped concurrencyLock = Striped.lock(20); private CopyOnWrite() { } public static void withTemporaryFile(FileWriter writer, Path targetFile) { validateInput(targetFile); - Lock lock = concurrencyLock.get(targetFile.toString()); - try { - lock.lock(); + execute(() -> { Path temporaryFile = createTemporaryFile(targetFile); executeCallback(writer, targetFile, temporaryFile); replaceOriginalFile(targetFile, temporaryFile); - } finally { - lock.unlock(); + }).withLockedFile(targetFile); + } + + public static FileLocker compute(Supplier supplier) { + return new FileLocker<>(supplier); + } + + public static FileLocker execute(Runnable runnable) { + return new FileLocker<>(() -> { + runnable.run(); + return null; + }); + } + + public static class FileLocker { + private final Supplier supplier; + + public FileLocker(Supplier 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(); + } } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java index 9ae042a3b8..bbb1b5dcaa 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java @@ -44,6 +44,7 @@ import java.util.Map.Entry; import java.util.function.Predicate; import static javax.xml.stream.XMLStreamConstants.END_ELEMENT; +import static sonia.scm.store.CopyOnWrite.execute; public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { @@ -70,19 +71,21 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { + if (file.exists()) { + load(); + } + }).withLockedFile(file); } @Override public void clear() { LOG.debug("clear configuration store"); - synchronized (file) { + execute(() -> { entries.clear(); store(); - } + }).withLockedFile(file); } @Override @@ -98,20 +101,20 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { entries.put(id, item); store(); - } + }).withLockedFile(file); } @Override public void remove(String id) { LOG.debug("remove item {} from configuration store", id); - synchronized (file) { + execute(() -> { entries.remove(id); store(); - } + }).withLockedFile(file); } @Override @@ -135,47 +138,47 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore + context.withUnmarshaller(u -> { + try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) { - context.withUnmarshaller(u -> { - try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) { - - // configuration - reader.nextTag(); - - // entry start - reader.nextTag(); - - while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) { - - // read key + // configuration reader.nextTag(); - String key = reader.getElementText(); - - // read value + // entry start reader.nextTag(); - JAXBElement element = u.unmarshal(reader, type); + while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) { - 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 + // read key reader.nextTag(); + + String key = reader.getElementText(); + + // read value + reader.nextTag(); + + JAXBElement 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() { diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java index caf55ea046..c2e3fad1f0 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java @@ -32,6 +32,9 @@ import java.io.File; import java.io.IOException; import java.util.function.BooleanSupplier; +import static sonia.scm.store.CopyOnWrite.compute; +import static sonia.scm.store.CopyOnWrite.execute; + /** * JAXB implementation of {@link ConfigurationStore}. * @@ -69,10 +72,14 @@ public class JAXBConfigurationStore extends AbstractStore { protected T readObject() { LOG.debug("load {} from store {}", type, configFile); - if (configFile.exists()) { - return context.unmarshall(configFile); - } - return null; + return compute( + () -> { + if (configFile.exists()) { + return context.unmarshall(configFile); + } + return null; + } + ).withLockedFile(configFile); } @Override @@ -87,10 +94,12 @@ public class JAXBConfigurationStore extends AbstractStore { @Override protected void deleteObject() { LOG.debug("deletes {}", configFile.getPath()); - try { - IOUtil.delete(configFile); - } catch (IOException e) { - throw new StoreException("Failed to delete store object " + configFile.getPath(), e); - } + execute(() -> { + try { + IOUtil.delete(configFile); + } catch (IOException e) { + throw new StoreException("Failed to delete store object " + configFile.getPath(), e); + } + }).withLockedFile(configFile); } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java index e25a66ff43..6a392545ec 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java @@ -35,6 +35,8 @@ import javax.xml.bind.Marshaller; import java.io.File; import java.util.Map; +import static sonia.scm.store.CopyOnWrite.compute; + /** * Jaxb implementation of {@link DataStore}. * @@ -106,10 +108,12 @@ public class JAXBDataStore extends FileBasedStore implements DataStore @Override protected T read(File file) { - if (file.exists()) { - LOG.trace("try to read {}", file); - return context.unmarshall(file); - } - return null; + return compute(() -> { + if (file.exists()) { + LOG.trace("try to read {}", file); + return context.unmarshall(file); + } + return null; + }).withLockedFile(file); } } diff --git a/scm-ui/ui-api/src/users.ts b/scm-ui/ui-api/src/users.ts index ca736ef080..81bc979221 100644 --- a/scm-ui/ui-api/src/users.ts +++ b/scm-ui/ui-api/src/users.ts @@ -24,7 +24,7 @@ import { ApiResult, useRequiredIndexLink } from "./base"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, Me, User, UserCollection, UserCreation } from "@scm-manager/ui-types"; +import { Link, Me, PermissionOverview, User, UserCollection, UserCreation } from "@scm-manager/ui-types"; import { apiClient } from "./apiclient"; import { createQueryString } from "./utils"; import { concat } from "./urls"; @@ -65,6 +65,13 @@ export const useUser = (name: string): ApiResult => { ); }; +export const useUserPermissionOverview = (user: User): ApiResult => { + const overviewLink = user._links.permissionOverview as Link; + return useQuery(["user", user.name, "permissionOverview"], () => + apiClient.get(overviewLink.href).then((response) => response.json()) + ); +}; + const createUser = (link: string) => { return (user: UserCreation) => { return apiClient diff --git a/scm-ui/ui-types/src/User.ts b/scm-ui/ui-types/src/User.ts index fe6a3dbcb3..799fc8f659 100644 --- a/scm-ui/ui-types/src/User.ts +++ b/scm-ui/ui-types/src/User.ts @@ -48,3 +48,20 @@ export type UserCreation = User; export type UserCollection = PagedCollection<{ users: User[]; }>; + +export type PermissionOverview = HalRepresentation & { + relevantGroups: PermissionOverviewGroupEntry[]; + relevantNamespaces: string[]; + relevantRepositories: PermissionOverviewRepositoryEntry[]; +}; + +export type PermissionOverviewGroupEntry = { + name: string; + permissions: boolean; + externalOnly: boolean; +}; + +export type PermissionOverviewRepositoryEntry = { + namespace: string; + name: string; +}; diff --git a/scm-ui/ui-webapp/public/locales/de/users.json b/scm-ui/ui-webapp/public/locales/de/users.json index d8b3f2945e..5f222c8e9b 100644 --- a/scm-ui/ui-webapp/public/locales/de/users.json +++ b/scm-ui/ui-webapp/public/locales/de/users.json @@ -30,9 +30,6 @@ "noUsers": "Keine Benutzer gefunden.", "createButton": "Benutzer erstellen" }, - "overview": { - "filterUser": "Benutzer filtern" - }, "singleUser": { "errorTitle": "Fehler", "errorSubtitle": "Unbekannter Benutzer Fehler", @@ -47,6 +44,9 @@ "setApiKeyNavLink": "API Schlüssel" } }, + "overview": { + "filterUser": "Benutzer filtern" + }, "createUser": { "title": "Benutzer erstellen", "subtitle": "Erstellen eines neuen Benutzers", @@ -172,5 +172,27 @@ "submit": "Ja", "cancel": "Nein" } + }, + "permissionOverview": { + "title": "Berechtigungsübersicht", + "help": "Nach einer Anmeldung dieses Kontos basiert diese Übersicht auf den Gruppen, die diesem Konto bei der letzten Anmeldung zugeordnet wurden. Daher beinhaltet sie auch solche Gruppen, die nicht im SCM-Manager eingerichtet sind, sondern von externen Systemen bereitgestellt wurden (wie z. B. LDAP). Da solche externen Daten auf der letzten Anmeldung basieren, stellt diese Übersicht unter Umständen nicht den aktuellen Stand dar. Gab es noch keine Anmeldung mit diesem Konto, so werden nur die intern zugewiesenen Gruppen aufgelistet.", + "groups": { + "noRepositoriesFound": "Keine Namespaces oder Repositories mit Berechtigungen vorhanden.", + "showGroupsWithoutPermission": "Noch nicht in SCM-Manager angelegte fremde Gruppen anzeigen", + "showGroupsWithoutPermissionHelp": "Diese Option zeigt fremde Gruppen an. Dies sind Gruppen, die im SCM-Manager nicht bekannt sind, sondern von externen Systemen stammen. Um diese Gruppen zu berechtigen, müssen sie zunächst im SCM-Manager (als externe Gruppen) angelegt werden.", + "noGroupsFound": "Keine Gruppen vorhanden.", + "noUnknownGroupsFound": "Keine weiteren Gruppen vorhanden.", + "groupName": "Name", + "permissionsConfigured": "Berechtigungen gesetzt", + "createGroup": "Erstellen", + "editPermissions": "Bearbeiten" + }, + "repositories": { + "subtitle": "Namespaces / Repositories", + "namespaceName": "Namespace / Name", + "permissionsConfigured": "Berechtigungen gesetzt", + "editPermissions": "Bearbeiten" + }, + "edit": "bearbeiten" } } diff --git a/scm-ui/ui-webapp/public/locales/en/users.json b/scm-ui/ui-webapp/public/locales/en/users.json index 2f68df6d88..278d0966d2 100644 --- a/scm-ui/ui-webapp/public/locales/en/users.json +++ b/scm-ui/ui-webapp/public/locales/en/users.json @@ -172,5 +172,27 @@ "submit": "Yes", "cancel": "No" } + }, + "permissionOverview": { + "title": "Permission Overview", + "help": "If the user has logged in at leas once, this overview is based on the groups assigned to the user at the last login. Therefor this also includes groups, that have not been set up in SCM-Manager but were provided by external systems (such as LDAP). Because this view is based on the last login of the user, this might not reflect the current state of the external groups if the user would log in now. If the user has never logged in before, only the internal groups for this user will be listed.", + "groups": { + "noRepositoriesFound": "No namespaces/repositories available.", + "showGroupsWithoutPermission": "Show external groups not yet created in SCM-Manager", + "showGroupsWithoutPermissionHelp": "This option shows foreign groups. These are groups, that have come from external systems and are unknown to SCM-Manager. The assign permissions for these groups, they have to be created in SCM-Manager first (as external groups).", + "noGroupsFound": "No groups available.", + "noUnknownGroupsFound": "No further groups available.", + "groupName": "Name", + "permissionsConfigured": "Permissions set", + "createGroup": "Create", + "editPermissions": "Edit" + }, + "repositories": { + "subtitle": "Namespaces / Repositories", + "namespaceName": "Namespace / Name", + "permissionsConfigured": "Permissions set", + "editPermissions": "Edit" + }, + "edit": "edit" } } diff --git a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx index 5fdbbc3407..c59b58679a 100644 --- a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx +++ b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx @@ -41,12 +41,14 @@ type Props = { loading?: boolean; group?: Group; loadUserSuggestions: (p: string) => Promise; + transmittedName?: string; + transmittedExternal?: boolean; }; -const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions }) => { +const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, transmittedName = "", transmittedExternal = false }) => { const [t] = useTranslation("groups"); const [groupState, setGroupState] = useState({ - name: "", + name: transmittedName, description: "", _embedded: { members: [] as Member[] @@ -54,7 +56,7 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions _links: {}, members: [] as string[], type: "", - external: false + external: transmittedExternal }); const [nameValidationError, setNameValidationError] = useState(false); diff --git a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx index 62f841fee1..0f69700691 100644 --- a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx @@ -22,9 +22,9 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { Redirect } from "react-router-dom"; +import { Redirect, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { useCreateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { useCreateGroup, useUserSuggestions, urls } from "@scm-manager/ui-api"; import { Page } from "@scm-manager/ui-components"; import GroupForm from "../components/GroupForm"; @@ -32,6 +32,7 @@ const CreateGroup: FC = () => { const [t] = useTranslation("groups"); const { isLoading, create, error, group } = useCreateGroup(); const userSuggestions = useUserSuggestions(); + const location = useLocation(); if (group) { return ; @@ -40,7 +41,13 @@ const CreateGroup: FC = () => { return (
- +
); diff --git a/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx b/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx new file mode 100644 index 0000000000..0c0bd67b8c --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx @@ -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 ; +}; + +const EditLink: FC<{ links?: Links; to: string }> = ({ links, to }) => { + if (!links?.permissions) { + return null; + } + return ( + + + + ); +}; + +const ElementLink: FC<{ link?: HalLink; to: string }> = ({ link, to, children }) => { + if (!link) { + return <>{children}; + } + return {children}; +}; + +const GroupRow: FC<{ entry: PermissionOverviewGroupEntry; group?: Group }> = ({ entry, group }) => ( + + + + {entry.name} + + + {entry.permissions && } + + + + +); + +const NotCreatedGroupRow: FC<{ entry: PermissionOverviewGroupEntry }> = ({ entry }) => ( + + {entry.name} + + + + + + +); + +const RepositoryNamespaceRows: FC<{ + entry: { namespace: Namespace; repositories: Repository[] }; + relevant: boolean; +}> = ({ entry, relevant }) => ( + <> + + {entry.repositories.map((repository) => ( + + ))} + +); + +const NamespaceRow: FC<{ namespace: Namespace; relevant: boolean }> = ({ namespace, relevant }) => ( + + + {namespace.namespace} + + {relevant && } + + + + +); + +const RepositoryRow: FC<{ entry: Repository }> = ({ entry }) => ( + + + + {entry.name} + + + + + + + + +); + +const GroupTable: FC<{ data: Data }> = ({ data }) => { + const [t] = useTranslation("users"); + + if (data.relevantGroups.find((entry) => !entry.externalOnly)) { + return ( + + + + + + + + + + {data.relevantGroups + .filter((entry) => !entry.externalOnly) + .map((entry) => ( + group.name === entry.name)} + /> + ))} + +
{t("permissionOverview.groups.groupName")}{t("permissionOverview.groups.permissionsConfigured")}{t("permissionOverview.groups.editPermissions")}
+ ); + } else { + return {t("permissionOverview.groups.noGroupsFound")}; + } +}; + +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 = ( + + + + + + + + + {data.relevantGroups + .filter((entry) => entry.externalOnly) + .map((entry) => ( + + ))} + +
{t("permissionOverview.groups.groupName")}{t("permissionOverview.groups.createGroup")}
+ ); + } else { + content = {t("permissionOverview.groups.noUnknownGroupsFound")}; + } + + return ( + <> + + {content} + + ); +}; + +const RepositoryTable: FC<{ data: Data }> = ({ data }) => { + const [t] = useTranslation("users"); + + if ((!data.relevantNamespaces || data.relevantNamespaces.length === 0) && !data.relevantRepositories) { + return {t("permissionOverview.groups.noRepositoriesFound")}; + } + + 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(); + 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 ( + + + + {t("permissionOverview.repositories.namespaceName")} + + + + + + {reposInNamespaces.map((entry) => ( + + ))} + +
{t("permissionOverview.repositories.permissionsConfigured")}{t("permissionOverview.repositories.editPermissions")}
+ ); +}; + +const PermissionOverview: FC<{ user: User }> = ({ user }) => { + const { data, isLoading, error } = useUserPermissionOverview(user); + const [t] = useTranslation("users"); + + if (isLoading || !data) { + return ; + } + + if (error) { + return ; + } + + // 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 ( + <> + + +

{t("permissionOverview.repositories.subtitle")}

+ + + ); +}; + +export default PermissionOverview; diff --git a/scm-ui/ui-webapp/src/users/components/table/Details.tsx b/scm-ui/ui-webapp/src/users/components/table/Details.tsx index 89c3c7642c..96f6204c0b 100644 --- a/scm-ui/ui-webapp/src/users/components/table/Details.tsx +++ b/scm-ui/ui-webapp/src/users/components/table/Details.tsx @@ -21,19 +21,47 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, useState } from "react"; +import { useTranslation, WithTranslation } from "react-i18next"; import { User } from "@scm-manager/ui-types"; -import { Checkbox, createAttributesForTesting, DateFromNow, InfoTable, MailLink } from "@scm-manager/ui-components"; +import { + Checkbox, + createAttributesForTesting, + DateFromNow, + Help, + InfoTable, + MailLink +} from "@scm-manager/ui-components"; +import { Icon } from "@scm-manager/ui-components"; +import PermissionOverview from "../PermissionOverview"; type Props = WithTranslation & { user: User; }; -class Details extends React.Component { - render() { - const { user, t } = this.props; - return ( +const Details: FC = ({ user }) => { + const [t] = useTranslation("users"); + const [collapsed, setCollapsed] = useState(true); + const toggleCollapse = () => setCollapsed(!collapsed); + + let permissionOverview; + if (user._links.permissionOverview) { + let icon = ; + if (!collapsed) { + icon = ; + } + permissionOverview = ( +
+

+ {icon} {t("permissionOverview.title")} +

+ {!collapsed && } +
+ ); + } + + return ( + <> @@ -76,8 +104,9 @@ class Details extends React.Component { - ); - } -} + {permissionOverview} + + ); +}; -export default withTranslation("users")(Details); +export default Details; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java new file mode 100644 index 0000000000..3d6abfb7e3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java @@ -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 relevantGroups; + private Collection relevantNamespaces; + private Collection 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; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java new file mode 100644 index 0000000000..ef6a0a9732 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java @@ -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 relevantNamespaces = permissionOverview + .getRelevantNamespaces() + .stream() + .map(namespaceToNamespaceDtoMapper::map) + .collect(toList()); + List otherNamespaces = permissionOverview + .getRelevantRepositories() + .stream() + .map(Repository::getNamespace) + .distinct() + .filter(namespace -> !permissionOverview.getRelevantNamespaces().contains(namespace)) + .map(namespaceToNamespaceDtoMapper::map) + .collect(toList()); + List repositories = permissionOverview + .getRelevantRepositories() + .stream() + .map(repositoryToRepositoryDtoMapper::map) + .collect(toList()); + List 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() + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 8b230a8853..a66fefc751 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -148,6 +148,10 @@ class ResourceLinks { return userLinkBuilder.method("getUserResource").parameters(name).method("toInternal").parameters().href(); } + public String permissionOverview(String name) { + return userLinkBuilder.method("getUserResource").parameters(name).method("permissionOverview").parameters().href(); + } + public String publicKeys(String name) { return publicKeyLinkBuilder.method("findAll").parameters(name).href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index 76902505d7..8066341308 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.apache.shiro.authc.credential.PasswordService; +import sonia.scm.user.PermissionOverviewCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; @@ -44,30 +45,36 @@ import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; public class UserResource { private final UserDtoToUserMapper dtoToUserMapper; private final UserToUserDtoMapper userToDtoMapper; + private final PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper; private final IdResourceManagerAdapter adapter; private final UserManager userManager; private final PasswordService passwordService; private final UserPermissionResource userPermissionResource; + private final PermissionOverviewCollector permissionOverviewCollector; @Inject - public UserResource( - UserDtoToUserMapper dtoToUserMapper, - UserToUserDtoMapper userToDtoMapper, - UserManager manager, - PasswordService passwordService, UserPermissionResource userPermissionResource) { + public UserResource(UserDtoToUserMapper dtoToUserMapper, + UserToUserDtoMapper userToDtoMapper, + PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper, UserManager manager, + PasswordService passwordService, + UserPermissionResource userPermissionResource, + PermissionOverviewCollector permissionOverviewCollector) { this.dtoToUserMapper = dtoToUserMapper; this.userToDtoMapper = userToDtoMapper; + this.permissionOverviewMapper = permissionOverviewMapper; this.adapter = new IdResourceManagerAdapter<>(manager, User.class); this.userManager = manager; this.passwordService = passwordService; this.userPermissionResource = userPermissionResource; + this.permissionOverviewCollector = permissionOverviewCollector; } /** @@ -298,6 +305,13 @@ public class UserResource { return Response.noContent().build(); } + @GET + @Path("permissionOverview") + @Produces(MediaType.APPLICATION_JSON) + public PermissionOverviewDto permissionOverview(@PathParam("id") String name) { + return permissionOverviewMapper.toDto(permissionOverviewCollector.create(name), name); + } + @Path("permissions") public UserPermissionResource permissions() { return userPermissionResource; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index a969dc74b0..1847fec79b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -29,6 +29,7 @@ import de.otto.edison.hal.Links; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ObjectFactory; +import sonia.scm.group.GroupPermissions; import sonia.scm.security.PermissionPermissions; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -76,6 +77,9 @@ public abstract class UserToUserDtoMapper extends BaseMapper { } if (PermissionPermissions.read().isPermitted()) { linksBuilder.single(link("permissions", resourceLinks.userPermissions().permissions(user.getName()))); + if (GroupPermissions.list().isPermitted()) { + linksBuilder.single(link("permissionOverview", resourceLinks.user().permissionOverview(user.getName()))); + } } Embedded.Builder embeddedBuilder = embeddedBuilder(); diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java index 91e6b58944..98e205a61a 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java @@ -34,11 +34,15 @@ import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.security.Authentications; import sonia.scm.security.LogoutEvent; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.user.UserEvent; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.HashSet; import java.util.Set; +import java.util.stream.Stream; /** * Collect groups for a certain principal. @@ -56,11 +60,14 @@ public class DefaultGroupCollector implements GroupCollector { private final Cache> cache; private final Set groupResolvers; + private final ConfigurationStoreFactory configurationStoreFactory; + @Inject - public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { + public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers, ConfigurationStoreFactory configurationStoreFactory) { this.groupDAO = groupDAO; this.cache = cacheManager.getCache(CACHE_NAME); this.groupResolvers = groupResolvers; + this.configurationStoreFactory = configurationStoreFactory; } @Override @@ -79,9 +86,30 @@ public class DefaultGroupCollector implements GroupCollector { Set groups = builder.build(); LOG.debug("collected following groups for principal {}: {}", principal, groups); + + ConfigurationStore store = createStore(); + UserGroupCache persistentCache = getPersistentCache(store); + persistentCache.put(principal, groups); + store.set(persistentCache); + return groups; } + @Override + public Set fromLastLoginPlusInternal(String principal) { + Set cached = new HashSet<>(getPersistentCache(createStore()).get(principal)); + computeInternalGroups(principal).forEach(cached::add); + return cached; + } + + private static UserGroupCache getPersistentCache(ConfigurationStore store) { + return store.getOptional().orElseGet(UserGroupCache::new); + } + + private ConfigurationStore createStore() { + return configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build(); + } + @Subscribe(async = false) public void clearCacheOnLogOut(LogoutEvent event) { String principal = event.getPrimaryPrincipal(); @@ -95,12 +123,12 @@ public class DefaultGroupCollector implements GroupCollector { } } + private Stream computeInternalGroups(String principal) { + return groupDAO.getAll().stream().filter(group -> group.isMember(principal)).map(Group::getName); + } + private void appendInternalGroups(String principal, ImmutableSet.Builder builder) { - for (Group group : groupDAO.getAll()) { - if (group.isMember(principal)) { - builder.add(group.getName()); - } - } + computeInternalGroups(principal).forEach(builder::add); } private Set resolveExternalGroups(String principal) { diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java index b19cc14e50..ec83256af8 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java @@ -37,7 +37,6 @@ import sonia.scm.HandlerEventType; import sonia.scm.ManagerDaoAdapter; import sonia.scm.NotFoundException; import sonia.scm.SCMContextProvider; -import sonia.scm.TransformFilter; import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchUtil; import sonia.scm.util.CollectionAppender; @@ -50,8 +49,11 @@ import java.util.Collections; import java.util.Comparator; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.function.Predicate; +import static java.util.stream.Collectors.toSet; + //~--- JDK imports ------------------------------------------------------------ /** @@ -346,7 +348,11 @@ public class DefaultGroupManager extends AbstractGroupManager return groupDAO.getLastModified(); } - //~--- methods -------------------------------------------------------------- + @Override + public Set getAllNames() { + GroupPermissions.list().check(); + return groupDAO.getAll().stream().map(Group::getName).collect(toSet()); + } /** * Remove duplicate members from group. diff --git a/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java b/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java new file mode 100644 index 0000000000..b55ac503a0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java @@ -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> cache; + + Set get(String user) { + if (cache == null) { + return emptySet(); + } + return cache.getOrDefault(user, emptySet()); + } + + void put(String user, Set groups) { + if (cache == null) { + cache = new HashMap<>(); + } + cache.put(user, groups); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java index 9ffcc652b1..afb52219b1 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java +++ b/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java @@ -49,6 +49,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Stream; +import static sonia.scm.store.CopyOnWrite.compute; + abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep { private static final Logger LOG = LoggerFactory.getLogger(DifferentiateBetweenConfigAndConfigEntryUpdateStep.class); @@ -90,7 +92,7 @@ abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep { private void updateSingleFile(Path configFile) { LOG.info("Updating config entry file: {}", configFile); - Document configEntryDocument = readAsXmlDocument(configFile); + Document configEntryDocument = compute(() -> readAsXmlDocument(configFile)).withLockedFile(configFile); configEntryDocument.getDocumentElement().setAttribute("type", "config-entry"); diff --git a/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java new file mode 100644 index 0000000000..89d760180a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java @@ -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 relevantGroups; + private final Collection relevantNamespaces; + private final Collection relevantRepositories; + + public PermissionOverview(Collection relevantGroups, Collection relevantNamespaces, Collection relevantRepositories) { + this.relevantGroups = relevantGroups; + this.relevantNamespaces = relevantNamespaces; + this.relevantRepositories = relevantRepositories; + } + + public Collection getRelevantGroups() { + return unmodifiableCollection(relevantGroups); + } + + public Collection getRelevantNamespaces() { + return unmodifiableCollection(relevantNamespaces); + } + + public Collection 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; + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java new file mode 100644 index 0000000000..c231a8bfc6 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java @@ -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 groupsFromLastLogin = groupCollector.fromLastLoginPlusInternal(userId); + + return new PermissionOverview( + collectGroups(groupsFromLastLogin), + collectNamespaces(userId, groupsFromLastLogin), + collectRepositories(userId, groupsFromLastLogin) + ); + } + + private Collection collectGroups(Collection groupsFromLastLogin) { + Collection groupEntries = new ArrayList<>(); + Collection allGroups = groupManager.getAllNames(); + groupsFromLastLogin.forEach(groupName -> { + Collection permissionDescriptors = permissionAssigner.readPermissionsForGroup(groupName); + groupEntries.add( + new GroupEntry( + groupName, + !permissionDescriptors.isEmpty(), + !allGroups.contains(groupName))); + }); + return groupEntries; + } + + private Collection collectNamespaces(String userId, Collection groupsFromLastLogin) { + return namespaceManager + .getAll() + .stream() + .filter(namespace -> isRelevant(userId, groupsFromLastLogin, namespace)) + .map(Namespace::getNamespace) + .collect(toList()); + } + + private List collectRepositories(String userId, Collection groupsFromLastLogin) { + return repositoryManager + .getAll() + .stream() + .filter(repo -> isRelevant(userId, groupsFromLastLogin, repo)) + .collect(toList()); + } + + private static boolean isRelevant(String userId, Collection groupsFromLastLogin, RepositoryPermissionHolder permissionHolder) { + return permissionHolder.getPermissions().stream().anyMatch(permission -> isRelevant(userId, groupsFromLastLogin, permission)); + } + + private static boolean isRelevant(String userId, Collection groupsFromLastLogin, RepositoryPermission permission) { + return permission.isGroupPermission() && groupsFromLastLogin.contains(permission.getName()) + || !permission.isGroupPermission() && userId.equals(permission.getName()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java new file mode 100644 index 0000000000..d15c7e97cd --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java @@ -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"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 486465dc79..04e2b861d3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -41,10 +41,13 @@ import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.NotFoundException; import sonia.scm.PageResult; +import sonia.scm.group.GroupManager; import sonia.scm.security.ApiKeyService; import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionDescriptor; import sonia.scm.user.ChangePasswordNotAllowedException; +import sonia.scm.user.PermissionOverview; +import sonia.scm.user.PermissionOverviewCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.RestDispatcher; @@ -58,6 +61,8 @@ import java.net.URL; import java.util.Collection; import java.util.function.Predicate; +import static de.otto.edison.hal.Links.emptyLinks; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; @@ -95,6 +100,16 @@ public class UserRootResourceTest { private ApiKeyService apiKeyService; @Mock private PermissionAssigner permissionAssigner; + @Mock + private PermissionOverviewCollector permissionOverviewCollector; + @Mock + private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper; + @Mock + private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper; + @Mock + private GroupManager groupManager; + @Mock + private GroupToGroupDtoMapper groupToGroupDtoMapper; @InjectMocks private UserDtoToUserMapperImpl dtoToUserMapper; @InjectMocks @@ -103,6 +118,8 @@ public class UserRootResourceTest { private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; @InjectMocks private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; + @InjectMocks + private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewMapper; @Captor private ArgumentCaptor userCaptor; @@ -110,6 +127,7 @@ public class UserRootResourceTest { private ArgumentCaptor> filterCaptor; private User originalUser; + private MockHttpResponse response = new MockHttpResponse(); @Before public void prepareEnvironment() { @@ -125,7 +143,7 @@ public class UserRootResourceTest { UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper, userCollectionToDtoMapper, resourceLinks, passwordService); UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); - UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource); + UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, permissionOverviewMapper, userManager, passwordService, userPermissionResource, permissionOverviewCollector); ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks); UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource), @@ -137,7 +155,6 @@ public class UserRootResourceTest { @Test public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -155,7 +172,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(userJson.getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -177,7 +193,6 @@ public class UserRootResourceTest { @SubjectAware(username = "unpriv") public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -195,7 +210,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -213,7 +227,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); doThrow(new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("passwordChange", "-"), "xml")).when(userManager).overwritePassword(any(), any()); @@ -231,7 +244,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); doThrow(new NotFoundException("Test", "x")).when(userManager).overwritePassword(any(), any()); @@ -249,7 +261,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -267,7 +278,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -287,7 +297,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -303,7 +312,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(new byte[]{}); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -314,7 +322,6 @@ public class UserRootResourceTest { @Test public void shouldGetNotFoundForNotExistentUser() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "nosuchuser"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -324,7 +331,6 @@ public class UserRootResourceTest { @Test public void shouldDeleteUser() throws Exception { MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -342,7 +348,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Other") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -360,7 +365,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -373,7 +377,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(1); when(userManager.getPage(any(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -389,7 +392,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(3); when(userManager.getPage(any(), any(), eq(1), eq(1))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -407,7 +409,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(1); when(userManager.getPage(filterCaptor.capture(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?q=One"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -427,7 +428,6 @@ public class UserRootResourceTest { @Test public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -440,7 +440,6 @@ public class UserRootResourceTest { public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException { when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*"))); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -455,7 +454,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions") .contentType(VndMediaType.PERMISSION_COLLECTION) .content("{\"permissions\":[\"other:*\"]}".getBytes()); - MockHttpResponse response = new MockHttpResponse(); ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture()); @@ -466,6 +464,19 @@ public class UserRootResourceTest { assertEquals("other:*", captor.getValue().iterator().next().getValue()); } + @Test + public void shouldGetPermissionsOverviewWithNamespaces() throws URISyntaxException, UnsupportedEncodingException { + when(permissionOverviewCollector.create("Neo")).thenReturn(new PermissionOverview(emptyList(), singletonList("hog"), emptyList())); + when(namespaceToNamespaceDtoMapper.map("hog")).thenReturn(new NamespaceDto("hog", emptyLinks())); + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissionOverview"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("hog"); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/Neo/permissionOverview\"}"); + } + @Test public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException { when(passwordService.encryptPassword(anyString())).thenReturn("abc"); @@ -474,7 +485,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal") .contentType(VndMediaType.USER) .content("{\"newPassword\":\"trillian\"}".getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -492,7 +502,6 @@ public class UserRootResourceTest { MockHttpRequest request = MockHttpRequest .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external") .contentType(VndMediaType.USER); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index 3e82c92cbe..d5390ba31a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; 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.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -177,4 +176,16 @@ public class UserToUserDtoMapperTest { assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref()); } + + @Test + public void shouldMapLinks_forPermissionOverview() { + User user = createDefaultUser(); + when(subject.isPermitted("permission:read")).thenReturn(true); + when(subject.isPermitted("group:list")).thenReturn(true); + + UserDto userDto = mapper.map(user); + + assertEquals("expected permissions link", expectedBaseUri.resolve("abc/permissions").toString(), userDto.getLinks().getLinkBy("permissions").get().getHref()); + assertEquals("expected permission overview link", expectedBaseUri.resolve("abc/permissionOverview").toString(), userDto.getLinks().getLinkBy("permissionOverview").get().getHref()); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java index c23cce618a..4eae81abd7 100644 --- a/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java @@ -30,17 +30,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.HandlerEventType; import sonia.scm.cache.MapCache; import sonia.scm.cache.MapCacheManager; import sonia.scm.security.LogoutEvent; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.user.User; import sonia.scm.user.UserEvent; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -59,6 +63,11 @@ class DefaultGroupCollectorTest { @Mock private GroupResolver groupResolver; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ConfigurationStoreFactory configurationStoreFactory; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ConfigurationStore configurationStore; + private MapCacheManager mapCacheManager; private Set groupResolvers; @@ -69,7 +78,14 @@ class DefaultGroupCollectorTest { void initCollector() { groupResolvers = new HashSet<>(); mapCacheManager = new MapCacheManager(); - collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers); + collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers, configurationStoreFactory); + } + + @BeforeEach + void initStore() { + when(configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build()) + .thenReturn(configurationStore); + when(configurationStore.getOptional()).thenReturn(Optional.empty()); } @Test @@ -141,7 +157,16 @@ class DefaultGroupCollectorTest { verify(groupDAO, never()).getAll(); } + @Test + void shouldGetCachedGroupsFromLastLogin() { + UserGroupCache cache = new UserGroupCache(); + cache.put("trillian", Set.of("hog")); + when(configurationStore.getOptional()).thenReturn(Optional.of(cache)); + Set cachedGroups = collector.fromLastLoginPlusInternal("trillian"); + + assertThat(cachedGroups).contains("hog"); + } @Nested class WithGroupsFromDao { @@ -169,5 +194,23 @@ class DefaultGroupCollectorTest { Iterable groupNames = collector.collect("trillian"); assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); } + + @Test + void shouldGetScmGroupsForLastLoginWhenNothingCached() { + Set 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 cachedGroups = collector.fromLastLoginPlusInternal("trillian"); + + assertThat(cachedGroups).contains("earth", "heartOfGold", "fjordsOfAfrican"); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java new file mode 100644 index 0000000000..cd402f9886 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java @@ -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 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); + } + } +}