committed by
GitHub
6 changed files with 457 additions and 30 deletions
@ -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(); |
|||
} |
|||
|
|||
} |
|||
@ -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)); |
|||
|
|||
} |
|||
|
|||
|
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue