From 1bfdba26bd221c3b74fff29a06dceb13d8c00ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20L=C3=A4mmer?= Date: Tue, 10 Feb 2026 10:59:03 +0100 Subject: [PATCH 1/3] [MODAUD-297] Add user event audit consumer Enable audit tracking for mod-users changes so that administrators can review user record history. - Events from the users.users Kafka topic are consumed and stored in a new user_audit table - Field-level diffs are computed for updates - Deleting a user cascades to remove their audit history - Processing is gated by the USER_RECORDS_ENABLED setting --- NEWS.md | 1 + PERSONAL_DATA_DISCLOSURE.md | 15 +- .../org/folio/dao/user/UserAuditEntity.java | 8 + .../java/org/folio/dao/user/UserEventDao.java | 15 ++ .../folio/dao/user/impl/UserEventDaoImpl.java | 87 ++++++++++ .../mapper/user/UserEventToEntityMapper.java | 43 +++++ .../java/org/folio/rest/impl/InitAPIs.java | 11 +- .../diff/user/UserDiffCalculator.java | 38 +++++ .../folio/services/user/UserEventService.java | 9 ++ .../user/impl/UserEventServiceImpl.java | 88 ++++++++++ .../java/org/folio/util/user/UserEvent.java | 34 ++++ .../org/folio/util/user/UserEventType.java | 38 +++++ .../org/folio/util/user/UserKafkaEvent.java | 20 +++ .../java/org/folio/util/user/UserUtils.java | 18 +++ .../verticle/user/UserConsumersVerticle.java | 41 +++++ .../user/consumers/UserEventHandler.java | 68 ++++++++ .../templates/db_scripts/schema.json | 5 + .../user/create_user_audit_table.sql | 9 ++ .../dao/user/impl/UserEventDaoImplTest.java | 96 +++++++++++ .../user/UserEventToEntityMapperTest.java | 94 +++++++++++ .../diff/user/UserDiffCalculatorTest.java | 73 +++++++++ .../user/impl/UserEventServiceImplTest.java | 150 ++++++++++++++++++ .../org/folio/util/user/UserEventTest.java | 58 +++++++ .../org/folio/util/user/UserUtilsTest.java | 76 +++++++++ .../java/org/folio/utils/EntityUtils.java | 23 +++ .../user/consumers/UserEventHandlerTest.java | 132 +++++++++++++++ 26 files changed, 1242 insertions(+), 8 deletions(-) create mode 100644 mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java create mode 100644 mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java create mode 100644 mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java create mode 100644 mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java create mode 100644 mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java create mode 100644 mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java create mode 100644 mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java create mode 100644 mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java create mode 100644 mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java create mode 100644 mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java create mode 100644 mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java create mode 100644 mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java create mode 100644 mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java create mode 100644 mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql create mode 100644 mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java create mode 100644 mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java diff --git a/NEWS.md b/NEWS.md index b36b55c3..28a08fea 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,7 @@ * [MODAUD-288](https://folio-org.atlassian.net/browse/MODAUD-288) - assertj: Upgrade from 3.27.3 to 3.27.7, change scope from compile to test * [MODAUD-296](https://folio-org.atlassian.net/browse/MODAUD-296) - Implement User Audit Enabled Setting * [MODAUD-298](https://folio-org.atlassian.net/browse/MODAUD-298) - Extract shared utilities and decouple DiffCalculator from inventory types +* [MODAUD-297](https://folio-org.atlassian.net/browse/MODAUD-297) - Consume user domain events and store audit history ## 2.11.1 2025-04-15 * [MODAUD-250](https://folio-org.atlassian.net/browse/MODAUD-250) - Version history of "MARC" records is not tracked diff --git a/PERSONAL_DATA_DISCLOSURE.md b/PERSONAL_DATA_DISCLOSURE.md index aa41c225..a639f9e1 100644 --- a/PERSONAL_DATA_DISCLOSURE.md +++ b/PERSONAL_DATA_DISCLOSURE.md @@ -12,24 +12,25 @@ For the purposes of this form, "store" includes the following: ## Personal Data Stored by This Module - [ ] This module does not store any personal data. - [ ] This module provides [custom fields](https://github.com/folio-org/folio-custom-fields). -- [ ] This module stores fields with free-form text (tags, notes, descriptions, etc.) +- [x] This module stores custom fields values +- [x] This module stores fields with free-form text (tags, notes, descriptions, etc.) - [x] This module caches personal data --- - [x] First name - [x] Last name -- [ ] Middle name -- [ ] Pseudonym / Alias / Nickname / Username / User ID +- [x] Middle name +- [x] Pseudonym / Alias / Nickname / Username / User ID - [ ] Gender -- [ ] Date of birth +- [x] Date of birth - [ ] Place of birth - [ ] Racial or ethnic origin -- [ ] Address +- [x] Address - [ ] Location information -- [ ] Phone numbers +- [x] Phone numbers - [ ] Passport number / National identification numbers - [ ] Driver’s license number - [ ] Social security number -- [ ] Email address +- [x] Email address - [ ] Web cookies - [ ] IP address - [ ] Geolocation data diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java b/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java new file mode 100644 index 00000000..84621f14 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java @@ -0,0 +1,8 @@ +package org.folio.dao.user; + +import java.sql.Timestamp; +import java.util.UUID; +import org.folio.domain.diff.ChangeRecordDto; + +public record UserAuditEntity(UUID eventId, Timestamp eventDate, UUID userId, + String action, UUID performedBy, ChangeRecordDto diff) { } diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java b/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java new file mode 100644 index 00000000..3a126f4b --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java @@ -0,0 +1,15 @@ +package org.folio.dao.user; + +import io.vertx.core.Future; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import java.util.UUID; + +public interface UserEventDao { + + Future> save(UserAuditEntity userAuditEntity, String tenantId); + + Future deleteByUserId(UUID userId, String tenantId); + + String tableName(); +} diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java b/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java new file mode 100644 index 00000000..9351f566 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java @@ -0,0 +1,87 @@ +package org.folio.dao.user.impl; + +import static org.folio.util.DbUtils.formatDBTableName; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.json.JsonObject; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.Tuple; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.util.PostgresClientFactory; +import org.springframework.stereotype.Repository; + +@Repository +public class UserEventDaoImpl implements UserEventDao { + + private static final Logger LOGGER = LogManager.getLogger(); + private static final String USER_AUDIT_TABLE = "user_audit"; + + private static final String INSERT_SQL = """ + INSERT INTO %s (event_id, event_date, user_id, action, performed_by, diff) + VALUES ($1, $2, $3, $4, $5, $6) + """; + + private static final String DELETE_BY_USER_ID_SQL = """ + DELETE FROM %s + WHERE user_id = $1 + """; + + private final PostgresClientFactory pgClientFactory; + + public UserEventDaoImpl(PostgresClientFactory pgClientFactory) { + this.pgClientFactory = pgClientFactory; + } + + @Override + public Future> save(UserAuditEntity event, String tenantId) { + LOGGER.debug("save:: Trying to save UserAuditEntity with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.eventId(), event.userId()); + var promise = Promise.>promise(); + var table = formatDBTableName(tenantId, tableName()); + var query = INSERT_SQL.formatted(table); + makeSaveCall(promise, query, event, tenantId); + return promise.future(); + } + + @Override + public Future deleteByUserId(UUID userId, String tenantId) { + LOGGER.debug("deleteByUserId:: Deleting user audit records with [tenantId: {}, userId: {}]", + tenantId, userId); + var table = formatDBTableName(tenantId, tableName()); + var query = DELETE_BY_USER_ID_SQL.formatted(table); + return pgClientFactory.createInstance(tenantId).execute(query, Tuple.of(userId)) + .mapEmpty(); + } + + @Override + public String tableName() { + return USER_AUDIT_TABLE; + } + + private void makeSaveCall(Promise> promise, String query, UserAuditEntity event, String tenantId) { + LOGGER.debug("makeSaveCall:: Making save call with query : {} and tenant id : {}", query, tenantId); + try { + pgClientFactory.createInstance(tenantId).execute(query, Tuple.of(event.eventId(), + LocalDateTime.ofInstant(event.eventDate().toInstant(), ZoneId.systemDefault()), + event.userId(), + event.action(), + event.performedBy(), + event.diff() != null ? JsonObject.mapFrom(event.diff()) : null), + promise); + LOGGER.info("makeSaveCall:: Saving UserAuditEntity with [tenantId: {}, eventId:{}, userId:{}]", + tenantId, event.eventId(), event.userId()); + } catch (Exception e) { + LOGGER.error("Failed to save record with [eventId:{}, userId:{}, tableName: {}]", + event.eventId(), event.userId(), tableName(), e); + promise.fail(e); + } + } +} diff --git a/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java b/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java new file mode 100644 index 00000000..4bd3566e --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java @@ -0,0 +1,43 @@ +package org.folio.mapper.user; + +import java.sql.Timestamp; +import java.util.UUID; +import java.util.function.Function; +import org.folio.dao.user.UserAuditEntity; +import org.folio.domain.diff.ChangeRecordDto; +import org.folio.services.diff.user.UserDiffCalculator; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.util.user.UserUtils; +import org.springframework.stereotype.Component; + +@Component +public class UserEventToEntityMapper implements Function { + + private final UserDiffCalculator userDiffCalculator; + + public UserEventToEntityMapper(UserDiffCalculator userDiffCalculator) { + this.userDiffCalculator = userDiffCalculator; + } + + @Override + public UserAuditEntity apply(UserEvent event) { + var performedByStr = UserUtils.extractPerformedBy(event); + var performedBy = performedByStr != null ? UUID.fromString(performedByStr) : null; + var diff = UserEventType.UPDATED.equals(event.getType()) + ? getDiff(event) + : null; + return new UserAuditEntity( + UUID.fromString(event.getId()), + new Timestamp(event.getTimestamp()), + UUID.fromString(event.getUserId()), + event.getType().name(), + performedBy, + diff + ); + } + + private ChangeRecordDto getDiff(UserEvent event) { + return userDiffCalculator.calculateDiff(event.getOldValue(), event.getNewValue()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java b/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java index 961cc463..66b89b56 100644 --- a/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java +++ b/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java @@ -26,6 +26,7 @@ import org.folio.verticle.inventory.InstanceConsumersVerticle; import org.folio.verticle.inventory.ItemConsumersVerticle; import org.folio.verticle.marc.MarcRecordEventConsumersVerticle; +import org.folio.verticle.user.UserConsumersVerticle; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.support.AbstractApplicationContext; @@ -85,6 +86,11 @@ public class InitAPIs implements InitAPI { @Value("${src.source-records.kafka.consumer.pool.size:5}") private int srsSourceRecordsConsumerPoolSize; + @Value("${user.users.kafka.consumer.instancesNumber:1}") + private int userConsumerInstancesNumber; + @Value("${user.users.kafka.consumer.pool.size:5}") + private int userConsumerPoolSize; + @Override public void init(Vertx vertx, Context context, Handler> handler) { LOGGER.debug("init:: InitAPI starting..."); @@ -122,6 +128,7 @@ private Future deployConsumersVerticles(Vertx vertx) { Promise inventoryHoldingsConsumer = Promise.promise(); Promise inventoryItemConsumer = Promise.promise(); Promise sourceRecordsConsumer = Promise.promise(); + Promise userEventsConsumer = Promise.promise(); deployVerticle(vertx, verticleFactory, OrderEventConsumersVerticle.class, acqOrderConsumerInstancesNumber, acqOrderConsumerPoolSize, orderEventsConsumer); deployVerticle(vertx, verticleFactory, OrderLineEventConsumersVerticle.class, acqOrderLineConsumerInstancesNumber, acqOrderLineConsumerPoolSize, orderLineEventsConsumer); @@ -133,6 +140,7 @@ private Future deployConsumersVerticles(Vertx vertx) { deployVerticle(vertx, verticleFactory, HoldingsConsumersVerticle.class, invHoldingsConsumerInstancesNumber, invHoldingsConsumerPoolSize, inventoryHoldingsConsumer); deployVerticle(vertx, verticleFactory, ItemConsumersVerticle.class, invItemConsumerInstancesNumber, invItemConsumerPoolSize, inventoryItemConsumer); deployVerticle(vertx, verticleFactory, MarcRecordEventConsumersVerticle.class, srsSourceRecordsConsumerInstancesNumber, srsSourceRecordsConsumerPoolSize, sourceRecordsConsumer); + deployVerticle(vertx, verticleFactory, UserConsumersVerticle.class, userConsumerInstancesNumber, userConsumerPoolSize, userEventsConsumer); LOGGER.info("deployConsumersVerticles:: All consumer verticles were successfully deployed"); return GenericCompositeFuture.all(Arrays.asList( @@ -145,7 +153,8 @@ private Future deployConsumersVerticles(Vertx vertx) { inventoryInstanceConsumer.future(), inventoryHoldingsConsumer.future(), inventoryItemConsumer.future(), - sourceRecordsConsumer.future() + sourceRecordsConsumer.future(), + userEventsConsumer.future() )); } diff --git a/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java new file mode 100644 index 00000000..132d7893 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java @@ -0,0 +1,38 @@ +package org.folio.services.diff.user; + +import java.util.function.Supplier; +import org.folio.rest.external.CustomFields; +import org.folio.rest.external.Metadata; +import org.folio.rest.external.Personal__1; +import org.folio.rest.external.Tags__3; +import org.folio.rest.external.User; +import org.folio.services.diff.DiffCalculator; +import org.springframework.stereotype.Component; + +@Component +public class UserDiffCalculator extends DiffCalculator { + + @Override + protected Supplier access(User value) { + return () -> { + if (value.getPersonal() == null) { + value.setPersonal(new Personal__1()); + } + if (value.getMetadata() == null) { + value.setMetadata(new Metadata()); + } + if (value.getTags() == null) { + value.setTags(new Tags__3()); + } + if (value.getCustomFields() == null) { + value.setCustomFields(new CustomFields()); + } + return value; + }; + } + + @Override + protected Class getType() { + return User.class; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java b/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java new file mode 100644 index 00000000..d5c5a720 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java @@ -0,0 +1,9 @@ +package org.folio.services.user; + +import io.vertx.core.Future; +import org.folio.util.user.UserEvent; + +public interface UserEventService { + + Future processEvent(UserEvent event, String tenantId); +} diff --git a/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java b/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java new file mode 100644 index 00000000..7a02295f --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java @@ -0,0 +1,88 @@ +package org.folio.services.user.impl; + +import static org.folio.util.ErrorUtils.handleFailures; + +import io.vertx.core.Future; +import java.util.UUID; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.services.configuration.ConfigurationService; +import org.folio.services.configuration.Setting; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class UserEventServiceImpl implements UserEventService { + + private static final Logger LOGGER = LogManager.getLogger(); + + private final Function eventToEntityMapper; + private final ConfigurationService configurationService; + private final UserEventDao userEventDao; + + public UserEventServiceImpl(Function eventToEntityMapper, + ConfigurationService configurationService, + UserEventDao userEventDao) { + this.eventToEntityMapper = eventToEntityMapper; + this.configurationService = configurationService; + this.userEventDao = userEventDao; + } + + @Override + public Future processEvent(UserEvent event, String tenantId) { + LOGGER.debug("processEvent:: Trying to process UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.getId(), event.getUserId()); + + return configurationService.getSetting(Setting.USER_RECORDS_ENABLED, tenantId) + .compose(setting -> { + if (!Boolean.TRUE.equals(setting.getValue())) { + LOGGER.debug("processEvent:: User audit is disabled for tenant [tenantId: {}]", tenantId); + return Future.succeededFuture(event.getId()); + } + return process(event, tenantId); + }) + .recover(throwable -> { + LOGGER.error("processEvent:: Could not process UserEvent for [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.getId(), event.getUserId(), throwable); + return handleFailures(throwable, event.getId()); + }); + } + + private Future process(UserEvent event, String tenantId) { + if (UserEventType.DELETED.equals(event.getType())) { + return deleteAll(event, tenantId); + } + return save(event, tenantId); + } + + private Future save(UserEvent event, String tenantId) { + var eventId = event.getId(); + LOGGER.debug("save:: Trying to save UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, eventId, event.getUserId()); + + var entity = eventToEntityMapper.apply(event); + if (UserEventType.UPDATED.equals(event.getType()) && entity.diff() == null) { + LOGGER.debug("save:: No diff calculated for UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, eventId, event.getUserId()); + return Future.succeededFuture(eventId); + } + + return userEventDao.save(entity, tenantId).map(eventId); + } + + private Future deleteAll(UserEvent event, String tenantId) { + var userId = UUID.fromString(event.getUserId()); + LOGGER.debug("deleteAll:: Trying to delete all user audit records with [tenantId: {}, userId: {}]", + tenantId, userId); + return userEventDao.deleteByUserId(userId, tenantId) + .map(event.getId()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java b/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java new file mode 100644 index 00000000..7f44af9c --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java @@ -0,0 +1,34 @@ +package org.folio.util.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserEvent { + + private String id; + private UserEventType type; + private String tenant; + private Long timestamp; + private String userId; + private Map oldValue; + private Map newValue; + + @JsonSetter("data") + @SuppressWarnings("unchecked") + public void setData(Map data) { + if (data != null) { + this.oldValue = (Map) data.get("old"); + this.newValue = (Map) data.get("new"); + } + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java b/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java new file mode 100644 index 00000000..c1c56f4e --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java @@ -0,0 +1,38 @@ +package org.folio.util.user; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum UserEventType { + + CREATED("CREATED"), + UPDATED("UPDATED"), + DELETED("DELETED"), + UNKNOWN("UNKNOWN"); + + private final String value; + + UserEventType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static UserEventType fromValue(String value) { + for (var eventType : UserEventType.values()) { + if (eventType.value.equals(value)) { + return eventType; + } + } + return UNKNOWN; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java b/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java new file mode 100644 index 00000000..57d8a62c --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java @@ -0,0 +1,20 @@ +package org.folio.util.user; + +public enum UserKafkaEvent { + USER("users"); + + private static final String TOPIC_GROUP = "users"; + private final String topicName; + + UserKafkaEvent(String value) { + this.topicName = value; + } + + public String getTopicName() { + return TOPIC_GROUP + "." + topicName; + } + + public String getTopicPattern() { + return TOPIC_GROUP + "\\." + topicName; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java b/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java new file mode 100644 index 00000000..94e6fe36 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java @@ -0,0 +1,18 @@ +package org.folio.util.user; + +import lombok.experimental.UtilityClass; +import org.folio.util.KafkaUtils; +import org.folio.util.PayloadUtils; + +@UtilityClass +public class UserUtils { + + public static String extractPerformedBy(UserEvent event) { + var payload = event.getNewValue() != null ? event.getNewValue() : event.getOldValue(); + return PayloadUtils.extractPerformedByUserId(payload); + } + + public static String formatUserTopicPattern(String env, UserKafkaEvent eventType) { + return KafkaUtils.formatTopicPattern(env, eventType.getTopicPattern()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java b/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java new file mode 100644 index 00000000..870e073f --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java @@ -0,0 +1,41 @@ +package org.folio.verticle.user; + +import static org.folio.util.user.UserUtils.formatUserTopicPattern; + +import java.util.List; +import org.folio.kafka.AsyncRecordHandler; +import org.folio.kafka.KafkaConfig; +import org.folio.kafka.SubscriptionDefinition; +import org.folio.util.user.UserKafkaEvent; +import org.folio.verticle.AbstractConsumersVerticle; +import org.folio.verticle.user.consumers.UserEventHandler; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.stereotype.Component; + +@Component +public class UserConsumersVerticle extends AbstractConsumersVerticle { + + private final ObjectFactory recordHandlerProvider; + + public UserConsumersVerticle(ObjectFactory recordHandlerProvider) { + this.recordHandlerProvider = recordHandlerProvider; + } + + @Override + protected SubscriptionDefinition subscriptionDefinition(String event, KafkaConfig kafkaConfig) { + return SubscriptionDefinition.builder() + .eventType(event) + .subscriptionPattern(formatUserTopicPattern(kafkaConfig.getEnvId(), UserKafkaEvent.USER)) + .build(); + } + + @Override + public List getEvents() { + return List.of(UserKafkaEvent.USER.getTopicName()); + } + + @Override + public AsyncRecordHandler getHandler() { + return recordHandlerProvider.getObject(); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java b/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java new file mode 100644 index 00000000..ba4ffa83 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java @@ -0,0 +1,68 @@ +package org.folio.verticle.user.consumers; + +import static org.folio.util.user.UserEventType.UNKNOWN; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.kafka.client.consumer.KafkaConsumerRecord; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.kafka.AsyncRecordHandler; +import org.folio.kafka.KafkaHeaderUtils; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.rest.util.OkapiConnectionParams; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class UserEventHandler implements AsyncRecordHandler { + + private static final Logger LOGGER = LogManager.getLogger(); + + private final UserEventService userEventService; + private final Vertx vertx; + + public UserEventHandler(Vertx vertx, UserEventService userEventService) { + this.vertx = vertx; + this.userEventService = userEventService; + } + + @Override + public Future handle(KafkaConsumerRecord kafkaConsumerRecord) { + var result = Promise.promise(); + var kafkaHeaders = kafkaConsumerRecord.headers(); + var okapiConnectionParams = new OkapiConnectionParams(KafkaHeaderUtils.kafkaHeadersToMap(kafkaHeaders), vertx); + var event = new JsonObject(kafkaConsumerRecord.value()).mapTo(UserEvent.class); + event.setUserId(kafkaConsumerRecord.key()); + + if (UNKNOWN == event.getType()) { + LOGGER.debug("handle:: Event type not supported [eventId: {}, userId: {}]", + event.getId(), event.getUserId()); + result.complete(event.getId()); + return result.future(); + } + + LOGGER.info("handle:: Starting processing of User event with id: {} for user id: {}", event.getId(), event.getUserId()); + userEventService.processEvent(event, okapiConnectionParams.getTenantId()) + .onSuccess(ar -> { + LOGGER.info("handle:: User event with id: {} has been processed for user id: {}", event.getId(), event.getUserId()); + result.complete(event.getId()); + }) + .onFailure(e -> { + if (e instanceof DuplicateEventException) { + LOGGER.info("handle:: Duplicate User event with id: {} for user id: {} received, skipped processing", event.getId(), event.getUserId()); + result.complete(event.getId()); + } else { + LOGGER.error("Processing of User event with id: {} for user id: {} has been failed", event.getId(), event.getUserId(), e); + result.fail(e); + } + }); + return result.future(); + } +} diff --git a/mod-audit-server/src/main/resources/templates/db_scripts/schema.json b/mod-audit-server/src/main/resources/templates/db_scripts/schema.json index ca335af2..853ba256 100644 --- a/mod-audit-server/src/main/resources/templates/db_scripts/schema.json +++ b/mod-audit-server/src/main/resources/templates/db_scripts/schema.json @@ -144,6 +144,11 @@ "run": "after", "snippetPath": "acquisition/update_to_orders_storage_14.0.ftl", "fromModuleVersion": "mod-audit-3.0.0" + }, + { + "run": "after", + "snippetPath": "user/create_user_audit_table.sql", + "fromModuleVersion": "mod-audit-3.0.0" } ] } diff --git a/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql new file mode 100644 index 00000000..9a49e603 --- /dev/null +++ b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS user_audit ( + event_id UUID PRIMARY KEY, + event_date TIMESTAMP NOT NULL, + user_id UUID NOT NULL, + action VARCHAR NOT NULL, + performed_by UUID, + diff JSONB +); +CREATE INDEX IF NOT EXISTS idx_user_audit_user_id ON user_audit USING BTREE (user_id); diff --git a/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java b/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java new file mode 100644 index 00000000..4cfc2192 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java @@ -0,0 +1,96 @@ +package org.folio.dao.user.impl; + +import static org.folio.utils.EntityUtils.TENANT_ID; +import static org.folio.utils.EntityUtils.createUserAuditEntity; +import static org.folio.utils.MockUtils.mockPostgresExecutionSuccess; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import io.vertx.core.Future; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.sqlclient.Tuple; +import org.folio.rest.persist.PostgresClient; +import org.folio.util.PostgresClientFactory; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventDaoImplTest { + + @Mock + PostgresClientFactory postgresClientFactory; + @Mock + PostgresClient postgresClient; + @InjectMocks + UserEventDaoImpl userEventDao; + + @BeforeEach + void setUp() { + lenient().when(postgresClientFactory.createInstance(TENANT_ID)).thenReturn(postgresClient); + mockPostgresExecutionSuccess(2).when(postgresClient).execute(anyString(), any(Tuple.class), any()); + } + + @Test + void shouldSaveSuccessfully(VertxTestContext ctx) { + var entity = createUserAuditEntity(); + + userEventDao.save(entity, TENANT_ID) + .onComplete(ctx.succeeding(result -> ctx.completeNow())); + + verify(postgresClientFactory, times(1)).createInstance(TENANT_ID); + } + + @Test + void shouldHandleExceptionOnSave(VertxTestContext ctx) { + var entity = createUserAuditEntity(); + + mockPostgresExecutionSuccess(2) + .doThrow(new IllegalStateException("Error")) + .when(postgresClient).execute(anyString(), any(Tuple.class), any()); + + userEventDao.save(entity, TENANT_ID) + .onComplete(ctx.succeeding(result -> + userEventDao.save(entity, TENANT_ID) + .onComplete(re -> { + assertTrue(re.failed()); + assertInstanceOf(IllegalStateException.class, re.cause()); + assertEquals("Error", re.cause().getMessage()); + ctx.completeNow(); + }) + )); + verify(postgresClientFactory, times(2)).createInstance(TENANT_ID); + } + + @Test + void shouldDeleteByUserId(VertxTestContext ctx) { + var userId = createUserAuditEntity().userId(); + + doReturn(Future.succeededFuture()) + .when(postgresClient).execute(anyString(), any(Tuple.class)); + + userEventDao.deleteByUserId(userId, TENANT_ID) + .onComplete(ctx.succeeding(result -> { + verify(postgresClient, times(1)).execute(anyString(), any(Tuple.class)); + ctx.completeNow(); + })); + } + + @Test + void shouldReturnCorrectTableName() { + assertEquals("user_audit", userEventDao.tableName()); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java b/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java new file mode 100644 index 00000000..5fac70f5 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java @@ -0,0 +1,94 @@ +package org.folio.mapper.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Timestamp; +import java.util.Map; +import java.util.UUID; +import org.folio.services.diff.user.UserDiffCalculator; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@UnitTest +class UserEventToEntityMapperTest { + + private UserEventToEntityMapper mapper; + + @BeforeEach + void setUp() { + mapper = new UserEventToEntityMapper(new UserDiffCalculator()); + } + + @Test + void shouldMapUpdatedEventWithDiff() { + var event = createUserEvent(UserEventType.UPDATED); + event.setOldValue(Map.of("username", "oldUser", "id", "123")); + event.setNewValue(Map.of("username", "newUser", "id", "123", + "metadata", Map.of("updatedByUserId", UUID.randomUUID().toString()))); + + var result = mapper.apply(event); + + assertEquals(UUID.fromString(event.getId()), result.eventId()); + assertEquals(new Timestamp(event.getTimestamp()), result.eventDate()); + assertEquals(UUID.fromString(event.getUserId()), result.userId()); + assertEquals(UserEventType.UPDATED.name(), result.action()); + assertNotNull(result.diff()); + assertNotNull(result.performedBy()); + } + + @EnumSource(value = UserEventType.class, names = {"CREATED", "DELETED"}) + @ParameterizedTest + void shouldMapCreateAndDeleteWithNullDiff(UserEventType eventType) { + var event = createUserEvent(eventType); + + var result = mapper.apply(event); + + assertEquals(UUID.fromString(event.getId()), result.eventId()); + assertEquals(new Timestamp(event.getTimestamp()), result.eventDate()); + assertEquals(UUID.fromString(event.getUserId()), result.userId()); + assertEquals(eventType.name(), result.action()); + assertNull(result.diff()); + } + + @Test + void shouldMapUpdateEventWithNullDiffIfNoChanges() { + var event = createUserEvent(UserEventType.UPDATED); + var sameData = Map.of("username", "sameUser", "id", "123"); + event.setOldValue(sameData); + event.setNewValue(sameData); + + var result = mapper.apply(event); + + assertEquals(UserEventType.UPDATED.name(), result.action()); + assertNull(result.diff()); + } + + @Test + void shouldHandleNullPerformedBy() { + var event = createUserEvent(UserEventType.CREATED); + event.setNewValue(Map.of("username", "testuser")); + + var result = mapper.apply(event); + + assertNull(result.performedBy()); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant("diku") + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "value")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java new file mode 100644 index 00000000..b39f50ba --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java @@ -0,0 +1,73 @@ +package org.folio.services.diff.user; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import java.util.Map; +import org.folio.domain.diff.FieldChangeDto; +import org.folio.rest.external.Personal__1; +import org.folio.rest.external.User; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserDiffCalculatorTest { + + private UserDiffCalculator userDiffCalculator; + + @BeforeEach + void setUp() { + userDiffCalculator = new UserDiffCalculator(); + } + + @Test + void shouldDetectUsernameChange() { + var oldUser = getMap(new User().withId("1").withUsername("oldUser")); + var newUser = getMap(new User().withId("1").withUsername("newUser")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("username", "username", "oldUser", "newUser")); + } + + @Test + void shouldDetectPersonalInfoChange() { + var oldUser = getMap(new User().withId("1").withPersonal(new Personal__1().withFirstName("John"))); + var newUser = getMap(new User().withId("1").withPersonal(new Personal__1().withFirstName("Jane"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("firstName", "personal.firstName", "John", "Jane")); + } + + @Test + void shouldHandleNullNestedObjects() { + var oldUser = getMap(new User().withId("1")); + var newUser = getMap(new User().withId("1").withUsername("newUser")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.added("username", "username", "newUser")); + } + + @Test + void shouldReturnNullWhenNoDifference() { + var userData = getMap(new User().withId("1").withUsername("sameUser")); + + var diff = userDiffCalculator.calculateDiff(userData, userData); + + assertThat(diff).isNull(); + } + + private static Map getMap(User obj) { + return new JsonObject(Json.encode(obj)).getMap(); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java b/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java new file mode 100644 index 00000000..222b4633 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java @@ -0,0 +1,150 @@ +package org.folio.services.user.impl; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.Future; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.mapper.user.UserEventToEntityMapper; +import org.folio.rest.jaxrs.model.Setting; +import org.folio.services.configuration.ConfigurationService; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventServiceImplTest { + + private static final String TENANT_ID = "testTenant"; + + @Mock + private RowSet rowSet; + @Mock + private UserEventToEntityMapper eventToEntityMapper; + @Mock + private ConfigurationService configurationService; + @Mock + private UserEventDao userEventDao; + + private UserEventService eventService; + + @BeforeEach + void setUp() { + eventService = new UserEventServiceImpl(eventToEntityMapper, configurationService, userEventDao); + } + + @Test + void shouldSaveCreatedEventSuccessfully(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.CREATED.name(), null, null)); + when(userEventDao.save(any(), anyString())).thenReturn(Future.succeededFuture(rowSet)); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, times(1)).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldNotProcessWhenAuditDisabled(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(false); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, never()).save(any(), anyString()); + verify(userEventDao, never()).deleteByUserId(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldDeleteAllRecordsOnDeleteEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.DELETED); + mockAuditEnabled(true); + when(userEventDao.deleteByUserId(any(UUID.class), anyString())).thenReturn(Future.succeededFuture()); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, times(1)).deleteByUserId(any(UUID.class), anyString()); + verify(userEventDao, never()).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldNotSaveUpdateEventWhenDiffIsNull(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UPDATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.UPDATED.name(), null, null)); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, never()).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleDuplicateEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.CREATED.name(), null, null)); + when(userEventDao.save(any(), anyString())).thenReturn( + Future.failedFuture(new io.vertx.pgclient.PgException("duplicate", null, "23505", null))); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.failing(cause -> { + assertInstanceOf(DuplicateEventException.class, cause); + ctx.completeNow(); + })); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "value")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } + + private void mockAuditEnabled(boolean value) { + when(configurationService.getSetting(any(), eq(TENANT_ID))) + .thenReturn(Future.succeededFuture(new Setting().withValue(value))); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java b/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java new file mode 100644 index 00000000..02b77907 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java @@ -0,0 +1,58 @@ +package org.folio.util.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserEventTest { + + @Test + void shouldExtractOldAndNewFromData() { + var oldValue = Map.of("username", "oldUser"); + var newValue = Map.of("username", "newUser"); + var data = Map.of("old", oldValue, "new", newValue); + + var event = new UserEvent(); + event.setData(data); + + assertEquals(oldValue, event.getOldValue()); + assertEquals(newValue, event.getNewValue()); + } + + @Test + void shouldHandleNullData() { + var event = new UserEvent(); + event.setData(null); + + assertNull(event.getOldValue()); + assertNull(event.getNewValue()); + } + + @Test + void shouldHandleMissingOldInData() { + var newValue = Map.of("username", "newUser"); + var data = Map.of("new", newValue); + + var event = new UserEvent(); + event.setData(data); + + assertNull(event.getOldValue()); + assertEquals(newValue, event.getNewValue()); + } + + @Test + void shouldHandleMissingNewInData() { + var oldValue = Map.of("username", "oldUser"); + var data = Map.of("old", oldValue); + + var event = new UserEvent(); + event.setData(data); + + assertEquals(oldValue, event.getOldValue()); + assertNull(event.getNewValue()); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java b/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java new file mode 100644 index 00000000..ef95ab9f --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java @@ -0,0 +1,76 @@ +package org.folio.util.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserUtilsTest { + + private static final String USER_ID = "550e8400-e29b-41d4-a716-446655440000"; + + @Test + void shouldExtractPerformedByFromUpdatedByUserId() { + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("updatedByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldExtractPerformedByFromCreatedByUserId() { + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("createdByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldPreferUpdatedByOverCreatedBy() { + var updatedBy = "660e8400-e29b-41d4-a716-446655440001"; + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("updatedByUserId", updatedBy, "createdByUserId", USER_ID))) + .build(); + + assertEquals(updatedBy, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldReturnNullWhenNoMetadata() { + var event = UserEvent.builder() + .newValue(Map.of("username", "testuser")) + .build(); + + assertNull(UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldReturnNullWhenNoPayload() { + var event = UserEvent.builder().build(); + + assertNull(UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldFallbackToOldValueWhenNewValueIsNull() { + var event = UserEvent.builder() + .oldValue(Map.of("metadata", Map.of("createdByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldFormatUserTopicPattern() { + var pattern = UserUtils.formatUserTopicPattern("folio", UserKafkaEvent.USER); + + assertNotNull(pattern); + assertEquals("(folio\\.)(.*\\.)users\\.users", pattern); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java b/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java index f7d6f427..dac22311 100644 --- a/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java +++ b/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java @@ -4,6 +4,7 @@ import org.folio.dao.configuration.SettingEntity; import org.folio.dao.configuration.SettingValueType; import org.folio.dao.inventory.InventoryAuditEntity; +import org.folio.dao.user.UserAuditEntity; import org.folio.dao.marc.MarcAuditEntity; import org.folio.domain.diff.ChangeRecordDto; import org.folio.domain.diff.ChangeType; @@ -17,6 +18,8 @@ import org.folio.util.inventory.InventoryEvent; import org.folio.util.inventory.InventoryEventType; import org.folio.util.inventory.InventoryResourceType; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; import org.folio.util.marc.EventMetadata; import org.folio.util.marc.MarcEventPayload; import org.folio.util.marc.Record; @@ -315,6 +318,26 @@ public static SourceRecordDomainEvent sourceRecordDomainEventWithNoDiff() { return event; } + public static UserAuditEntity createUserAuditEntity() { + var changeRecordDto = new ChangeRecordDto(); + changeRecordDto.setFieldChanges(List.of(new FieldChangeDto(ChangeType.MODIFIED, "username", "username", "old", "new"))); + + return new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), UUID.randomUUID(), "UPDATED", + UUID.randomUUID(), changeRecordDto); + } + + public static UserEvent createUserEvent(String eventId, UserEventType type) { + return UserEvent.builder() + .id(eventId) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "newValue")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } + public static MarcAuditEntity createMarcAuditEntity() { return new MarcAuditEntity( UUID.randomUUID().toString(), diff --git a/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java b/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java new file mode 100644 index 00000000..8d284a54 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java @@ -0,0 +1,132 @@ +package org.folio.verticle.user.consumers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.json.Json; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.kafka.client.consumer.KafkaConsumerRecord; +import io.vertx.kafka.client.consumer.impl.KafkaConsumerRecordImpl; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.rest.util.OkapiConnectionParams; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventHandlerTest { + + private static final String TENANT_ID = "diku"; + private static final String TOKEN = "token"; + + @Spy + private Vertx vertx = Vertx.vertx(); + + @Mock + private UserEventService userEventService; + + private UserEventHandler userEventHandler; + + @BeforeEach + void setUp() { + userEventHandler = new UserEventHandler(vertx, userEventService); + } + + @Test + void shouldHandleSupportedEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())).thenReturn(Future.succeededFuture()); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleDuplicateEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())) + .thenReturn(Future.failedFuture(new DuplicateEventException("Duplicate event"))); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleUnsupportedEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UNKNOWN); + var kafkaRecord = buildKafkaConsumerRecord(event); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, never()).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldFailOnProcessEventError(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UPDATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())) + .thenReturn(Future.failedFuture(new RuntimeException("Error"))); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.failing(cause -> { + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .newValue(Map.of("key", "value")) + .build(); + } + + private KafkaConsumerRecord buildKafkaConsumerRecord(UserEvent event) { + var userId = UUID.randomUUID().toString(); + var consumerRecord = new ConsumerRecord<>("folio.diku.users.users", 0, 0, userId, Json.encode(event)); + consumerRecord.headers().add(new RecordHeader(OkapiConnectionParams.OKAPI_TENANT_HEADER, TENANT_ID.getBytes(StandardCharsets.UTF_8))); + consumerRecord.headers().add(new RecordHeader("x-okapi-url", "http://localhost:8080".getBytes(StandardCharsets.UTF_8))); + consumerRecord.headers().add(new RecordHeader("x-okapi-token", TOKEN.getBytes(StandardCharsets.UTF_8))); + return new KafkaConsumerRecordImpl<>(consumerRecord); + } +} From 9b56956e88d6eb2fb896aaa9de3d7f5580ac6cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20L=C3=A4mmer?= Date: Fri, 13 Feb 2026 07:42:51 +0100 Subject: [PATCH 2/3] [MODAUD-297] Update indices to incorporate event_date for later usage --- .../templates/db_scripts/user/create_user_audit_table.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql index 9a49e603..d3e258fe 100644 --- a/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql +++ b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql @@ -6,4 +6,5 @@ CREATE TABLE IF NOT EXISTS user_audit ( performed_by UUID, diff JSONB ); -CREATE INDEX IF NOT EXISTS idx_user_audit_user_id ON user_audit USING BTREE (user_id); +CREATE INDEX IF NOT EXISTS idx_user_audit_user_id_event_date ON user_audit USING BTREE (user_id, event_date DESC); +CREATE INDEX IF NOT EXISTS idx_user_audit_event_date ON user_audit USING BTREE (event_date); From 583a2cb748a5030f00a559f3280a6511473b6585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20L=C3=A4mmer?= Date: Fri, 13 Feb 2026 14:42:31 +0100 Subject: [PATCH 3/3] [MODAUD-297] Add MapChange support to DiffCalculator with customFields diffing Extend DiffCalculator to handle Javers MapChange events, suppressed by default to filter noise from jsonschema2pojo additionalProperties maps. UserDiffCalculator overrides this to process customFields, which uses additionalProperties to carry tenant-defined custom field data. --- .../folio/services/diff/DiffCalculator.java | 51 +++++++++++ .../diff/user/UserDiffCalculator.java | 6 ++ .../services/diff/DiffCalculatorTest.java | 12 +++ .../diff/user/UserDiffCalculatorTest.java | 88 +++++++++++++++++++ 4 files changed, 157 insertions(+) diff --git a/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java b/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java index 08183798..25a5fac5 100644 --- a/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java +++ b/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java @@ -26,6 +26,11 @@ import org.javers.core.diff.changetype.container.ElementValueChange; import org.javers.core.diff.changetype.container.ValueAddOrRemove; import org.javers.core.diff.changetype.container.ValueAdded; +import org.javers.core.diff.changetype.map.EntryAdded; +import org.javers.core.diff.changetype.map.EntryChange; +import org.javers.core.diff.changetype.map.EntryRemoved; +import org.javers.core.diff.changetype.map.EntryValueChange; +import org.javers.core.diff.changetype.map.MapChange; import org.javers.core.metamodel.object.ValueObjectId; public abstract class DiffCalculator { @@ -71,6 +76,10 @@ private ChangeRecordDto convert(Changes changes) { fieldChanges.add(processValueChange(valueChange)); } else if (change instanceof CollectionChange collectionChange) { collectionChanges.add(processCollectionChange(collectionChange, groupedChanges)); + // MapChanges are skipped by default to filter out noise from JSON schema additionalProperties. + // Subclasses opt in for map properties that carry real data (see UserDiffCalculator). + } else if (change instanceof MapChange mapChange && shouldProcessMapChange(mapChange)) { + fieldChanges.addAll(processMapChange(mapChange)); } } @@ -129,6 +138,48 @@ private CollectionItemChangeDto processElementValueChange(ElementValueChange ele ); } + /** + * Determines whether a {@link MapChange} should be processed and included in the diff output. + * + *

JSON schema-generated models include an {@code additionalProperties} catch-all map that + * Javers treats as a real property, producing spurious {@link MapChange} events for every entity. + * The default implementation returns {@code false} to suppress these. + * + *

Subclasses should override this when the entity has map-type properties that carry real + * semantic data (e.g., {@code CustomFields} in the User model, whose values are stored in the + * underlying {@code additionalProperties} map). + * + * @param mapChange the map change reported by Javers + * @return {@code true} to include this change in the diff, {@code false} to skip it + */ + protected boolean shouldProcessMapChange(MapChange mapChange) { + return false; + } + + private List processMapChange(MapChange mapChange) { + var result = new ArrayList(); + String basePath = mapChange.getPropertyNameWithPath(); + String propName = mapChange.getPropertyName(); + String prefix = basePath.endsWith(propName) + ? basePath.substring(0, basePath.length() - propName.length()) + : ""; + + for (var entry : mapChange.getEntryChanges()) { + EntryChange entryChange = (EntryChange) entry; + String key = String.valueOf(entryChange.getKey()); + String fullPath = prefix + key; + + if (entryChange instanceof EntryValueChange evc) { + result.add(FieldChangeDto.modified(key, fullPath, evc.getLeftValue(), evc.getRightValue())); + } else if (entryChange instanceof EntryAdded ea) { + result.add(FieldChangeDto.added(key, fullPath, ea.getValue())); + } else if (entryChange instanceof EntryRemoved er) { + result.add(FieldChangeDto.removed(key, fullPath, er.getValue())); + } + } + return result; + } + private FieldChangeDto processValueChange(ValueChange valueChange) { return FieldChangeDto.of( valueChange.getPropertyName(), diff --git a/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java index 132d7893..13cd8cfd 100644 --- a/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java +++ b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java @@ -2,6 +2,7 @@ import java.util.function.Supplier; import org.folio.rest.external.CustomFields; +import org.javers.core.diff.changetype.map.MapChange; import org.folio.rest.external.Metadata; import org.folio.rest.external.Personal__1; import org.folio.rest.external.Tags__3; @@ -31,6 +32,11 @@ protected Supplier access(User value) { }; } + @Override + protected boolean shouldProcessMapChange(MapChange mapChange) { + return mapChange.getPropertyNameWithPath().startsWith("customFields."); + } + @Override protected Class getType() { return User.class; diff --git a/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java b/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java index 6ec29ac8..3b33d99e 100644 --- a/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java +++ b/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java @@ -283,6 +283,18 @@ void shouldDetectFieldChangesInInnerObjectCollectionAndInnerObject() { ); } + @Test + void shouldIgnoreAdditionalProperties() { + var oldInstance = getMap(new Instance().withId("1").withAdditionalProperty("junk", "old")); + var newInstance = getMap(new Instance().withId("1").withAdditionalProperty("junk", "new")); + + var diff = diffCalculator.calculateDiff(oldInstance, newInstance); + + assertThat(diff) + .as("Changes to additionalProperties should be ignored by default") + .isNull(); + } + private static Map getMap(Instance obj) { return new JsonObject(Json.encode(obj)).getMap(); } diff --git a/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java index b39f50ba..d10e6007 100644 --- a/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java +++ b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java @@ -6,6 +6,7 @@ import io.vertx.core.json.JsonObject; import java.util.Map; import org.folio.domain.diff.FieldChangeDto; +import org.folio.rest.external.CustomFields; import org.folio.rest.external.Personal__1; import org.folio.rest.external.User; import org.folio.utils.UnitTest; @@ -67,6 +68,93 @@ void shouldReturnNullWhenNoDifference() { assertThat(diff).isNull(); } + @Test + void shouldDetectCustomFieldChanges() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "meaning"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning")); + } + + @Test + void shouldDetectAddedCustomField() { + var oldUser = getMap(new User().withId("1")); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.added("reasonForLife", "customFields.reasonForLife", "42")); + } + + @Test + void shouldDetectRemovedCustomField() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.removed("reasonForLife", "customFields.reasonForLife", "42")); + } + + @Test + void shouldDetectMultipleCustomFieldChanges() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields() + .withAdditionalProperty("reasonForLife", "42") + .withAdditionalProperty("toBeRemoved", "gone"))); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields() + .withAdditionalProperty("reasonForLife", "meaning") + .withAdditionalProperty("brandNew", "hello"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(3) + .containsExactlyInAnyOrder( + FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning"), + FieldChangeDto.removed("toBeRemoved", "customFields.toBeRemoved", "gone"), + FieldChangeDto.added("brandNew", "customFields.brandNew", "hello")); + } + + @Test + void shouldDetectCustomFieldAndRegularFieldChanges() { + var oldUser = getMap(new User().withId("1").withUsername("oldUser") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1").withUsername("newUser") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "meaning"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(2) + .containsExactlyInAnyOrder( + FieldChangeDto.modified("username", "username", "oldUser", "newUser"), + FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning")); + } + + @Test + void shouldIgnoreAdditionalPropertiesOnUser() { + var oldUser = getMap(new User().withId("1").withAdditionalProperty("junk", "old")); + var newUser = getMap(new User().withId("1").withAdditionalProperty("junk", "new")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff).isNull(); + } + private static Map getMap(User obj) { return new JsonObject(Json.encode(obj)).getMap(); }