Browse Source

Merge fd9ecfabc5 into d4329f3b7c

pull/13308/merge
Alexander Keidel 3 days ago
committed by GitHub
parent
commit
ba65e75ae7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      application/src/main/resources/thingsboard.yml
  2. 74
      common/util/src/main/java/org/thingsboard/common/util/AuditLogUtil.java
  3. 32
      dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java
  4. 53
      dao/src/main/java/org/thingsboard/server/dao/audit/sink/StdOutAuditLogSink.java
  5. 120
      dao/src/main/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSink.java
  6. 198
      dao/src/test/java/org/thingsboard/server/dao/audit/sink/WebHookAuditLogSinkTest.java

10
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

74
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();
}
}

32
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

53
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));
}
}

120
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;
}
}

198
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<HttpResponse<String>> completableFuture;
@Mock
private CompletableFuture<Void> 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<HttpRequest> 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<HttpRequest> 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;
}
}
Loading…
Cancel
Save