From f4890d56b0a61427ea5c9731cc8315ba7e247125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Garc=C3=ADa?= Date: Thu, 16 Jan 2025 14:15:06 +0100 Subject: [PATCH] Audit user changes (#8137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Audit user changes * Audit user changes - update translations * Add README.md file * Update UserAuditable model to track user address changes and add unit test for UserAuditable * Audit user changes - change to use Hibernate Envers due to Javers dependency conflict with Geotools for the picocontainer version * Audit user changes - update README.md documentation * Code formatting and styling * Update UsersApi to remove invalid user group assignment in the user update endpoint --------- Co-authored-by: Juan Luis Rodríguez --- auditable/README.md | 14 + auditable/pom.xml | 51 ++++ .../auditable/BaseAuditableService.java | 180 ++++++++++++ .../auditable/UserAuditableService.java | 45 +++ .../auditable/model/RevisionFieldChange.java | 51 ++++ .../geonet/auditable/model/RevisionInfo.java | 72 +++++ .../resources/config-spring-geonetwork.xml | 34 +++ .../auditable/model/UserAuditableTest.java | 89 ++++++ core/pom.xml | 5 + .../fao/geonet/kernel/setting/Settings.java | 2 + .../org/fao/geonet/api/Messages.properties | 9 +- .../fao/geonet/api/Messages_fre.properties | 7 +- ...rWithProfileSearchEnhancedTest-context.xml | 6 +- domain/pom.xml | 4 + .../auditable/UsernameAuditorAware.java | 46 +++ .../fao/geonet/auditable/package-info.java | 26 ++ .../domain/auditable/AuditableEntity.java | 100 +++++++ .../domain/auditable/UserAuditable.java | 275 ++++++++++++++++++ .../repository/BaseAuditableRepository.java | 31 ++ .../repository/UserAuditableRepository.java | 32 ++ .../resources/config-spring-geonetwork.xml | 8 +- pom.xml | 7 +- services/pom.xml | 7 +- .../geonet/api/auditable/AuditableApi.java | 92 ++++++ .../fao/geonet/api/reports/ReportUsers.java | 49 +++- .../org/fao/geonet/api/users/UsersApi.java | 73 +++-- .../auditable/AuditableDirective.js | 65 +++++ .../components/auditable/AuditableModule.js | 34 +++ .../components/auditable/AuditableService.js | 44 +++ .../auditable/partials/auditableHistory.html | 30 ++ .../catalog/js/admin/UserGroupController.js | 25 +- .../resources/catalog/locales/en-admin.json | 11 +- .../templates/admin/usergroup/users.html | 15 + web/pom.xml | 6 + .../org/fao/geonet/api/Messages.properties | 6 + .../fao/geonet/api/Messages_fre.properties | 6 + .../setup/sql/data/data-db-default.sql | 3 + .../sql/migrate/v445/migrate-default.sql | 1 + .../sql/migrate/v447/migrate-default.sql | 1 + workers/camelPeriodicProducer/pom.xml | 5 + .../domain-repository-test-context.xml | 8 +- 41 files changed, 1526 insertions(+), 49 deletions(-) create mode 100644 auditable/README.md create mode 100644 auditable/pom.xml create mode 100644 auditable/src/main/java/org/fao/geonet/auditable/BaseAuditableService.java create mode 100644 auditable/src/main/java/org/fao/geonet/auditable/UserAuditableService.java create mode 100644 auditable/src/main/java/org/fao/geonet/auditable/model/RevisionFieldChange.java create mode 100644 auditable/src/main/java/org/fao/geonet/auditable/model/RevisionInfo.java create mode 100644 auditable/src/main/resources/config-spring-geonetwork.xml create mode 100644 auditable/src/test/java/org/fao/geonet/auditable/model/UserAuditableTest.java create mode 100644 domain/src/main/java/org/fao/geonet/auditable/UsernameAuditorAware.java create mode 100644 domain/src/main/java/org/fao/geonet/auditable/package-info.java create mode 100644 domain/src/main/java/org/fao/geonet/domain/auditable/AuditableEntity.java create mode 100644 domain/src/main/java/org/fao/geonet/domain/auditable/UserAuditable.java create mode 100644 domain/src/main/java/org/fao/geonet/repository/BaseAuditableRepository.java create mode 100644 domain/src/main/java/org/fao/geonet/repository/UserAuditableRepository.java create mode 100644 services/src/main/java/org/fao/geonet/api/auditable/AuditableApi.java create mode 100644 web-ui/src/main/resources/catalog/components/auditable/AuditableDirective.js create mode 100644 web-ui/src/main/resources/catalog/components/auditable/AuditableModule.js create mode 100644 web-ui/src/main/resources/catalog/components/auditable/AuditableService.js create mode 100644 web-ui/src/main/resources/catalog/components/auditable/partials/auditableHistory.html diff --git a/auditable/README.md b/auditable/README.md new file mode 100644 index 00000000000..41f2b36d10c --- /dev/null +++ b/auditable/README.md @@ -0,0 +1,14 @@ +# Auditable Module + +The auditable module contains the classes that allow auditing changes in user information using [Hibernate Envers](https://hibernate.org/orm/envers/). + +Support for new auditable entities can be added, for example to audit changes in group information. For users auditing: + +- Entity with the information to audit: [UserAuditable](../domain/src/main/java/org/fao/geonet/domain/auditable/UserAuditable.java). +- Related JPA repository: [UserAuditableRepository](../domain/src/main/java/org/fao/geonet/repository/UserAuditableRepository.java). +- The auditable service: [UserAuditableService](src/main/java/org/fao/geonet/auditable/UserAuditableService.java). +- The users API updated to use the auditable service: [UserApi](../services/src/main/java/org/fao/geonet/api/users/UsersApi.java). + + + + diff --git a/auditable/pom.xml b/auditable/pom.xml new file mode 100644 index 00000000000..4d475ecd0db --- /dev/null +++ b/auditable/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + org.geonetwork-opensource + geonetwork + 4.4.7-SNAPSHOT + + + + + + gn-auditable + jar + GeoNetwork auditable objects + + + + General Public License (GPL) + http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt + repo + + + + + + + ${project.groupId} + gn-domain + ${project.version} + + + + ${project.groupId} + gn-core + ${project.version} + + + + org.springframework + spring-core + + + + + ${basedir}/.. + + + diff --git a/auditable/src/main/java/org/fao/geonet/auditable/BaseAuditableService.java b/auditable/src/main/java/org/fao/geonet/auditable/BaseAuditableService.java new file mode 100644 index 00000000000..9268195da1f --- /dev/null +++ b/auditable/src/main/java/org/fao/geonet/auditable/BaseAuditableService.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.auditable; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.MapDifference; +import com.google.common.collect.Maps; +import org.fao.geonet.auditable.model.RevisionFieldChange; +import org.fao.geonet.auditable.model.RevisionInfo; +import org.fao.geonet.domain.auditable.AuditableEntity; +import org.fao.geonet.kernel.setting.SettingManager; +import org.fao.geonet.kernel.setting.Settings; +import org.fao.geonet.repository.BaseAuditableRepository; +import org.springframework.data.history.Revision; +import org.springframework.data.history.Revisions; +import org.springframework.util.StringUtils; + +import java.util.*; + +public abstract class BaseAuditableService { + protected static final String LINE_SEPARATOR = System.lineSeparator(); + + protected BaseAuditableRepository repository; + protected SettingManager settingManager; + + public abstract String getEntityType(); + + public void auditSave(U auditableEntity) { + if (!isAuditableEnabled()) return; + + repository.save(auditableEntity); + } + + public void auditDelete(U auditableEntity) { + if (!isAuditableEnabled()) return; + + repository.delete(auditableEntity); + } + + public String getEntityHistoryAsString(Integer entityIdentifier, ResourceBundle messages) { + if (!isAuditableEnabled()) return ""; + + Revisions revisions = repository.findRevisions(entityIdentifier); + + return retrieveRevisionHistoryAsString(revisions, messages); + } + + public List getEntityHistory(Integer entityIdentifier) { + if (!isAuditableEnabled()) return new ArrayList<>(); + + Revisions revisions = repository.findRevisions(entityIdentifier); + + return retrieveRevisionHistory(revisions); + } + + + protected String retrieveRevisionHistoryAsString(Revisions revisions, ResourceBundle messages) { + List revisionInfoList = retrieveRevisionHistory(revisions); + + List diffs = new ArrayList<>(); + + revisionInfoList.stream().forEach(revision -> { + List revisionChanges = new ArrayList<>(); + revisionChanges.add(revision.getValue()); + + revision.getChanges().forEach(change -> { + boolean oldValueIsDefined = StringUtils.hasLength(change.getOldValue()); + boolean newValueIsDefined = StringUtils.hasLength(change.getNewValue()); + + if (oldValueIsDefined && newValueIsDefined) { + revisionChanges.add(String.format(messages.getString("audit.revision.field.updated"), + change.getName(), change.getOldValue(), change.getNewValue())); + } else if (!oldValueIsDefined && newValueIsDefined) { + revisionChanges.add(String.format(messages.getString("audit.revision.field.set"), + change.getName(), change.getNewValue())); + } else if (oldValueIsDefined && !newValueIsDefined) { + revisionChanges.add(String.format(messages.getString("audit.revision.field.unset"), change.getName())); + } + }); + + String revisionInfo = String.format(messages.getString("audit.revision"), + revision.getUser(), + revision.getDate(), + String.join(LINE_SEPARATOR, revisionChanges)); + + diffs.add(revisionInfo); + + }); + + return String.join(LINE_SEPARATOR, diffs); + } + + protected List retrieveRevisionHistory(Revisions revisions) { + String idFieldName = "id"; + List> revisionList = revisions.toList(); + int numRevisions = revisions.toList().size(); + + List revisionInfoList = new ArrayList<>(); + + if (numRevisions > 0) { + Revision initialRevision = revisionList.get(0); + AuditableEntity initialRevisionEntity = (AuditableEntity) initialRevision.getEntity(); + + // Initial revision + ObjectMapper objectMapper = new ObjectMapper(); + Map revisionMap = objectMapper.convertValue(initialRevision.getEntity(), Map.class); + // Remove empty values and id + revisionMap.values().removeAll(Arrays.asList("", null)); + revisionMap.remove(idFieldName); + + RevisionInfo initialRevisionInfo = new RevisionInfo( + initialRevision.getMetadata().getRequiredRevisionNumber(), + initialRevisionEntity.getCreatedBy(), + initialRevisionEntity.getCreatedDate(), revisionMap.toString()); + + revisionInfoList.add(initialRevisionInfo); + + int i = 0; + while (i + 1 < numRevisions) { + Revision revision1 = revisionList.get(i); + Revision revision2 = revisionList.get(i + 1); + + Map revision1Map = objectMapper.convertValue(revision1.getEntity(), Map.class); + revision1Map.remove(idFieldName); + Map revision2Map = objectMapper.convertValue(revision2.getEntity(), Map.class); + revision2Map.remove(idFieldName); + + MapDifference diff = Maps.difference(revision1Map, revision2Map); + + revision2Map.values().removeAll(Arrays.asList("", null)); + + final RevisionInfo revisionInfo = new RevisionInfo( + revision2.getMetadata().getRequiredRevisionNumber(), + ((AuditableEntity) revision2.getEntity()).getLastModifiedBy(), + ((AuditableEntity) revision2.getEntity()).getLastModifiedDate(), + revision2Map.toString()); + + diff.entriesDiffering().forEach((key, entry) -> { + String oldValueAsString = (entry.leftValue() != null) ? entry.leftValue().toString() : ""; + String newValueAsString = (entry.rightValue() != null) ? entry.rightValue().toString() : ""; + + RevisionFieldChange revisionFieldChange = new RevisionFieldChange(key, oldValueAsString, newValueAsString); + + revisionInfo.addChange(revisionFieldChange); + }); + + + revisionInfoList.add(revisionInfo); + i++; + } + } + + Collections.reverse(revisionInfoList); + return revisionInfoList; + } + + protected boolean isAuditableEnabled() { + return settingManager.getValueAsBool(Settings.SYSTEM_AUDITABLE_ENABLE, false); + } +} diff --git a/auditable/src/main/java/org/fao/geonet/auditable/UserAuditableService.java b/auditable/src/main/java/org/fao/geonet/auditable/UserAuditableService.java new file mode 100644 index 00000000000..2d00b824360 --- /dev/null +++ b/auditable/src/main/java/org/fao/geonet/auditable/UserAuditableService.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.auditable; + +import org.fao.geonet.domain.auditable.UserAuditable; +import org.fao.geonet.kernel.setting.SettingManager; +import org.fao.geonet.repository.UserAuditableRepository; +import org.springframework.stereotype.Service; + +@Service +public class UserAuditableService extends BaseAuditableService { + + public static final String ENTITY_TYPE = "user"; + + public UserAuditableService(SettingManager settingManager, UserAuditableRepository repository) { + this.settingManager = settingManager; + this.repository = repository; + } + + @Override + public String getEntityType() { + return ENTITY_TYPE; + } +} diff --git a/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionFieldChange.java b/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionFieldChange.java new file mode 100644 index 00000000000..a55bfe5cea0 --- /dev/null +++ b/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionFieldChange.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.auditable.model; + +/** + * This class represents a change in an entity field. It stores the field name, + * the previous and the new value. + */ +public class RevisionFieldChange { + private final String name; + private final String oldValue; + private final String newValue; + + public RevisionFieldChange(String name, String oldValue, String newValue) { + this.name = name; + this.oldValue = oldValue; + this.newValue = newValue; + } + + public String getName() { + return name; + } + + public String getOldValue() { + return oldValue; + } + + public String getNewValue() { + return newValue; + } +} diff --git a/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionInfo.java b/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionInfo.java new file mode 100644 index 00000000000..3441a6e8c23 --- /dev/null +++ b/auditable/src/main/java/org/fao/geonet/auditable/model/RevisionInfo.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.auditable.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.fao.geonet.domain.ISODate; + +public class RevisionInfo { + private final int revisionNumber; + private final String user; + private final String date; + private final String value; + private final List changes; + + public RevisionInfo(int revisionNumber, String user, Date date, String value) { + this.revisionNumber = revisionNumber; + this.user = user; + this.date = new ISODate(date.getTime()).toString(); + this.value = value; + this.changes = new ArrayList<>(); + } + + public int getRevisionNumber() { + return revisionNumber; + } + + public String getUser() { + return user; + } + + public String getDate() { + return date; + } + + public String getValue() { + return value; + } + + /** + * @return an unmodifiable view of the list of changes. + */ + public List getChanges() { + return Collections.unmodifiableList(changes); + } + + public void addChange(RevisionFieldChange change) { + changes.add(change); + } +} diff --git a/auditable/src/main/resources/config-spring-geonetwork.xml b/auditable/src/main/resources/config-spring-geonetwork.xml new file mode 100644 index 00000000000..61411f180ac --- /dev/null +++ b/auditable/src/main/resources/config-spring-geonetwork.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/auditable/src/test/java/org/fao/geonet/auditable/model/UserAuditableTest.java b/auditable/src/test/java/org/fao/geonet/auditable/model/UserAuditableTest.java new file mode 100644 index 00000000000..0b6a47b8393 --- /dev/null +++ b/auditable/src/test/java/org/fao/geonet/auditable/model/UserAuditableTest.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.auditable.model; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.User; +import org.fao.geonet.domain.UserGroup; +import org.fao.geonet.domain.auditable.UserAuditable; +import org.junit.Test; +import org.springframework.util.StringUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class UserAuditableTest { + + @Test + public void testBuildUserAuditable() { + Group group = new Group().setId(1).setName("sample"); + Group group2 = new Group().setId(2).setName("sampleGroup2"); + + + User user = new User() + .setId(1) + .setName("name") + .setSurname("surname") + .setUsername("username") + .setEnabled(true) + .setEmailAddresses(new HashSet<>(List.of("test@mail.com"))) + .setProfile(Profile.Reviewer); + + + List userGroupList = new ArrayList<>(); + UserGroup userGroup1 = new UserGroup() + .setGroup(group) + .setUser(user) + .setProfile(Profile.Editor); + + UserGroup userGroup2 = new UserGroup() + .setGroup(group2) + .setUser(user) + .setProfile(Profile.Reviewer); + + userGroupList.add(userGroup1); + userGroupList.add(userGroup2); + + UserAuditable userAuditable = UserAuditable.build(user, userGroupList); + + assertEquals(user.getId(), userAuditable.getId()); + assertEquals(user.isEnabled(), userAuditable.isEnabled()); + assertEquals(user.getName(), userAuditable.getName()); + assertEquals(user.getSurname(), userAuditable.getSurname()); + assertEquals(user.getUsername(), userAuditable.getUsername()); + assertEquals(user.getEmailAddresses().toArray()[0], userAuditable.getEmailAddress()); + assertEquals(user.getProfile().toString(), userAuditable.getProfile()); + assertFalse(StringUtils.hasLength(userAuditable.getGroupsRegisteredUser())); + assertTrue(userAuditable.getGroupsEditor().contains(group.getName())); + assertTrue(userAuditable.getGroupsReviewer().contains(group2.getName())); + assertFalse(StringUtils.hasLength(userAuditable.getGroupsUserAdmin())); + + assertEquals(group.getName(), userAuditable.getGroupsEditor()); + assertEquals(group2.getName(), userAuditable.getGroupsReviewer()); + } +} diff --git a/core/pom.xml b/core/pom.xml index 09b45fd1817..9600887d592 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -576,6 +576,11 @@ xmlunit-core test + + org.springframework.data + spring-data-envers + test + com.fasterxml.jackson.datatype jackson-datatype-hibernate5 diff --git a/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java b/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java index a96fa132585..c3c7a9e2aa0 100644 --- a/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java +++ b/core/src/main/java/org/fao/geonet/kernel/setting/Settings.java @@ -148,6 +148,8 @@ public class Settings { public static final String METADATA_UNPUBLISH_USERPROFILE = "metadata/publication/profileUnpublishMetadata"; public static final String METADATA_BACKUPARCHIVE_ENABLE = "metadata/backuparchive/enable"; public static final String METADATA_VCS = "metadata/vcs/enable"; + + public static final String SYSTEM_AUDITABLE_ENABLE = "system/auditable/enable"; public static final String VIRTUAL_SETTINGS_SUFFIX_ISDEFINED = "IsDefined"; public static final String NODE = "node/id"; public static final String NODE_DEFAULT = "node/default"; diff --git a/core/src/test/resources/org/fao/geonet/api/Messages.properties b/core/src/test/resources/org/fao/geonet/api/Messages.properties index a2825472f84..8c09a2d1398 100644 --- a/core/src/test/resources/org/fao/geonet/api/Messages.properties +++ b/core/src/test/resources/org/fao/geonet/api/Messages.properties @@ -1,5 +1,5 @@ # -# Copyright (C) 2001-2016 Food and Agriculture Organization of the +# Copyright (C) 2001-2024 Food and Agriculture Organization of the # United Nations (FAO-UN), United Nations World Food Programme (WFP) # and United Nations Environment Programme (UNEP) # @@ -20,7 +20,6 @@ # Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, # Rome - Italy. email: geonetwork@osgeo.org # - mail_error=Failed to send email. mail_config_test_subject=%s / Test / Mail configuration mail_config_test_message=Test message from %s\n\ @@ -247,3 +246,9 @@ api.metadata.status.errorGetStatusNotAllowed=Only the owner of the metadata can api.metadata.status.errorSetStatusNotAllowed=Only the owner of the metadata can set the status of this record. User is not the owner of the metadata. feedback_subject_userFeedback=User feedback + +audit.revision=Updated by %s on %s:\n\ +%s +audit.revision.field.set=- Field '%s' set to '%s' +audit.revision.field.unset=- Field '%s' unset +audit.revision.field.updated=- Field '%s' changed from '%s' to '%s' diff --git a/core/src/test/resources/org/fao/geonet/api/Messages_fre.properties b/core/src/test/resources/org/fao/geonet/api/Messages_fre.properties index 333cd714f68..db0fc9f14e1 100644 --- a/core/src/test/resources/org/fao/geonet/api/Messages_fre.properties +++ b/core/src/test/resources/org/fao/geonet/api/Messages_fre.properties @@ -20,7 +20,6 @@ # Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, # Rome - Italy. email: geonetwork@osgeo.org # - mail_error=Erreur lors de l'envoi du mail. mail_config_test_subject=%s / Test / Configuration serveur de mail mail_config_test_message=Message de test de %s\n\ @@ -240,3 +239,9 @@ api.metadata.status.errorGetStatusNotAllowed=Seul le propri\u00E9taire des m\u00 api.metadata.status.errorSetStatusNotAllowed=Seul le propri\u00E9taire des m\u00E9tadonn\u00E9es peut d\u00E9finir le statut de cet enregistrement. L'utilisateur n'est pas le propri\u00E9taire des m\u00E9tadonn\u00E9es feedback_subject_userFeedback=Commentaire de l'utilisateur + +audit.revision=Mise \u00E0 jour par %s le %s:\n\ +%s +audit.revision.field.set=- Champ '%s' d\u00E9fini \u00E0 '%s' +audit.revision.field.unset=- Champ '%s' d\u00E9sactiv\u00E9 +audit.revision.field.updated=- Champ '%s' modifi\u00E9 de '%s' \u00E0 '%s' diff --git a/core/src/test/resources/org/fao/geonet/kernel/security/ldap/LDAPUserDetailsContextMapperWithProfileSearchEnhancedTest-context.xml b/core/src/test/resources/org/fao/geonet/kernel/security/ldap/LDAPUserDetailsContextMapperWithProfileSearchEnhancedTest-context.xml index bd68693764a..7ab3055d502 100644 --- a/core/src/test/resources/org/fao/geonet/kernel/security/ldap/LDAPUserDetailsContextMapperWithProfileSearchEnhancedTest-context.xml +++ b/core/src/test/resources/org/fao/geonet/kernel/security/ldap/LDAPUserDetailsContextMapperWithProfileSearchEnhancedTest-context.xml @@ -138,7 +138,8 @@ + transaction-manager-ref="transactionManager" + factory-class="org.springframework.data.envers.repository.support.EnversRevisionRepositoryFactoryBean"/> @@ -211,5 +212,8 @@ + + + diff --git a/domain/pom.xml b/domain/pom.xml index 685c96638c2..fceb67a0d28 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -90,6 +90,10 @@ org.springframework.data spring-data-jpa + + org.springframework.data + spring-data-envers + org.springframework spring-test diff --git a/domain/src/main/java/org/fao/geonet/auditable/UsernameAuditorAware.java b/domain/src/main/java/org/fao/geonet/auditable/UsernameAuditorAware.java new file mode 100644 index 00000000000..3f98f4ea5f2 --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/auditable/UsernameAuditorAware.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.auditable; + +import java.util.Optional; +import org.fao.geonet.domain.User; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Extracts the current user used, to store the information in the auditable entities. + */ +public class UsernameAuditorAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return Optional.empty(); + } + + return Optional.of(((User) authentication.getPrincipal()).getUsername()); + } +} diff --git a/domain/src/main/java/org/fao/geonet/auditable/package-info.java b/domain/src/main/java/org/fao/geonet/auditable/package-info.java new file mode 100644 index 00000000000..48c47d3fabf --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/auditable/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +@NonNullApi +package org.fao.geonet.auditable; + +import org.springframework.lang.NonNullApi; diff --git a/domain/src/main/java/org/fao/geonet/domain/auditable/AuditableEntity.java b/domain/src/main/java/org/fao/geonet/domain/auditable/AuditableEntity.java new file mode 100644 index 00000000000..536f12b536d --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/domain/auditable/AuditableEntity.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.domain.auditable; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.Date; +import org.hibernate.envers.Audited; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * Base class for auditable entities, providing fields for creation and last modification user / dates. + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Audited +public abstract class AuditableEntity { + @CreatedBy + @Column(name = "created_by") + @JsonIgnore + protected String createdBy; + + @LastModifiedBy + @Column(name = "last_modified_by", nullable = true, updatable = true) + @JsonIgnore + protected String lastModifiedBy; + + @LastModifiedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "last_modified_date", nullable = true, updatable = true) + @JsonIgnore + protected Date lastModifiedDate; + + @CreatedDate + @Column(name = "created_date") + @Temporal(TemporalType.TIMESTAMP) + @JsonIgnore + private Date createdDate; + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public Date getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Date createdDate) { + this.createdDate = createdDate; + } + + public String getLastModifiedBy() { + return lastModifiedBy; + } + + public void setLastModifiedBy(String lastModifiedBy) { + this.lastModifiedBy = lastModifiedBy; + } + + public Date getLastModifiedDate() { + return lastModifiedDate; + } + + public void setLastModifiedDate(Date lastModifiedDate) { + this.lastModifiedDate = lastModifiedDate; + } +} + diff --git a/domain/src/main/java/org/fao/geonet/domain/auditable/UserAuditable.java b/domain/src/main/java/org/fao/geonet/domain/auditable/UserAuditable.java new file mode 100644 index 00000000000..232b8ae3cbf --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/domain/auditable/UserAuditable.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.domain.auditable; + +import javax.annotation.Nonnull; +import javax.persistence.Access; +import javax.persistence.AccessType; +import javax.persistence.Entity; +import javax.persistence.Id; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.apache.commons.lang.StringUtils; +import org.fao.geonet.domain.Address; +import org.fao.geonet.domain.User; +import org.fao.geonet.domain.UserGroup; +import org.hibernate.envers.Audited; + +/** + * An entity to audit the changes for user entities. + * + * @see org.fao.geonet.domain.User + */ +@Entity +@Access(AccessType.PROPERTY) +@Audited(withModifiedFlag = true) +public class UserAuditable extends AuditableEntity { + + private int id; + private String profile; + private String username; + private String name; + private String surname; + private String emailAddress; + private String organisation; + private String address; + private String city; + private String state; + private String zip; + private String country; + private String kind; + private String groupsRegisteredUser; + private String groupsEditor; + private String groupsReviewer; + private String groupsUserAdmin; + private boolean enabled; + + public static UserAuditable build(User user, List userGroups) { + UserAuditable userAuditable = new UserAuditable(); + + userAuditable.setId(user.getId()); + userAuditable.setUsername(user.getUsername()); + userAuditable.setName(user.getName()); + userAuditable.setSurname(user.getSurname()); + userAuditable.setEnabled(user.isEnabled()); + userAuditable.setKind(user.getKind()); + userAuditable.setOrganisation(user.getOrganisation()); + userAuditable.setProfile(user.getProfile().name()); + if (!user.getEmailAddresses().isEmpty()) { + // A user can have only 1 address defined in the UI. + userAuditable.setEmailAddress((String) user.getEmailAddresses().toArray()[0]); + } + if (!user.getAddresses().isEmpty()) { + // A user can have only 1 address defined in the UI. + Address userAddress = (Address) user.getAddresses().toArray()[0]; + userAuditable.setAddress(userAddress.getAddress()); + userAuditable.setZip(userAddress.getZip()); + userAuditable.setState(userAddress.getState()); + userAuditable.setCity(userAddress.getCity()); + userAuditable.setCountry(userAddress.getCountry()); + } + userAuditable.setEnabled(user.isEnabled()); + + Set groupsRegisteredUserList = new TreeSet<>(); + Set groupsEditorList = new TreeSet<>(); + Set groupsReviewerList = new TreeSet<>(); + Set groupsUserAdminList = new TreeSet<>(); + + // Groups + if (userGroups != null) { + userGroups.forEach(userGroup -> { + switch (userGroup.getProfile()) { + case RegisteredUser: + groupsRegisteredUserList.add(userGroup.getGroup().getName()); + break; + case Editor: + groupsEditorList.add(userGroup.getGroup().getName()); + break; + case Reviewer: + groupsReviewerList.add(userGroup.getGroup().getName()); + break; + case UserAdmin: + groupsUserAdminList.add(userGroup.getGroup().getName()); + break; + default: + break; + } + }); + } + + + userAuditable.setGroupsRegisteredUser(StringUtils.join(groupsRegisteredUserList, ",")); + userAuditable.setGroupsEditor(StringUtils.join(groupsEditorList, ",")); + userAuditable.setGroupsReviewer(StringUtils.join(groupsReviewerList, ",")); + userAuditable.setGroupsUserAdmin(StringUtils.join(groupsUserAdminList, ",")); + + return userAuditable; + } + + @Id + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getProfile() { + return profile; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + @Nonnull + public String getUsername() { + return username; + } + + public void setUsername(@Nonnull String username) { + this.username = username; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSurname() { + return surname; + } + + public void setSurname(String surname) { + this.surname = surname; + } + + public String getEmailAddress() { + return emailAddress; + } + + public void setEmailAddress(String emailAddresses) { + this.emailAddress = emailAddresses; + } + + public String getOrganisation() { + return organisation; + } + + public void setOrganisation(String organisation) { + this.organisation = organisation; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getZip() { + return zip; + } + + public void setZip(String zip) { + this.zip = zip; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getGroupsRegisteredUser() { + return groupsRegisteredUser; + } + + public void setGroupsRegisteredUser(String groupsRegisteredUser) { + this.groupsRegisteredUser = groupsRegisteredUser; + } + + public String getGroupsEditor() { + return groupsEditor; + } + + public void setGroupsEditor(String groupsEditor) { + this.groupsEditor = groupsEditor; + } + + public String getGroupsReviewer() { + return groupsReviewer; + } + + public void setGroupsReviewer(String groupsReviewer) { + this.groupsReviewer = groupsReviewer; + } + + public String getGroupsUserAdmin() { + return groupsUserAdmin; + } + + public void setGroupsUserAdmin(String groupsUserAdmin) { + this.groupsUserAdmin = groupsUserAdmin; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/domain/src/main/java/org/fao/geonet/repository/BaseAuditableRepository.java b/domain/src/main/java/org/fao/geonet/repository/BaseAuditableRepository.java new file mode 100644 index 00000000000..0a7cb21747a --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/repository/BaseAuditableRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.history.RevisionRepository; + +@NoRepositoryBean +public interface BaseAuditableRepository extends RevisionRepository, JpaRepository { +} diff --git a/domain/src/main/java/org/fao/geonet/repository/UserAuditableRepository.java b/domain/src/main/java/org/fao/geonet/repository/UserAuditableRepository.java new file mode 100644 index 00000000000..86ec4cc8f4e --- /dev/null +++ b/domain/src/main/java/org/fao/geonet/repository/UserAuditableRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.repository; + +import org.fao.geonet.domain.auditable.UserAuditable; + +/** + * Data Access object for accessing {@link UserAuditable} entities. + */ +public interface UserAuditableRepository extends BaseAuditableRepository { + +} diff --git a/domain/src/main/resources/config-spring-geonetwork.xml b/domain/src/main/resources/config-spring-geonetwork.xml index 23f50b235b0..f9f171e0802 100644 --- a/domain/src/main/resources/config-spring-geonetwork.xml +++ b/domain/src/main/resources/config-spring-geonetwork.xml @@ -33,7 +33,8 @@ + transaction-manager-ref="transactionManager" + factory-class="org.springframework.data.envers.repository.support.EnversRevisionRepositoryFactoryBean"/> @@ -69,4 +70,9 @@ class="org.springframework.orm.jpa.JpaTransactionManager"> + + + + + diff --git a/pom.xml b/pom.xml index 488bba58f23..2dad9a15d5f 100644 --- a/pom.xml +++ b/pom.xml @@ -1167,6 +1167,11 @@ spring-data-jpa ${spring.jpa.version} + + org.springframework.data + spring-data-envers + ${spring.jpa.version} + com.fasterxml.jackson.datatype jackson-datatype-hibernate5 @@ -1306,7 +1311,6 @@ json 20240205 - @@ -1420,6 +1424,7 @@ index datastorages translationproviders + auditable diff --git a/services/pom.xml b/services/pom.xml index 0c563cb2c8a..c640bb577a3 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -99,8 +99,11 @@ gn-translationproviders ${project.version} - - + + ${project.groupId} + gn-auditable + ${project.version} + org.mockito mockito-all diff --git a/services/src/main/java/org/fao/geonet/api/auditable/AuditableApi.java b/services/src/main/java/org/fao/geonet/api/auditable/AuditableApi.java new file mode 100644 index 00000000000..880c6e48ec8 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/auditable/AuditableApi.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.auditable; + +import javax.servlet.ServletRequest; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.fao.geonet.auditable.BaseAuditableService; +import org.fao.geonet.auditable.model.RevisionInfo; +import org.fao.geonet.domain.auditable.AuditableEntity; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + + +@RequestMapping(value = { + "/{portal}/api/auditable" +}) +@Tag(name = "auditable", + description = "Entity auditable operations") +@RestController("auditable") +public class AuditableApi { + + // Auditable service beans + private final Map> factory = new HashMap<>(); + + public AuditableApi(ListableBeanFactory beanFactory) { + Collection auditableServiceBeans = beanFactory.getBeansOfType(BaseAuditableService.class).values(); + auditableServiceBeans.forEach(filter -> factory.put(filter.getEntityType().toLowerCase(), filter)); + } + + @io.swagger.v3.oas.annotations.Operation( + summary = "Get an entity history", + description = "") + @GetMapping( + value = "/{entityType}/{entityIdentifier}" + ) + @ResponseStatus(HttpStatus.OK) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Entity history details.") + }) + @PreAuthorize("hasAuthority('UserAdmin')") + public List getEntityHistory( + @Parameter( + description = "Entity type", + required = true + ) + @PathVariable + String entityType, + @Parameter( + description = "Entity identifier", + required = true + ) + @PathVariable + Integer entityIdentifier + ) { + BaseAuditableService service = factory.get(entityType); + return service.getEntityHistory(entityIdentifier); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/reports/ReportUsers.java b/services/src/main/java/org/fao/geonet/api/reports/ReportUsers.java index ba3d83bb70c..236237c24d0 100644 --- a/services/src/main/java/org/fao/geonet/api/reports/ReportUsers.java +++ b/services/src/main/java/org/fao/geonet/api/reports/ReportUsers.java @@ -20,12 +20,24 @@ * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, * Rome - Italy. email: geonetwork@osgeo.org */ - package org.fao.geonet.api.reports; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.ResourceBundle; import jeeves.server.context.ServiceContext; import org.apache.commons.csv.CSVPrinter; -import org.fao.geonet.domain.*; +import org.fao.geonet.auditable.UserAuditableService; +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.User; +import org.fao.geonet.domain.UserGroup; +import org.fao.geonet.domain.User_; +import org.fao.geonet.kernel.setting.SettingManager; +import org.fao.geonet.kernel.setting.Settings; import org.fao.geonet.repository.SortUtils; import org.fao.geonet.repository.UserGroupRepository; import org.fao.geonet.repository.UserRepository; @@ -34,17 +46,10 @@ import org.springframework.data.domain.Sort; import org.springframework.util.StringUtils; -import java.io.PrintWriter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - import static org.fao.geonet.api.reports.ReportUtils.CSV_FORMAT; /** * Creates a users report including last login date. - * */ public class ReportUsers implements IReport { /** @@ -72,8 +77,15 @@ public ReportUsers(final ReportFilter filter) { */ public void create(final ServiceContext context, final PrintWriter writer) throws Exception { + SettingManager settingManager = context.getBean(SettingManager.class); + UserAuditableService userAuditableService = context.getBean(UserAuditableService.class); + boolean isUserHistoryEnabled = settingManager.getValueAsBool(Settings.SYSTEM_AUDITABLE_ENABLE, false); + String lang = context.getLanguage(); + ResourceBundle messages = ResourceBundle.getBundle("org.fao.geonet.api.Messages", + new Locale(lang)); + // Initialize CSVPrinter object - try(CSVPrinter csvFilePrinter = new CSVPrinter(writer, CSV_FORMAT)) { + try (CSVPrinter csvFilePrinter = new CSVPrinter(writer, CSV_FORMAT)) { // Retrieve users final UserRepository userRepository = context.getBean(UserRepository.class); @@ -91,7 +103,7 @@ public void create(final ServiceContext context, csvFilePrinter.println(); String[] entries = ("Username#Surname#Name#" - + "Email#User groups/Profile#Last login date").split("#"); + + "Email#User groups/Profile#Last login date" + (isUserHistoryEnabled ? "#Change history" : "")).split("#"); csvFilePrinter.printRecord(Arrays.asList(entries)); for (User user : records) { @@ -112,7 +124,7 @@ public void create(final ServiceContext context, } // Build the record element with the information for the report - List metadataRecord = new ArrayList<>(); + List metadataRecord = new ArrayList<>(isUserHistoryEnabled ? 7 : 6); metadataRecord.add(username); metadataRecord.add(surname); metadataRecord.add(name); @@ -120,6 +132,13 @@ public void create(final ServiceContext context, metadataRecord.add(userGroupsInfo); metadataRecord.add(lastLoginDate); + if (isUserHistoryEnabled) { + String userChanges = userAuditableService.getEntityHistoryAsString(user.getId(), messages); + if (StringUtils.hasLength(userChanges)) { + metadataRecord.add(userChanges); + } + } + csvFilePrinter.printRecord(metadataRecord); } } finally { @@ -129,8 +148,8 @@ public void create(final ServiceContext context, /** * Creates a string with the list of groups / profiles of a user: - * - * group1/profileGroup1-group2/profileGroup2 ... + *

+ * group1/profileGroup1-group2/profileGroup2 ... * * @param context * @param user @@ -159,7 +178,7 @@ private String retrieveGroupsListInfo(final ServiceContext context, User user) { if (i++ > 0) { userGroupsList.append("-"); } - userGroupsList.append(groupName + "/" + groupProfile); + userGroupsList.append(groupName).append("/").append(groupProfile); } return userGroupsList.toString(); diff --git a/services/src/main/java/org/fao/geonet/api/users/UsersApi.java b/services/src/main/java/org/fao/geonet/api/users/UsersApi.java index c6911f9f2bf..bc2116541be 100644 --- a/services/src/main/java/org/fao/geonet/api/users/UsersApi.java +++ b/services/src/main/java/org/fao/geonet/api/users/UsersApi.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2001-2021 Food and Agriculture Organization of the + * Copyright (C) 2001-2024 Food and Agriculture Organization of the * United Nations (FAO-UN), United Nations World Food Programme (WFP) * and United Nations Environment Programme (UNEP) * @@ -38,7 +38,9 @@ import org.fao.geonet.api.users.model.UserDto; import org.fao.geonet.api.users.validation.PasswordResetDtoValidator; import org.fao.geonet.api.users.validation.UserDtoValidator; +import org.fao.geonet.auditable.UserAuditableService; import org.fao.geonet.domain.*; +import org.fao.geonet.domain.auditable.UserAuditable; import org.fao.geonet.exceptions.UserNotFoundEx; import org.fao.geonet.kernel.DataManager; import org.fao.geonet.kernel.datamanager.IMetadataUtils; @@ -116,6 +118,9 @@ public class UsersApi { @Autowired(required=false) SecurityProviderConfiguration securityProviderConfiguration; + @Autowired + UserAuditableService userAuditableService; + private BufferedImage pixel; public UsersApi() { @@ -197,7 +202,7 @@ public User getUser( myUserId.equals(Integer.toString(userIdentifier))) { Optional user = userRepository.findById(userIdentifier); - if (!user.isPresent()) { + if (user.isEmpty()) { throw new UserNotFoundEx(Integer.toString(userIdentifier)); } @@ -246,7 +251,7 @@ public void getUserIdenticon( try { Optional user = userRepository.findById(userIdentifier); - if (!user.isPresent()) { + if (user.isEmpty()) { throw new UserNotFoundEx(Integer.toString(userIdentifier)); } @@ -341,17 +346,28 @@ public ResponseEntity deleteUser( } } + Optional userToDelete = userRepository.findById(userIdentifier); + List userGroups = userGroupRepository.findAll(UserGroupSpecs.hasUserId(userIdentifier)); + userGroupRepository.deleteAllByIdAttribute(UserGroupId_.userId, - Arrays.asList(userIdentifier)); + List.of(userIdentifier)); userSavedSelectionRepository.deleteAllByUser(userIdentifier); + + try { userRepository.deleteById(userIdentifier); } catch (org.springframework.dao.EmptyResultDataAccessException ex) { throw new UserNotFoundEx(Integer.toString(userIdentifier)); } + if (userToDelete.isPresent()) { + UserAuditable userAuditable = UserAuditable.build(userToDelete.get(), userGroups); + userAuditableService.auditDelete(userAuditable); + } + + return new ResponseEntity(HttpStatus.NO_CONTENT); } @@ -393,7 +409,7 @@ public ResponseEntity checkUserPropertyExist( return new ResponseEntity<>(HttpStatus.OK); } } else { - throw new IllegalArgumentException(String.format("Property '%s' is not supported. You can only check username and email")); + throw new IllegalArgumentException("Property is not supported. You can only check username and email"); } return new ResponseEntity<>(HttpStatus.NOT_FOUND); } @@ -488,6 +504,12 @@ public ResponseEntity createUser( user = userRepository.save(user); setUserGroups(user, groups); + List userGroups = userGroupRepository.findAll(UserGroupSpecs + .hasUserId(user.getId())); + + UserAuditable userAuditable = UserAuditable.build(user, userGroups); + userAuditableService.auditSave(userAuditable); + return new ResponseEntity(HttpStatus.NO_CONTENT); } @@ -535,16 +557,16 @@ public ResponseEntity updateUser( // TODO: CheckAccessRights - User user = userRepository.findById(userIdentifier).get(); - if (user == null) { - throw new IllegalArgumentException("No user found with id: " - + userDto.getId()); + Optional userOptional = userRepository.findById(userIdentifier); + if (userOptional.isEmpty()) { + throw new IllegalArgumentException(String.format("No user found with id: %s", userDto.getId())); } + User user = userOptional.get(); // Check no duplicated username and if we are adding a duplicate existing name with other case combination List usersWithUsernameIgnoreCase = userRepository.findByUsernameIgnoreCase(userDto.getUsername()); - if (usersWithUsernameIgnoreCase.size() != 0 && - (!usersWithUsernameIgnoreCase.stream().anyMatch(u -> u.getId() == userIdentifier) + if (!usersWithUsernameIgnoreCase.isEmpty() && + (usersWithUsernameIgnoreCase.stream().noneMatch(u -> u.getId() == userIdentifier) || usersWithUsernameIgnoreCase.stream().anyMatch(u -> u.getUsername().equals(userDto.getUsername()) && u.getId() != userIdentifier) )) { @@ -566,7 +588,7 @@ public ResponseEntity updateUser( groups.addAll(processGroups(userDto.getGroupsReviewer(), Profile.Reviewer)); groups.addAll(processGroups(userDto.getGroupsUserAdmin(), Profile.UserAdmin)); - //If it is a useradmin updating, + //If it is an useradmin updating, //maybe we don't know all the groups the user is part of if (!Profile.Administrator.equals(myProfile)) { List myUserAdminGroups = userGroupRepository.findGroupIds(Specification.where( @@ -614,6 +636,12 @@ public ResponseEntity updateUser( if (securityProviderConfiguration == null || securityProviderConfiguration.isUserGroupUpdateEnabled()) { setUserGroups(user, groups); } + + List userGroups = userGroupRepository.findAll(UserGroupSpecs + .hasUserId(user.getId())); + + UserAuditable userAuditable = UserAuditable.build(user, userGroups); + userAuditableService.auditSave(userAuditable); return new ResponseEntity(HttpStatus.NO_CONTENT); } @@ -667,14 +695,14 @@ public ResponseEntity resetUserPassword( Profile myProfile = session.getProfile(); String myUserId = session.getUserId(); - if (!Profile.Administrator.equals(myProfile) - && !Profile.UserAdmin.equals(myProfile) + if (!Profile.Administrator.equals(myProfile) + && !Profile.UserAdmin.equals(myProfile) && !myUserId.equals(Integer.toString(userIdentifier))) { throw new IllegalArgumentException("You don't have rights to do this"); } Optional user = userRepository.findById(userIdentifier); - if (!user.isPresent()) { + if (user.isEmpty()) { throw new UserNotFoundEx(Integer.toString(userIdentifier)); } @@ -724,10 +752,12 @@ public List retrieveUserGroups( if (Profile.Administrator.equals(myProfile) || Profile.UserAdmin.equals(myProfile) || myUserId.equals(Integer.toString(userIdentifier))) { // -- get the profile of the user id supplied - User user = userRepository.findById(userIdentifier).get(); - if (user == null) { + Optional userOptional = userRepository.findById(userIdentifier); + + if (userOptional.isEmpty()) { throw new IllegalArgumentException("user " + userIdentifier + " doesn't exist"); } + User user = userOptional.get(); String userProfile = user.getProfile().name(); @@ -793,7 +823,7 @@ private void setUserGroups(final User user, List userGroups) .hasUserId(user.getId())); // Have a quick reference of existing groups and profiles for this user - Set listOfAddedProfiles = new HashSet(); + Set listOfAddedProfiles = new HashSet<>(); for (UserGroup ug : all) { String key = ug.getProfile().name() + ug.getGroup().getId(); listOfAddedProfiles.add(key); @@ -801,11 +831,10 @@ private void setUserGroups(final User user, List userGroups) // We start removing all old usergroup objects. We will remove the // explicitly defined for this call - Collection toRemove = new ArrayList(); - toRemove.addAll(all); + Collection toRemove = new ArrayList<>(all); // New pairs of group-profile we need to add - Collection toAdd = new ArrayList(); + Collection toAdd = new ArrayList<>(); // For each of the parameters on the request, make sure the group is // updated. @@ -865,7 +894,7 @@ private void setUserGroups(final User user, List userGroups) private List processGroups(List groupsToProcessList, Profile profile) { - List groups = new LinkedList(); + List groups = new LinkedList<>(); for (String g : groupsToProcessList) { groups.add(new GroupElem(profile.name(), Integer.parseInt(g))); } diff --git a/web-ui/src/main/resources/catalog/components/auditable/AuditableDirective.js b/web-ui/src/main/resources/catalog/components/auditable/AuditableDirective.js new file mode 100644 index 00000000000..b80673301e8 --- /dev/null +++ b/web-ui/src/main/resources/catalog/components/auditable/AuditableDirective.js @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +(function () { + goog.provide("gn_auditable_directive"); + goog.require("gn_auditable_service"); + + var module = angular.module("gn_auditable_directive", ["gn_auditable_service"]); + + module.directive("gnAuditableHistory", [ + "gnAuditableService", + "gnConfigService", + "gnConfig", + function (gnAuditableService, gnConfigService, gnConfig) { + return { + restrict: "A", + replace: true, + scope: { + id: "=gnAuditableHistory", + type: "@" + }, + templateUrl: "../../catalog/components/auditable/partials/auditableHistory.html", + link: function (scope, element, attrs) { + scope.history = []; + + gnConfigService.load().then(function (c) { + if (gnConfig["system.auditable.enable"]) { + scope.$watch("id", function (n, o) { + if (n !== o && n !== undefined) { + scope.history = []; + + gnAuditableService + .getEntityHistory(scope.type, scope.id) + .then(function (response) { + scope.history = response.data; + }); + } + }); + } + }); + } + }; + } + ]); +})(); diff --git a/web-ui/src/main/resources/catalog/components/auditable/AuditableModule.js b/web-ui/src/main/resources/catalog/components/auditable/AuditableModule.js new file mode 100644 index 00000000000..c9c653e6277 --- /dev/null +++ b/web-ui/src/main/resources/catalog/components/auditable/AuditableModule.js @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +(function () { + goog.provide("gn_auditable"); + + goog.require("gn_auditable_directive"); + goog.require("gn_auditable_service"); + + var module = angular.module("gn_auditable", [ + "gn_auditable_directive", + "gn_auditable_service" + ]); +})(); diff --git a/web-ui/src/main/resources/catalog/components/auditable/AuditableService.js b/web-ui/src/main/resources/catalog/components/auditable/AuditableService.js new file mode 100644 index 00000000000..304ef4913ba --- /dev/null +++ b/web-ui/src/main/resources/catalog/components/auditable/AuditableService.js @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2001-2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +(function () { + goog.provide("gn_auditable_service"); + + var module = angular.module("gn_auditable_service", []); + + /** + * Service to deal with Auditable entities. + */ + module.service("gnAuditableService", [ + "$http", + function ($http) { + function getEntityHistory(entityType, entityId) { + return $http.get("../api/auditable/" + entityType + "/" + entityId); + } + + return { + getEntityHistory: getEntityHistory + }; + } + ]); +})(); diff --git a/web-ui/src/main/resources/catalog/components/auditable/partials/auditableHistory.html b/web-ui/src/main/resources/catalog/components/auditable/partials/auditableHistory.html new file mode 100644 index 00000000000..7a2366a3761 --- /dev/null +++ b/web-ui/src/main/resources/catalog/components/auditable/partials/auditableHistory.html @@ -0,0 +1,30 @@ +

+

noUserHistory

+ +
    +
  • +

    + {{ 'userHistoryRevision' | translate:{ revisionUser: u.user, revisionDate: u.date + } }} +

    + +
      +
    • {{u.value}}

    • + +
    • +

      + {{ 'userHistoryFieldUpdate' | translate:{ fieldName: c.name, oldValue: + c.oldValue, newValue: c.newValue } }} +

      +

      + {{ 'userHistoryFieldSet' | translate:{ fieldName: c.name, newValue: c.newValue + } }} +

      +

      + {{ 'userHistoryFieldUnset' | translate:{ fieldName: c.name } }} +

      +
    • +
    +
  • +
+
diff --git a/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js b/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js index 025048896f3..37a1bdedd6a 100644 --- a/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js +++ b/web-ui/src/main/resources/catalog/js/admin/UserGroupController.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2001-2016 Food and Agriculture Organization of the + * Copyright (C) 2001-2024 Food and Agriculture Organization of the * United Nations (FAO-UN), United Nations World Food Programme (WFP) * and United Nations Environment Programme (UNEP) * @@ -27,10 +27,12 @@ goog.require("gn_dbtranslation"); goog.require("gn_multiselect"); goog.require("gn_mdtypewidget"); + goog.require("gn_auditable"); var module = angular.module("gn_usergroup_controller", [ "gn_dbtranslation", "gn_multiselect", + "gn_auditable", "gn_mdtypewidget", "blueimp.fileupload", "ngMessages" @@ -47,8 +49,10 @@ "$rootScope", "$translate", "$timeout", + "$log", "gnConfig", "gnConfigService", + "gnAuditableService", function ( $scope, $routeParams, @@ -56,8 +60,10 @@ $rootScope, $translate, $timeout, + $log, gnConfig, - gnConfigService + gnConfigService, + gnAuditableService ) { $scope.searchObj = { params: { @@ -117,6 +123,7 @@ $scope.isLoadingUsers = false; $scope.isLoadingGroups = false; + $scope.auditableEnabled = false; gnConfigService.load().then(function (c) { // take the bigger of the two values @@ -135,6 +142,8 @@ gnConfig["system.security.passwordEnforcement.pattern"] ); } + + $scope.auditableEnabled = gnConfig["system.auditable.enable"]; }); // This is to force IE11 NOT to cache json requests @@ -312,9 +321,21 @@ // TODO } ); + + // Load user changes + gnAuditableService.getEntityHistory("user", u.id).then( + function (response) { + $scope.userHistory = response.data; + }, + function (response) { + // TODO + $log.error("Error retrieving the audit history of user " + u.id); + } + ); }, function (response) { // TODO + $log.error("Error retrieving the info of user " + u.id); } ); diff --git a/web-ui/src/main/resources/catalog/locales/en-admin.json b/web-ui/src/main/resources/catalog/locales/en-admin.json index 43e15b7548d..ac324daf305 100644 --- a/web-ui/src/main/resources/catalog/locales/en-admin.json +++ b/web-ui/src/main/resources/catalog/locales/en-admin.json @@ -854,6 +854,9 @@ "system/banner": "Application banner", "system/banner/enable": "Enable", "system/banner/enable-help": "If set, an application banner is displayed with the message configured. To configure the message, go to Language and translations and configure a translation with the key application-banner", + "system/auditable": "Audit changes", + "system/auditable/enable": "Allow auditing changes", + "system/auditable/enable-help": "When enabled, audits changes in users configuration", "metadata/workflow": "Metadata workflow", "metadata/workflow/automaticUnpublishInvalidMd": "Automatic unpublication of invalid metadata", "metadata/workflow/automaticUnpublishInvalidMd-help": " Automatically unpublishes metadata that is edited that becomes not valid according to xsd or schematron rules.", @@ -1531,6 +1534,12 @@ "doiserver-euApiText": "Publication Office of the European Union", "confirmDoiServerDelete": "Are you sure you want to delete this DOI server?", "NoTranslationProvider": "No translation provider", - "LibreTranslate": "Libretranslate" + "LibreTranslate": "Libretranslate", + "userHistory": "User history", + "userHistoryRevision": "Updated by {{revisionUser}} on {{revisionDate}}:", + "userHistoryFieldUpdate": "Field '{{fieldName}}' changed from '{{oldValue}}' to '{{newValue}}'", + "userHistoryFieldSet": "Field '{{fieldName}}' set to '{{newValue}}'", + "userHistoryFieldUnset": "Field '{{fieldName}}' unset", + "noUserHistory": "No user history available" } diff --git a/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html b/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html index 39a7163574e..86b09c14d3b 100644 --- a/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html +++ b/web-ui/src/main/resources/catalog/templates/admin/usergroup/users.html @@ -609,6 +609,21 @@

UserAdmin

+
+ +
+
+   + userHistory +
+
+
+
+
+
${project.version} + + ${project.groupId} + gn-auditable + ${project.version} + + dlib diff --git a/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages.properties b/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages.properties index 0d5044c4a65..accb35b9632 100644 --- a/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages.properties +++ b/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages.properties @@ -254,3 +254,9 @@ api.metadata.status.errorGetStatusNotAllowed=Only the owner of the metadata can api.metadata.status.errorSetStatusNotAllowed=Only the owner of the metadata can set the status of this record. User is not the owner of the metadata. feedback_subject_userFeedback=User feedback + +audit.revision=Updated by %s on %s:\n\ +%s +audit.revision.field.set=- Field '%s' set to '%s' +audit.revision.field.unset=- Field '%s' unset +audit.revision.field.updated=- Field '%s' changed from '%s' to '%s' diff --git a/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages_fre.properties b/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages_fre.properties index 02b7f00af87..a52fec3dc97 100644 --- a/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages_fre.properties +++ b/web/src/main/webapp/WEB-INF/classes/org/fao/geonet/api/Messages_fre.properties @@ -247,3 +247,9 @@ api.metadata.status.errorGetStatusNotAllowed=Seul le propri\u00E9taire des m\u00 api.metadata.status.errorSetStatusNotAllowed=Seul le propri\u00E9taire des m\u00E9tadonn\u00E9es peut d\u00E9finir le statut de cet enregistrement. L'utilisateur n'est pas le propri\u00E9taire des m\u00E9tadonn\u00E9es feedback_subject_userFeedback=Commentaire de l'utilisateur + +audit.revision=Mise \u00E0 jour par %s le %s:\n\ +%s +audit.revision.field.set=- Champ '%s' d\u00E9fini \u00E0 '%s' +audit.revision.field.unset=- Champ '%s' d\u00E9sactiv\u00E9 +audit.revision.field.updated=- Champ '%s' modifi\u00E9 de '%s' \u00E0 '%s' diff --git a/web/src/main/webapp/WEB-INF/classes/setup/sql/data/data-db-default.sql b/web/src/main/webapp/WEB-INF/classes/setup/sql/data/data-db-default.sql index a0b0b7c8843..d9a267e3d93 100644 --- a/web/src/main/webapp/WEB-INF/classes/setup/sql/data/data-db-default.sql +++ b/web/src/main/webapp/WEB-INF/classes/setup/sql/data/data-db-default.sql @@ -743,6 +743,9 @@ INSERT INTO Settings (name, value, datatype, position, internal, editable) VALUE INSERT INTO Settings (name, value, datatype, position, internal) VALUES ('system/banner/enable', 'false', 2, 1920, 'n'); +INSERT INTO Settings (name, value, datatype, position, internal) VALUES ('system/auditable/enable', 'false', 2, 12010, 'n'); + + -- WARNING: Security / Add this settings only if you need to allow admin -- users to be able to reset user password. If you have mail server configured diff --git a/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v445/migrate-default.sql b/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v445/migrate-default.sql index 7f56bfa6c6c..1fbdff7dbd0 100644 --- a/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v445/migrate-default.sql +++ b/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v445/migrate-default.sql @@ -7,3 +7,4 @@ INSERT INTO Settings (name, value, datatype, position, internal) VALUES ('system INSERT INTO Settings (name, value, datatype, position, internal) SELECT distinct 'system/feedback/languages', '', 0, 646, 'n' from settings WHERE NOT EXISTS (SELECT name FROM Settings WHERE name = 'system/feedback/languages'); INSERT INTO Settings (name, value, datatype, position, internal) SELECT distinct 'system/feedback/translationFollowsText', '', 0, 647, 'n' from settings WHERE NOT EXISTS (SELECT name FROM Settings WHERE name = 'system/feedback/translationFollowsText'); + diff --git a/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v447/migrate-default.sql b/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v447/migrate-default.sql index 7cebc07e38d..a2a75e37d54 100644 --- a/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v447/migrate-default.sql +++ b/web/src/main/webapp/WEB-INF/classes/setup/sql/migrate/v447/migrate-default.sql @@ -2,3 +2,4 @@ UPDATE Settings SET value='4.4.7' WHERE name='system/platform/version'; UPDATE Settings SET value='SNAPSHOT' WHERE name='system/platform/subVersion'; INSERT INTO Settings (name, value, datatype, position, internal) VALUES ('system/banner/enable', 'false', 2, 1920, 'n'); +INSERT INTO Settings (name, value, datatype, position, internal) VALUES ('system/auditable/enable', 'false', 2, 12010, 'n'); diff --git a/workers/camelPeriodicProducer/pom.xml b/workers/camelPeriodicProducer/pom.xml index 80eef98c055..8ad71fb6d79 100644 --- a/workers/camelPeriodicProducer/pom.xml +++ b/workers/camelPeriodicProducer/pom.xml @@ -72,5 +72,10 @@ org.quartz-scheduler quartz + + org.springframework.data + spring-data-envers + test + diff --git a/workers/camelPeriodicProducer/src/test/resources/domain-repository-test-context.xml b/workers/camelPeriodicProducer/src/test/resources/domain-repository-test-context.xml index 0fe4d59e39e..572d8a95a50 100644 --- a/workers/camelPeriodicProducer/src/test/resources/domain-repository-test-context.xml +++ b/workers/camelPeriodicProducer/src/test/resources/domain-repository-test-context.xml @@ -65,7 +65,8 @@ + transaction-manager-ref="transactionManager" + factory-class="org.springframework.data.envers.repository.support.EnversRevisionRepositoryFactoryBean"/> @@ -94,4 +95,9 @@ class="org.jasypt.encryption.pbe.StandardPBEStringEncryptor" /> + + + + +