diff --git a/scm-core/src/main/java/sonia/scm/auditlog/AuditEntry.java b/scm-core/src/main/java/sonia/scm/auditlog/AuditEntry.java index 2dd9cd729d..985fbe6c31 100644 --- a/scm-core/src/main/java/sonia/scm/auditlog/AuditEntry.java +++ b/scm-core/src/main/java/sonia/scm/auditlog/AuditEntry.java @@ -25,14 +25,17 @@ package sonia.scm.auditlog; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@Inherited @Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE, ElementType.LOCAL_VARIABLE, ElementType.FIELD, ElementType.PACKAGE, ElementType.METHOD}) +@Target({ElementType.TYPE}) public @interface AuditEntry { String[] labels() default {}; String[] maskedFields() default {}; String[] ignoredFields() default {}; + boolean ignore() default false; } diff --git a/scm-core/src/main/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecorator.java b/scm-core/src/main/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecorator.java index 9b21502796..176c9d5583 100644 --- a/scm-core/src/main/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecorator.java +++ b/scm-core/src/main/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecorator.java @@ -29,8 +29,11 @@ import sonia.scm.repository.RepositoryDAO; import sonia.scm.store.ConfigurationStore; import sonia.scm.store.StoreDecoratorFactory; +import java.util.Optional; import java.util.Set; +import static java.util.Collections.emptySet; + public class AuditLogConfigurationStoreDecorator implements ConfigurationStore { private final Set auditors; @@ -50,13 +53,40 @@ public class AuditLogConfigurationStoreDecorator implements ConfigurationStor } public void set(T object) { - String repositoryId = context.getStoreParameters().getRepositoryId(); - if (!Strings.isNullOrEmpty(repositoryId)) { - String name = repositoryDAO.get(repositoryId).getNamespaceAndName().toString(); - auditors.forEach(s -> s.createEntry(new EntryCreationContext<>(object, get(), name, Set.of("repository")))); - } else { - auditors.forEach(s -> s.createEntry(new EntryCreationContext<>(object, get(), "", Set.of(context.getStoreParameters().getName())))); - } - delegate.set(object); + if (!shouldBeIgnored(object)) { + auditors.forEach(s -> s.createEntry(createEntryCreationContext(object))); + } + delegate.set(object); + } + + private EntryCreationContext createEntryCreationContext(T object) { + String repositoryId = context.getStoreParameters().getRepositoryId(); + if (!Strings.isNullOrEmpty(repositoryId)) { + String name = repositoryDAO.get(repositoryId).getNamespaceAndName().toString(); + return new EntryCreationContext<>(object, get(), name, getRepositoryLabels(object)); + } else { + return new EntryCreationContext<>(object, get(), "", shouldUseStoreNameAsLabel(object) ? Set.of(context.getStoreParameters().getName()) : emptySet()); + } + } + + private boolean shouldBeIgnored(T object) { + return getAnnotation(object).map(AuditEntry::ignore).orElse(false); + } + + private Set getRepositoryLabels(T object) { + Set labels = new java.util.HashSet<>(); + labels.add("repository"); + if (shouldUseStoreNameAsLabel(object)) { + labels.add(context.getStoreParameters().getName()); + } + return labels; + } + + private boolean shouldUseStoreNameAsLabel(T object) { + return getAnnotation(object).map(annotation -> annotation.labels().length == 0).orElse(true); + } + + private Optional getAnnotation(T object) { + return Optional.ofNullable(object.getClass().getAnnotation(AuditEntry.class)); } } diff --git a/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java b/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java index 9ea4804c67..b64b6940df 100644 --- a/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java +++ b/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- @@ -29,6 +29,7 @@ package sonia.scm.security; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import sonia.scm.auditlog.AuditEntry; +import sonia.scm.auditlog.AuditLogEntity; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -47,7 +48,7 @@ import java.io.Serializable; @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "assigned-permission") @AuditEntry(labels = "permission") -public class AssignedPermission implements PermissionObject, Serializable +public class AssignedPermission implements PermissionObject, Serializable, AuditLogEntity { /** serial version uid */ @@ -207,4 +208,9 @@ public class AssignedPermission implements PermissionObject, Serializable /** string representation of the permission */ private PermissionDescriptor permission; + + @Override + public String getEntityName() { + return getName(); + } } diff --git a/scm-core/src/test/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecoratorTest.java b/scm-core/src/test/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecoratorTest.java new file mode 100644 index 0000000000..d40843ff9b --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/auditlog/AuditLogConfigurationStoreDecoratorTest.java @@ -0,0 +1,231 @@ +/* + * 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.auditlog; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryDAO; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.StoreDecoratorFactory; +import sonia.scm.store.TypedStoreParameters; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogConfigurationStoreDecoratorTest { + + @Mock + private Auditor auditor; + @Mock + private RepositoryDAO repositoryDAO; + @Mock + private ConfigurationStore delegate; + @Mock + private StoreDecoratorFactory.Context storeContext; + @Mock + private TypedStoreParameters parameters; + + private AuditLogConfigurationStoreDecorator decorator; + + @BeforeEach + void setUpDecorator() { + decorator = new AuditLogConfigurationStoreDecorator<>(Set.of(auditor), repositoryDAO, delegate, storeContext); + } + + @Nested + class WithAuditableEntries { + + @BeforeEach + void setUpStoreContext() { + when(storeContext.getStoreParameters()).thenReturn(parameters); + lenient().when(parameters.getName()).thenReturn("hog"); + } + + @Test + void shouldCallAuditorForSimpleEntry() { + Object entry = new SimpleEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getEntity()).isEmpty(); + assertThat(context.getAdditionalLabels()).contains("hog"); + assertThat(context.getObject()).isSameAs(entry); + assertThat(context.getOldObject()).isNull(); + return true; + } + )); + } + + @Test + void shouldCallAuditorForAdditionalLabelEntry() { + Object entry = new ExtraLabelEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getEntity()).isEmpty(); + assertThat(context.getAdditionalLabels()).isEmpty(); + assertThat(context.getObject()).isSameAs(entry); + assertThat(context.getOldObject()).isNull(); + return true; + } + )); + } + + @Test + void shouldCallDelegateForSimpleEntry() { + Object entry = new SimpleEntry(); + + decorator.set(entry); + + verify(delegate).set(entry); + } + + @Nested + class ForRepositoryStore { + + @BeforeEach + void mockRepositoryContext() { + when(parameters.getRepositoryId()).thenReturn("42"); + when(repositoryDAO.get("42")).thenReturn(new Repository("42", "git", "hitchhiker", "hog")); + } + + @Test + void shouldCallAuditorForSimpleEntry() { + Object entry = new SimpleEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getEntity()).isEqualTo("hitchhiker/hog"); + assertThat(context.getAdditionalLabels()).contains("hog"); + assertThat(context.getObject()).isSameAs(entry); + assertThat(context.getOldObject()).isNull(); + return true; + } + )); + } + + @Test + void shouldCallAuditorForAdditionalLabelEntry() { + Object entry = new ExtraLabelEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getEntity()).isEqualTo("hitchhiker/hog"); + assertThat(context.getAdditionalLabels()).contains("repository"); + assertThat(context.getObject()).isSameAs(entry); + assertThat(context.getOldObject()).isNull(); + return true; + } + )); + } + + @Test + void shouldUseOldObjectFromStore() { + Object oldObject = new Object(); + when(delegate.get()).thenReturn(oldObject); + + Object entry = new SimpleEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getOldObject()).isSameAs(oldObject); + return true; + } + )); + } + } + + @Test + void shouldUseOldObjectFromStore() { + Object oldObject = new Object(); + when(delegate.get()).thenReturn(oldObject); + + Object entry = new SimpleEntry(); + + decorator.set(entry); + + verify(auditor).createEntry(argThat( + context -> { + assertThat(context.getOldObject()).isSameAs(oldObject); + return true; + } + )); + } + } + + @Test + void shouldNotCallAuditorForIgnoredEntry() { + Object entry = new IgnoredEntry(); + + decorator.set(entry); + + verify(auditor, never()).createEntry(any()); + } + + @Test + void shouldCallDelegateForIgnoredEntry() { + Object entry = new IgnoredEntry(); + + decorator.set(entry); + + verify(delegate).set(entry); + } + + @AuditEntry + private static class SimpleEntry { + } + + @AuditEntry(labels = "permission") + private static class ExtraLabelEntry { + } + + @AuditEntry(ignore = true) + private static class IgnoredEntry { + } +} + diff --git a/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDatabase.java index 1c5b14763f..9f0715c829 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDatabase.java +++ b/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDatabase.java @@ -24,6 +24,7 @@ package sonia.scm.group.xml; +import sonia.scm.auditlog.AuditEntry; import sonia.scm.group.Group; import sonia.scm.xml.XmlDatabase; @@ -40,6 +41,7 @@ import java.util.TreeMap; * * @author Sebastian Sdorra */ +@AuditEntry(ignore = true) @XmlRootElement(name = "group-db") @XmlAccessorType(XmlAccessType.FIELD) public class XmlGroupDatabase implements XmlDatabase diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java index 76c8ab7fc0..0b8da6fea6 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryRoleDatabase.java @@ -24,6 +24,7 @@ package sonia.scm.repository.xml; +import sonia.scm.auditlog.AuditEntry; import sonia.scm.repository.RepositoryRole; import sonia.scm.xml.XmlDatabase; @@ -36,6 +37,7 @@ import java.util.Collection; import java.util.Map; import java.util.TreeMap; +@AuditEntry(ignore = true) @XmlRootElement(name = "user-db") @XmlAccessorType(XmlAccessType.FIELD) public class XmlRepositoryRoleDatabase implements XmlDatabase { diff --git a/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDatabase.java index 4a8a110b5d..3d5a4c2a77 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDatabase.java +++ b/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDatabase.java @@ -24,6 +24,7 @@ package sonia.scm.user.xml; +import sonia.scm.auditlog.AuditEntry; import sonia.scm.user.User; import sonia.scm.xml.XmlDatabase; @@ -40,6 +41,7 @@ import java.util.TreeMap; * * @author Sebastian Sdorra */ +@AuditEntry(ignore = true) @XmlRootElement(name = "user-db") @XmlAccessorType(XmlAccessType.FIELD) public class XmlUserDatabase implements XmlDatabase diff --git a/scm-ui/ui-forms/src/select/Select.tsx b/scm-ui/ui-forms/src/select/Select.tsx index 025eadf91f..0fbaa3f122 100644 --- a/scm-ui/ui-forms/src/select/Select.tsx +++ b/scm-ui/ui-forms/src/select/Select.tsx @@ -36,7 +36,7 @@ type Props = { const Select = React.forwardRef( ({ variant, children, className, options, testId, ...props }, ref) => (
- {options ? options.map((option) => (