diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6f8ac..ce6a8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Version 0.0.2 - 2025-10-30 + +### Added + +- Support for sending generic audit log events + ## Version 0.0.1 - 2025-07-04 ### Added diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java index 454a9c8..b5ebfb9 100644 --- a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java @@ -68,7 +68,7 @@ public AuditLogNGCommunicator(ServiceBinding binding) { } } - public String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { + String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { logger.debug("Sending bulk request to audit log service"); String bulkRequestJson = serializeBulkRequest(auditLogEvents); HttpPost request = new HttpPost(serviceUrl + AUDITLOG_EVENTS_ENDPOINT); diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java index 1ef1262..d6670f9 100644 --- a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java @@ -3,20 +3,22 @@ */ package com.sap.cds.feature.auditlog.ng; +import static java.util.Objects.*; +import static org.slf4j.LoggerFactory.*; + import java.time.Instant; import java.util.Collection; -import static java.util.Objects.requireNonNull; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import org.slf4j.Logger; -import static org.slf4j.LoggerFactory.getLogger; -import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sap.cds.services.EventContext; import com.sap.cds.services.auditlog.Access; import com.sap.cds.services.auditlog.Attachment; import com.sap.cds.services.auditlog.Attribute; @@ -62,22 +64,66 @@ public class AuditLogNGHandler implements EventHandler { } @On - public void handleSecurityEvent(SecurityLogContext context) { + public void handleGeneralEvent(EventContext context) { try { - ArrayNode alsEvents = createSecurityEvent(context); - communicator.sendBulkRequest(alsEvents); - } catch (JsonParseException e) { - LOGGER.error("Audit Log write exception occurred for security event", e); + if (context instanceof SecurityLogContext || context.getEvent().equals("securityLog")) { + LOGGER.debug("Handling security log event"); + handleSecurityEvent(context.as(SecurityLogContext.class)); + return; + } else if (context instanceof DataAccessLogContext || context.getEvent().equals("dataAccessLog")) { + LOGGER.debug("Handling data access log event"); + handleDataAccessEvent(context.as(DataAccessLogContext.class)); + return; + } else if (context instanceof ConfigChangeLogContext || context.getEvent().equals("configChangeLog")) { + LOGGER.debug("Handling configuration change log event"); + handleConfigChangeEvent(context.as(ConfigChangeLogContext.class)); + return; + } else if (context instanceof DataModificationLogContext || context.getEvent().equals("dataModificationLog")) { + LOGGER.debug("Handling data modification log event"); + handleDataModificationEvent(context.as(DataModificationLogContext.class)); + return; + } else { + ArrayNode alsEvents = createGeneralEvent(context); + communicator.sendBulkRequest(alsEvents); + } + } catch (JsonProcessingException e) { + LOGGER.error("Audit Log write exception occurred", e); throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); } catch (ErrorStatusException e) { - LOGGER.error("Audit Log service not available for security event", e); + LOGGER.error("Audit Log service not available", e); throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); - } catch (Exception e) { - LOGGER.error("Unexpected exception while handling security event", e); + } catch (Exception e) { + LOGGER.error("Unexpected exception", e); throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); } } + private ArrayNode createGeneralEvent(EventContext context) throws JsonProcessingException { + UserInfo userInfo = requireNonNull(context.getUserInfo(), "UserInfo in EventContext must not be null"); + String eventType = requireNonNull((String) context.getEvent(), "EventType in EventContext must not be null"); + Map data = (Map) context.get("data"); + String eventJson = (String) data.get("event"); + + ObjectNode eventEnvelope = buildEventEnvelope(OBJECT_MAPPER, eventType, userInfo); + ObjectNode metadata = buildEventMetadata(); + ObjectNode parsedEventNode = (ObjectNode) OBJECT_MAPPER.readTree(eventJson); + ObjectNode wrappedDataNode = OBJECT_MAPPER.createObjectNode(); + wrappedDataNode.set(eventType, parsedEventNode); + ObjectNode alsData = buildAuditLogEventData(metadata, wrappedDataNode); + eventEnvelope.set("data", alsData); + + ArrayNode result = OBJECT_MAPPER.createArrayNode(); + result.add(eventEnvelope); + + LOGGER.debug("Created general event for Audit Log NG: {}", result.toString()); + return result; + } + + public void handleSecurityEvent(SecurityLogContext context) throws JsonProcessingException { + ArrayNode alsEvents = createSecurityEvent(context); + communicator.sendBulkRequest(alsEvents); + } + /** * Creates an ArrayNode representing security events for the Audit Log service. * @@ -141,21 +187,9 @@ private ObjectNode createLegacySecurityOrigEvent(UserInfo userInfo, SecurityLog return envelop; } - @On - public void handleDataAccessEvent(DataAccessLogContext context) { - try { - ArrayNode alsEvents = createAlsDataAccessEvents(context); - communicator.sendBulkRequest(alsEvents); - } catch (JsonParseException e) { - LOGGER.error("Audit Log write exception occurred for data access event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } catch (ErrorStatusException e) { - LOGGER.error("Audit Log service not available for data access event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); - } catch (Exception e) { - LOGGER.error("Unexpected exception while handling data access event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } + public void handleDataAccessEvent(DataAccessLogContext context) throws JsonProcessingException { + ArrayNode alsEvents = createAlsDataAccessEvents(context); + communicator.sendBulkRequest(alsEvents); } /** @@ -221,21 +255,9 @@ private void addAttributeAccessEvents(UserInfo userInfo, ArrayNode eventArray, A } } - @On - public void handleConfigChangeEvent(ConfigChangeLogContext context) { - try { - ArrayNode alsEvents = createAlsConfigChangeEvents(context); - communicator.sendBulkRequest(alsEvents); - } catch (JsonParseException e) { - LOGGER.error("Audit Log write exception occurred for configuration change event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } catch (ErrorStatusException e) { - LOGGER.error("Audit Log service not available for configuration change event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); - } catch (Exception e) { - LOGGER.error("Unexpected exception while handling configuration change event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } + public void handleConfigChangeEvent(ConfigChangeLogContext context) throws JsonProcessingException { + ArrayNode alsEvents = createAlsConfigChangeEvents(context); + communicator.sendBulkRequest(alsEvents); } /** @@ -280,21 +302,9 @@ private ObjectNode buildConfigChangeEvent(UserInfo userInfo, ConfigChange config return buildAlsEvent("configurationChange", userInfo, metadata, "configurationChange", changeNode); } - @On - public void handleDataModificationEvent(DataModificationLogContext context) { - try { - ArrayNode alsEvents = createAlsDataModificationEvents(context); - communicator.sendBulkRequest(alsEvents); - } catch (JsonParseException e) { - LOGGER.error("Audit Log write exception occurred for data modification event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } catch (ErrorStatusException e) { - LOGGER.error("Audit Log service not available for data modification event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); - } catch (Exception e) { - LOGGER.error("Unexpected exception while handling data modification event", e); - throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); - } + public void handleDataModificationEvent(DataModificationLogContext context) throws JsonProcessingException { + ArrayNode alsEvents = createAlsDataModificationEvents(context); + communicator.sendBulkRequest(alsEvents); } /** @@ -473,9 +483,8 @@ private ObjectNode buildDataAccessNode(Access access, String attribute, String a */ private void addValueDetails(ObjectNode node, ChangedAttribute attribute, String fieldName) { String attributeName = requireNonNull(attribute.getName(), "ChangedAttribute.getName() is null"); - String newValue = requireNonNull(attribute.getNewValue(), "ChangedAttribute.getNewValue() is null"); node.put(fieldName, attributeName); - node.put("newValue", newValue); + node.put("newValue", attribute.getNewValue() != null ? attribute.getNewValue() : "null"); node.put("oldValue", attribute.getOldValue() != null ? attribute.getOldValue() : "null"); } diff --git a/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java b/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java index 6b203d1..59eec0a 100644 --- a/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java +++ b/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java @@ -1,21 +1,22 @@ package com.sap.cds.feature.auditlog.ng; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + import java.io.File; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Assertions; -import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.Mockito; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import com.fasterxml.jackson.databind.JsonNode; @@ -25,6 +26,7 @@ import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; import com.networknt.schema.ValidationMessage; +import com.sap.cds.services.EventContext; import com.sap.cds.services.auditlog.Access; import com.sap.cds.services.auditlog.Attachment; import com.sap.cds.services.auditlog.Attribute; @@ -44,7 +46,6 @@ import com.sap.cds.services.auditlog.SecurityLogContext; import com.sap.cds.services.mt.TenantProviderService; import com.sap.cds.services.request.UserInfo; -import com.sap.cds.services.utils.ErrorStatusException; public class AuditLogNGHandlerTest { @@ -63,7 +64,10 @@ public void setUp() { handler = new AuditLogNGHandler(communicator, tenantService); } - private void runAndAssertEvent(String schemaPath, Runnable handlerMethod) throws Exception { + @FunctionalInterface + private interface ThrowingRunnable { void run() throws Exception; } + + private void runAndAssertEvent(String schemaPath, ThrowingRunnable handlerMethod) throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handlerMethod.run(); verify(communicator).sendBulkRequest(captor.capture()); @@ -78,7 +82,7 @@ public void testHandleSecurityEventSchemaValidation() throws Exception { when(context.getUserInfo()).thenReturn(userInfo); when(context.getData()).thenReturn(securityLog); when(securityLog.getData()).thenReturn("security event data"); - runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); + runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); } @Test @@ -110,7 +114,7 @@ public void testHandleDataAccessEvent_MultiAttrAttach_MultiAccess() throws Excep when(dataAccessLog.getAccesses()).thenReturn(List.of(access1, access2)); when(context.getData()).thenReturn(dataAccessLog); when(context.getUserInfo()).thenReturn(userInfo); - runAndAssertEvent("src/test/resources/dpp-data-access-schema.json", () -> handler.handleDataAccessEvent(context)); + runAndAssertEvent("src/test/resources/dpp-data-access-schema.json", () -> handler.handleDataAccessEvent(context)); } @Test @@ -204,8 +208,7 @@ public void testHandleDataAccessEvent_NullAttributesAndAttachments() throws Exce when(context.getData()).thenReturn(dataAccessLog); when(context.getUserInfo()).thenReturn(userInfo); - ErrorStatusException ex = assertThrows(ErrorStatusException.class, () -> handler.handleDataAccessEvent(context)); - Assertions.assertTrue(ex.getCause() instanceof NullPointerException); + assertThrows(NullPointerException.class, () -> handler.handleDataAccessEvent(context)); } @Test @@ -289,7 +292,40 @@ public void testHandleLegacyWrapperEvent() throws Exception { when(context.getUserInfo()).thenReturn(userInfo); when(context.getData()).thenReturn(securityLog); when(securityLog.getData()).thenReturn("{\"legacy\":true}"); - runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); + runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); + } + + @Test + public void testHandleGeneralEvent_DataExportWrapping() throws Exception { + // Prepare general context + EventContext generalContext = mock(EventContext.class); + when(generalContext.getUserInfo()).thenReturn(userInfo); + when(generalContext.getEvent()).thenReturn("dataExport"); + + // Simulate context.get("data") returning a Map with an 'event' JSON String + String innerJson = "{\"channelType\":\"UNSPECIFIED\",\"channelId\":\"string\",\"objectType\":\"string\",\"objectId\":\"string\",\"destinationUri\":\"string\"}"; + Map outer = new HashMap(); + outer.put("event", innerJson); + when(generalContext.get("data")).thenReturn(outer); + + // Execute + handler.handleGeneralEvent(generalContext); + + // Capture and validate + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode events = captor.getValue(); + Assertions.assertEquals(1, events.size(), "Exactly one general event expected"); + JsonNode event = events.get(0); + // Basic top-level assertions + Assertions.assertEquals("dataExport", event.get("type").asText()); + JsonNode dataNode = event.get("data").get("data"); + Assertions.assertTrue(dataNode.has("dataExport"), "Inner data should be wrapped under 'dataExport'"); + JsonNode wrapped = dataNode.get("dataExport"); + Assertions.assertEquals("UNSPECIFIED", wrapped.get("channelType").asText()); + Assertions.assertEquals("string", wrapped.get("channelId").asText()); + // Schema validation (generic general event schema) + assertJsonMatchesSchema("src/test/resources/general-event-schema.json", events); } private ChangedAttribute mockChangedAttribute(String name, String oldValue, String newValue) { diff --git a/cds-feature-auditlog-ng/src/test/resources/general-event-schema.json b/cds-feature-auditlog-ng/src/test/resources/general-event-schema.json new file mode 100644 index 0000000..7fa3008 --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/resources/general-event-schema.json @@ -0,0 +1,38 @@ +[ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "id", + "specversion", + "source", + "type", + "time", + "data" + ], + "properties": { + "id": {"type": "string"}, + "specversion": {"type": ["string","integer"]}, + "source": {"type": "string"}, + "type": {"type": "string"}, + "time": {"type": "string", "format": "date-time"}, + "data": { + "type": "object", + "required": ["metadata", "data"], + "properties": { + "metadata": { + "type": "object", + "required": ["ts"], + "properties": { + "ts": {"type": "string"} + } + }, + "data": { + "type": "object" + } + } + } + }, + "additionalProperties": false + } +] diff --git a/pom.xml b/pom.xml index ab4b3a5..38f27b6 100644 --- a/pom.xml +++ b/pom.xml @@ -23,11 +23,11 @@ - 0.0.2-SNAPSHOT + 0.0.2 17 ${java.version} UTF-8 - 4.1.1 + 4.4.1 1.5.8 2.7.2 2.8.3