diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index bde1873627..88c65f6d5a 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1029,8 +1029,16 @@ audit-log: "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. "ai_model": "${AUDIT_LOG_MASK_AI_MODEL:W}" # AI model logging levels. sink: - # Type of external sink. possible options: none, elasticsearch + # Type of external sink. possible options: none, elasticsearch, webhook or stdout (info-scope) type: "${AUDIT_LOG_SINK_TYPE:none}" + #URL for the Webhook Endpoint + webhook_url: "${AUDIT_LOG_SINK_WEBHOOK_URL}" + #Webhook API Key is transmitted via (X-API-KEY Header) + webhook_api_key: "${AUDIT_LOG_SINK_WEBHOOK_API_KEY:}" + #Webhook API Secret is transmitted via (X-API-KEY Header) + webhook_api_secret: "${AUDIT_LOG_SINK_WEBHOOK_API_SECRET:}" + #Set Header "Content-Encoding", "gzip" and compress each webhook messages with gzip + webhook_compression: "${AUDIT_LOG_SINK_WEBHOOK_COMPRESSION:false}" # Name of the index where audit logs are stored # Index name could contain next placeholders (not mandatory): # @{TENANT} - substituted by tenant ID diff --git a/common/util/src/main/java/org/thingsboard/common/util/AuditLogUtil.java b/common/util/src/main/java/org/thingsboard/common/util/AuditLogUtil.java new file mode 100644 index 0000000000..afa6e2c3b8 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/AuditLogUtil.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.common.util; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.common.data.audit.AuditLog; + +import java.time.LocalDateTime; +import java.util.Date; + +public class AuditLogUtil { + + public static String format(AuditLog auditLogEntry) { + StringBuilder logMessage = new StringBuilder(); + logMessage.append("Audit log entry:\n") + .append("EventTime : ").append(new Date(auditLogEntry.getCreatedTime())).append("\n") + .append("ID : ").append(auditLogEntry.getId()).append("\n") + .append("TenantID : ").append(auditLogEntry.getTenantId()).append("\n"); + if (auditLogEntry.getCustomerId() != null) { + logMessage.append("CustomerID : ").append(auditLogEntry.getCustomerId()).append("\n"); + } + logMessage.append("UserID : ").append(auditLogEntry.getUserId()).append("\n") + .append("UserName : ").append(auditLogEntry.getUserName()).append("\n") + .append("Action : ").append(auditLogEntry.getActionType()).append("\n") + .append("ActionStatus : ").append(auditLogEntry.getActionStatus()).append("\n"); + if (auditLogEntry.getActionData() != null) { + logMessage.append("ActionData : ").append(auditLogEntry.getActionData()).append("\n"); + } + if (!auditLogEntry.getActionFailureDetails().isEmpty()) { + logMessage.append("ActionFailureDetails : ").append(auditLogEntry.getActionFailureDetails()).append("\n"); + } + logMessage.append("EntityType : ").append(auditLogEntry.getEntityId().getEntityType()).append("\n") + .append("EntityID : ").append(auditLogEntry.getEntityId()).append("\n") + .append("EntityName : ").append(auditLogEntry.getEntityName()).append("\n"); + return logMessage.toString(); + } + + + public static String createJsonRecord(AuditLog auditLog) { + ObjectNode auditLogNode = JacksonUtil.newObjectNode(); + auditLogNode.put("postDate", LocalDateTime.now().toString()); + auditLogNode.put("id", auditLog.getId().getId().toString()); + auditLogNode.put("entityName", auditLog.getEntityName()); + auditLogNode.put("tenantId", auditLog.getTenantId().getId().toString()); + if (auditLog.getCustomerId() != null) { + auditLogNode.put("customerId", auditLog.getCustomerId().getId().toString()); + } + auditLogNode.put("entityId", auditLog.getEntityId().getId().toString()); + auditLogNode.put("entityType", auditLog.getEntityId().getEntityType().name()); + auditLogNode.put("userId", auditLog.getUserId().getId().toString()); + auditLogNode.put("userName", auditLog.getUserName()); + auditLogNode.put("actionType", auditLog.getActionType().name()); + if (auditLog.getActionData() != null) { + auditLogNode.put("actionData", auditLog.getActionData().toString()); + } + auditLogNode.put("actionStatus", auditLog.getActionStatus().name()); + auditLogNode.put("actionFailureDetails", auditLog.getActionFailureDetails()); + return auditLogNode.toString(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java index ee0dda8f28..3408ef4539 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.audit.sink; -import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @@ -27,16 +26,12 @@ import org.apache.http.client.CredentialsProvider; import org.apache.http.entity.ContentType; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.nio.entity.NStringEntity; -import org.elasticsearch.client.Request; -import org.elasticsearch.client.Response; -import org.elasticsearch.client.ResponseListener; -import org.elasticsearch.client.RestClient; -import org.elasticsearch.client.RestClientBuilder; +import org.elasticsearch.client.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; -import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.AuditLogUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.AuditLog; @@ -118,7 +113,7 @@ public class ElasticsearchAuditLogSink implements AuditLogSink { } private void doLogAction(AuditLog auditLogEntry) { - String jsonContent = createElasticJsonRecord(auditLogEntry); + String jsonContent = AuditLogUtil.createJsonRecord(auditLogEntry); HttpEntity entity = new NStringEntity( jsonContent, @@ -130,27 +125,6 @@ public class ElasticsearchAuditLogSink implements AuditLogSink { restClient.performRequestAsync(request, responseListener); } - private String createElasticJsonRecord(AuditLog auditLog) { - ObjectNode auditLogNode = JacksonUtil.newObjectNode(); - auditLogNode.put("postDate", LocalDateTime.now().toString()); - auditLogNode.put("id", auditLog.getId().getId().toString()); - auditLogNode.put("entityName", auditLog.getEntityName()); - auditLogNode.put("tenantId", auditLog.getTenantId().getId().toString()); - if (auditLog.getCustomerId() != null) { - auditLogNode.put("customerId", auditLog.getCustomerId().getId().toString()); - } - auditLogNode.put("entityId", auditLog.getEntityId().getId().toString()); - auditLogNode.put("entityType", auditLog.getEntityId().getEntityType().name()); - auditLogNode.put("userId", auditLog.getUserId().getId().toString()); - auditLogNode.put("userName", auditLog.getUserName()); - auditLogNode.put("actionType", auditLog.getActionType().name()); - if (auditLog.getActionData() != null) { - auditLogNode.put("actionData", auditLog.getActionData().toString()); - } - auditLogNode.put("actionStatus", auditLog.getActionStatus().name()); - auditLogNode.put("actionFailureDetails", auditLog.getActionFailureDetails()); - return auditLogNode.toString(); - } private ResponseListener responseListener = new ResponseListener() { @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/StdOutAuditLogSink.java b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/StdOutAuditLogSink.java new file mode 100644 index 0000000000..7acfdc7814 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/StdOutAuditLogSink.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.audit.sink; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AuditLogUtil; +import org.thingsboard.server.common.data.audit.AuditLog; + +/** + * The StdOutAuditLogSink class provides an implementation of the AuditLogSink interface that + * writes audit log entries to the info-scope of standard output (stdout) using a formatted log message. + * + * This class is enabled when the application configuration specifies the type + * of the audit-log sink as "stdout". It is primarily used for debugging or local development + * purposes where audit log entries need to be observed in real-time. + * + * The log entries are formatted using the {@link AuditLogUtil#format(AuditLog)} method to + * provide detailed and structured output of the audit log data. + * + * Annotations: + * - {@code @Slf4j}: Enables logging functionality for logging messages. + * - {@code @Component}: Marks this class as a Spring-managed component. + * - {@code @ConditionalOnProperty}: Configures this class to be instantiated conditionally based + * on the presence and value of the "audit-log.sink.type" property in the application configuration. + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "audit-log.sink", value = "type", havingValue = "stdout") +public class StdOutAuditLogSink implements AuditLogSink { + + @Override + public void logAction(AuditLog auditLogEntry) { + log.info("Audit log entry: {}", AuditLogUtil.format(auditLogEntry)); + + } + + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSink.java b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSink.java new file mode 100644 index 0000000000..a6a02b0a95 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSink.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.dao.audit.sink; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AuditLogUtil; +import org.thingsboard.server.common.data.audit.AuditLog; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Base64; +import java.util.zip.GZIPOutputStream; + +/* + * A component that implements the {@link AuditLogSink} interface to forward audit log entries to a configured WebHook endpoint. + * This implementation supports authentication via API key and secret, which are included in the request headers. + * The endpoint URL and authentication details are configured through application properties. + * As an optional parameter it can compress the logs via gzip before sending + * + * The component initializes an HTTP client upon startup and performs cleanup during shutdown to release resources. + */ +@Component +@ConditionalOnProperty(prefix = "audit-log.sink", value = "type", havingValue = "webhook") +@Slf4j +public class WebHookAuditLogSink implements AuditLogSink { + + private HttpClient client; + @Value("${audit-log.sink.webhook_url}") + private String url; + @Value("${audit-log.sink.webhook_api_key}") + private String apiKey; + @Value("${audit-log.sink.webhook_api_secret}") + private String apiSecret; + + @Value("${audit-log.sink.webhook_gzip_enabled:false}") + private boolean gzipEnabled; + + @PostConstruct + private void init() { + log.info("Initializing WebHookAuditLogSink"); + this.client = HttpClient.newBuilder().build(); + log.info("WebHookAuditLogSink initialized"); + + } + + @Override + public void logAction(AuditLog auditLogEntry) { + String payload = AuditLogUtil.createJsonRecord(auditLogEntry); + HttpRequest.BodyPublisher bodyPublisher; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(); + + try { + if (gzipEnabled) { + byte[] compressedPayload = gzipCompress(payload); + bodyPublisher = HttpRequest.BodyPublishers.ofByteArray(compressedPayload); + requestBuilder.header("Content-Encoding", "gzip"); + log.debug("Using gzip compression for payload."); + } else { + bodyPublisher = HttpRequest.BodyPublishers.ofString(payload); + } + } catch (IOException e) { + log.error("Error with compressing the auditLogData {}", e.getMessage(), e); + bodyPublisher = HttpRequest.BodyPublishers.ofString(payload); + } + + HttpRequest request = requestBuilder + .POST(bodyPublisher) + .uri(URI.create(url)) + .header("Accept", "application/json") + .header("X-API-KEY", apiKey) + .header("X-API-SECRET", Base64.getEncoder().encodeToString(apiSecret.getBytes())) + .header("Content-Type", "application/json") + .build(); + client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenAccept(response -> log.debug("Audit log entry sent to WebHook: {} with response status code: {}", + AuditLogUtil.format(auditLogEntry), response.statusCode())) + .exceptionally(e -> { + log.error("Error while sending : {}", e.getMessage(), e); + return null; + }); + } + + + private byte[] gzipCompress(String payload) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { + gzipOutputStream.write(payload.getBytes()); + } + return byteArrayOutputStream.toByteArray(); + } + + @PreDestroy + private void destroy() { + //Set Client to Null as it doesnt implement any AutoClosable @TODO change in J21 to shutdown() + client = null; + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSinkTest.java b/dao/src/test/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSinkTest.java new file mode 100644 index 0000000000..29569d3277 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSinkTest.java @@ -0,0 +1,198 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.dao.audit.sink; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.audit.ActionStatus; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.id.*; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class WebHookAuditLogSinkTest { + + private WebHookAuditLogSink webHookAuditLogSink; + + @Mock + private HttpClient httpClient; + + @Mock + private CompletableFuture> completableFuture; + + @Mock + private CompletableFuture voidCompletableFuture; + + private final String TEST_URL = "http://test-webhook.com"; + private final String TEST_API_KEY = "test-api-key"; + private final String TEST_API_SECRET = "test-api-secret"; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + webHookAuditLogSink = new WebHookAuditLogSink(); + ReflectionTestUtils.setField(webHookAuditLogSink, "url", TEST_URL); + ReflectionTestUtils.setField(webHookAuditLogSink, "apiKey", TEST_API_KEY); + ReflectionTestUtils.setField(webHookAuditLogSink, "apiSecret", TEST_API_SECRET); + ReflectionTestUtils.setField(webHookAuditLogSink, "gzipEnabled", false); + ReflectionTestUtils.setField(webHookAuditLogSink, "client", httpClient); + } + + @Test + public void testLogAction() { + // Create a sample AuditLog + AuditLog auditLog = createSampleAuditLog(); + + // Mock HTTP client response + when(httpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(completableFuture); + when(completableFuture.thenAccept(any(Consumer.class))).thenReturn(voidCompletableFuture); + when(voidCompletableFuture.exceptionally(any())).thenReturn(voidCompletableFuture); + + // Call the method under test + webHookAuditLogSink.logAction(auditLog); + + // Capture the HTTP request + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).sendAsync(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + // Verify the request + HttpRequest capturedRequest = requestCaptor.getValue(); + assertEquals(URI.create(TEST_URL), capturedRequest.uri()); + assertEquals("POST", capturedRequest.method()); + + // Verify headers + assertTrue(capturedRequest.headers().map().containsKey("X-API-KEY")); + assertEquals(TEST_API_KEY, capturedRequest.headers().firstValue("X-API-KEY").orElse(null)); + + assertTrue(capturedRequest.headers().map().containsKey("X-API-SECRET")); + String expectedSecret = Base64.getEncoder().encodeToString(TEST_API_SECRET.getBytes()); + assertEquals(expectedSecret, capturedRequest.headers().firstValue("X-API-SECRET").orElse(null)); + + assertTrue(capturedRequest.headers().map().containsKey("Content-Type")); + assertEquals("application/json", capturedRequest.headers().firstValue("Content-Type").orElse(null)); + + // Verify that Content-Encoding header is not present when gzip is disabled + assertFalse(capturedRequest.headers().map().containsKey("Content-Encoding")); + } + + @Test + public void testInit() { + // Create a new instance to test init method + WebHookAuditLogSink sink = new WebHookAuditLogSink(); + ReflectionTestUtils.setField(sink, "url", TEST_URL); + ReflectionTestUtils.setField(sink, "apiKey", TEST_API_KEY); + ReflectionTestUtils.setField(sink, "apiSecret", TEST_API_SECRET); + ReflectionTestUtils.setField(sink, "gzipEnabled", false); + + // Call init method + ReflectionTestUtils.invokeMethod(sink, "init"); + + // Verify that client is initialized + HttpClient client = (HttpClient) ReflectionTestUtils.getField(sink, "client"); + assertNotNull(client); + } + + @Test + public void testLogActionWithGzipEnabled() { + // Set gzipEnabled to true + ReflectionTestUtils.setField(webHookAuditLogSink, "gzipEnabled", true); + + // Create a sample AuditLog + AuditLog auditLog = createSampleAuditLog(); + + // Mock HTTP client response + when(httpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(completableFuture); + when(completableFuture.thenAccept(any(Consumer.class))).thenReturn(voidCompletableFuture); + when(voidCompletableFuture.exceptionally(any())).thenReturn(voidCompletableFuture); + + // Call the method under test + webHookAuditLogSink.logAction(auditLog); + + // Capture the HTTP request + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).sendAsync(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + // Verify the request + HttpRequest capturedRequest = requestCaptor.getValue(); + assertEquals(URI.create(TEST_URL), capturedRequest.uri()); + assertEquals("POST", capturedRequest.method()); + + // Verify headers + assertTrue(capturedRequest.headers().map().containsKey("X-API-KEY")); + assertEquals(TEST_API_KEY, capturedRequest.headers().firstValue("X-API-KEY").orElse(null)); + + assertTrue(capturedRequest.headers().map().containsKey("X-API-SECRET")); + String expectedSecret = Base64.getEncoder().encodeToString(TEST_API_SECRET.getBytes()); + assertEquals(expectedSecret, capturedRequest.headers().firstValue("X-API-SECRET").orElse(null)); + + assertTrue(capturedRequest.headers().map().containsKey("Content-Type")); + assertEquals("application/json", capturedRequest.headers().firstValue("Content-Type").orElse(null)); + + // Verify gzip-specific headers + assertTrue(capturedRequest.headers().map().containsKey("Content-Encoding")); + assertEquals("gzip", capturedRequest.headers().firstValue("Content-Encoding").orElse(null)); + } + + @Test + public void testDestroy() { + // Call destroy method + ReflectionTestUtils.invokeMethod(webHookAuditLogSink, "destroy"); + + // Verify that client is set to null + HttpClient client = (HttpClient) ReflectionTestUtils.getField(webHookAuditLogSink, "client"); + assertNull(client); + } + + private AuditLog createSampleAuditLog() { + AuditLog auditLog = new AuditLog(); + auditLog.setId(new AuditLogId(UUID.randomUUID())); + auditLog.setTenantId(new TenantId(UUID.randomUUID())); + auditLog.setCustomerId(new CustomerId(UUID.randomUUID())); + auditLog.setEntityId(new DeviceId(UUID.randomUUID())); + auditLog.setEntityName("Test Device"); + auditLog.setUserId(new UserId(UUID.randomUUID())); + auditLog.setUserName("test@example.com"); + auditLog.setActionType(ActionType.ADDED); + auditLog.setActionStatus(ActionStatus.SUCCESS); + auditLog.setActionData(JacksonUtil.newObjectNode().put("test", "value")); + auditLog.setActionFailureDetails(""); + return auditLog; + } +}