Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 for general event", 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 for general event", 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 while handling general event", 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<String, Object> data = (Map<String, Object>) 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.
*
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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 {

Expand All @@ -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<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
handlerMethod.run();
verify(communicator).sendBulkRequest(captor.capture());
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String,Object> outer = new HashMap<String,Object>();
outer.put("event", innerJson);
when(generalContext.get("data")).thenReturn(outer);

// Execute
handler.handleGeneralEvent(generalContext);

// Capture and validate
ArgumentCaptor<ArrayNode> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
]
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
</developers>

<properties>
<revision>0.0.2-SNAPSHOT</revision>
<revision>0.0.2</revision>
<java.version>17</java.version>
<maven.compiler.release>${java.version}</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<cds.services.version>4.1.1</cds.services.version>
<cds.services.version>4.4.1</cds.services.version>
<json-schema-validator.version>1.5.8</json-schema-validator.version>
<auditlog-common.version>2.7.2</auditlog-common.version>
<auditlog-client.version>2.8.3</auditlog-client.version>
Expand Down
Loading