From c745f235c77d7426844c139bb48fab032c363837 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Wed, 1 Jul 2026 13:53:34 +0300 Subject: [PATCH 01/10] Update ingestion endpoint to v2. --- .../sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 b5ebfb9..feb22f3 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 @@ -33,7 +33,7 @@ public class AuditLogNGCommunicator { private static final int NUMBER_RETRIES = 3; private static final Duration TIMEOUT_DURATION = Duration.ofMillis(30000); private static final String RESILIENCE_CONFIG_NAME = "auditlog"; - private static final String AUDITLOG_EVENTS_ENDPOINT = "/ingestion/v1/events"; + private static final String AUDITLOG_EVENTS_ENDPOINT = "/ingestion/v2/events"; private final ResilienceConfiguration resilienceConfig; private final String serviceUrl; @@ -71,6 +71,7 @@ public AuditLogNGCommunicator(ServiceBinding binding) { String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { logger.debug("Sending bulk request to audit log service"); String bulkRequestJson = serializeBulkRequest(auditLogEvents); + System.out.println("========================= The auditlogs which are sent: " + bulkRequestJson); HttpPost request = new HttpPost(serviceUrl + AUDITLOG_EVENTS_ENDPOINT); request.setEntity(new StringEntity(bulkRequestJson, ContentType.APPLICATION_JSON)); try { From 174d18afcf8f402c642fb6c7f43a5fec6cb13a6b Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Thu, 23 Jul 2026 13:52:25 +0300 Subject: [PATCH 02/10] Adapted ALS events format to v2 schema and added userInitiatorContext in the common section of the event. --- .../auditlog/ng/AuditLogNGCommunicator.java | 48 ++- .../auditlog/ng/AuditLogNGHandler.java | 278 ++++++++++-------- 2 files changed, 182 insertions(+), 144 deletions(-) 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 feb22f3..6595e50 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 @@ -3,9 +3,14 @@ */ package com.sap.cds.feature.auditlog.ng; -import java.io.IOException; -import java.time.Duration; - +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.services.utils.CdsErrorStatuses; +import com.sap.cds.services.utils.ErrorStatusException; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceDecorator; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceIsolationMode; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpPost; @@ -16,14 +21,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.services.utils.CdsErrorStatuses; -import com.sap.cds.services.utils.ErrorStatusException; -import com.sap.cloud.environment.servicebinding.api.ServiceBinding; -import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration; -import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceDecorator; -import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceIsolationMode; +import java.io.IOException; +import java.time.Duration; public class AuditLogNGCommunicator { @@ -33,7 +32,8 @@ public class AuditLogNGCommunicator { private static final int NUMBER_RETRIES = 3; private static final Duration TIMEOUT_DURATION = Duration.ofMillis(30000); private static final String RESILIENCE_CONFIG_NAME = "auditlog"; - private static final String AUDITLOG_EVENTS_ENDPOINT = "/ingestion/v2/events"; + private static final String AUDITLOG_V1_INGESTION_ENDPOINT = "/ingestion/v1/events"; + private static final String AUDITLOG_V2_INGESTION_ENDPOINT = "/ingestion/v2/events"; private final ResilienceConfiguration resilienceConfig; private final String serviceUrl; @@ -68,12 +68,11 @@ public AuditLogNGCommunicator(ServiceBinding binding) { } } - String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { + String sendBulkRequest(Object auditLogEvents, final boolean isLegacyEvent) throws JsonProcessingException { logger.debug("Sending bulk request to audit log service"); String bulkRequestJson = serializeBulkRequest(auditLogEvents); - System.out.println("========================= The auditlogs which are sent: " + bulkRequestJson); - HttpPost request = new HttpPost(serviceUrl + AUDITLOG_EVENTS_ENDPOINT); - request.setEntity(new StringEntity(bulkRequestJson, ContentType.APPLICATION_JSON)); + HttpPost request; + request = prepareRequest(isLegacyEvent, bulkRequestJson); try { return ResilienceDecorator.executeCallable(() -> executeBulkRequest(request), resilienceConfig); } catch (ErrorStatusException ese) { @@ -88,6 +87,23 @@ String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { } } + /** + * Prepares the HTTP POST request for the given audit log events payload. + * Legacy events are sent to the v1 ingestion endpoint with {@code application/json} content type. + * Non-legacy events are sent to the v2 ingestion endpoint with {@code application/cloudevents-batch+json} content type. + * + * @param isLegacyEvent whether the event uses the legacy v1 format + * @param bulkRequestJson the serialized JSON payload to send + * @return a configured {@link HttpPost} request ready to execute + */ + private HttpPost prepareRequest(boolean isLegacyEvent, String bulkRequestJson) { + String endpoint = isLegacyEvent ? AUDITLOG_V1_INGESTION_ENDPOINT : AUDITLOG_V2_INGESTION_ENDPOINT; + ContentType contentType = isLegacyEvent ? ContentType.APPLICATION_JSON : ContentType.create("application/cloudevents-batch+json", "UTF-8"); + HttpPost request = new HttpPost(serviceUrl + endpoint); + request.setEntity(new StringEntity(bulkRequestJson, contentType)); + return request; + } + /** * Serializes the audit log events object to JSON. */ 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 531c7dc..8f12696 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,17 +3,6 @@ */ 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 java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.slf4j.Logger; - import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -45,6 +34,16 @@ import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.utils.CdsErrorStatuses; import com.sap.cds.services.utils.ErrorStatusException; +import org.slf4j.Logger; + +import java.time.Instant; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static java.util.Objects.requireNonNull; +import static org.slf4j.LoggerFactory.getLogger; /** * Handler that reacts on audit log events to log audit messages with the auditlog NG API. @@ -61,6 +60,16 @@ public class AuditLogNGHandler implements EventHandler { private static final Logger LOGGER = getLogger(AuditLogNGHandler.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String LEGACY_SECURITY_WRAPPER = "legacySecurityWrapper"; + private static final String DATA_SCHEMA_VERSION = "audit-logging-v2.1.6"; + private static final String DATA_SCHEMA = String.format("https://github.tools.sap/wg-observability/telemetry-semantic-conventions/blob/%s/payload-specifications/audit-logging/proto/sap/auditlog/auditevent/v2/auditevent.proto#/", DATA_SCHEMA_VERSION); + private static final String ATTRIBUTE_SAP_SUPPORT_USER = "sap_support_user"; + private static final String USER_TYPE_SAP_SUPPORT_USER = "USER_TYPE_SAP_SUPPORT_USER"; + private static final String EVENT_TYPE_CONFIGURATION_CHANGE = "ConfigurationChange"; + private static final String EVENT_TYPE_DPP_DATA_ACCESS = "DppDataAccess"; + private static final String EVENT_TYPE_DPP_DATA_MODIFICATION = "DppDataModification"; + private static final String CHANNEL_NOT_SPECIFIED = "not specified"; + private static final String ANONYMOUS_USER = "anonymous"; + private static final String NULL_VALUE = "null"; private final AuditLogNGCommunicator communicator; private final TenantProviderService tenantService; @@ -91,7 +100,7 @@ public void handleGeneralEvent(EventContext context) { return; } else { ArrayNode alsEvents = createGeneralEvent(context); - communicator.sendBulkRequest(alsEvents); + communicator.sendBulkRequest(alsEvents, false); } } catch (JsonProcessingException e) { LOGGER.error("Audit Log write exception occurred", e); @@ -110,26 +119,38 @@ private ArrayNode createGeneralEvent(EventContext context) throws JsonProcessing 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 eventNode = (ObjectNode) OBJECT_MAPPER.readTree(eventJson); // General events don't support payload-based namespace override - ObjectNode eventEnvelope = buildEventEnvelope(OBJECT_MAPPER, eventType, userInfo, null); - ObjectNode metadata = buildEventMetadata(userInfo); - 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); + ObjectNode event = createGeneralEventNode(eventType, userInfo, eventNode); ArrayNode result = OBJECT_MAPPER.createArrayNode(); - result.add(eventEnvelope); + result.add(event); LOGGER.debug("Created general event for Audit Log NG: {}", result.toString()); return result; } + private ObjectNode createGeneralEventNode(String eventType, UserInfo userInfo, ObjectNode eventNode) { + String formattedEventType = formatEventTypeToV2(eventType); + ObjectNode eventEnvelope = buildEventEnvelope(formattedEventType, userInfo, null); + ObjectNode common = buildEventCommonSection(userInfo); + ObjectNode dataNode = createGeneralEventDataNode(formattedEventType, common, eventNode); + eventEnvelope.set("data", dataNode); + return eventEnvelope; + } + + private ObjectNode createGeneralEventDataNode(String eventType, ObjectNode common, ObjectNode eventDataNode) { + ObjectNode dataNode = OBJECT_MAPPER.createObjectNode(); + dataNode.put("type", eventType); + dataNode.set("common", common); + dataNode.setAll(eventDataNode); + return dataNode; + } + public void handleSecurityEvent(SecurityLogContext context) throws JsonProcessingException { ArrayNode alsEvents = createSecurityEvent(context); - communicator.sendBulkRequest(alsEvents); + communicator.sendBulkRequest(alsEvents, true); } /** @@ -152,8 +173,8 @@ public void handleSecurityEvent(SecurityLogContext context) throws JsonProcessin private ArrayNode createSecurityEvent(SecurityLogContext context) { SecurityLog data = requireNonNull(context.getData(), "SecurityLogContext.getData() is null"); UserInfo userInfo = requireNonNull(context.getUserInfo(), "SecurityLogContext.getUserInfo() is null"); - ObjectNode alsEvent = buildEventEnvelope(OBJECT_MAPPER, LEGACY_SECURITY_WRAPPER, userInfo, data); - ObjectNode metadata = buildEventMetadata(userInfo); + ObjectNode alsEvent = buildLegacySecurityEventEnvelope(LEGACY_SECURITY_WRAPPER, userInfo, data); + ObjectNode metadata = buildLegacySecurityEventMetadata(userInfo); ObjectNode origEvent = createLegacySecurityOrigEvent(userInfo, data); ObjectNode legacySecurityWrapper = OBJECT_MAPPER.createObjectNode(); try { @@ -164,11 +185,40 @@ private ArrayNode createSecurityEvent(SecurityLogContext context) { } ObjectNode dataNode = OBJECT_MAPPER.createObjectNode(); dataNode.set(LEGACY_SECURITY_WRAPPER, legacySecurityWrapper); - ObjectNode alsData = buildAuditLogEventData(metadata, dataNode); + ObjectNode alsData = buildAuditLogEventDataV1(metadata, dataNode); alsEvent.set("data", alsData); return OBJECT_MAPPER.createArrayNode().add(alsEvent); } + private ObjectNode buildLegacySecurityEventEnvelope(String type, UserInfo userInfo, CdsData payload) { + ObjectNode alsEvent = OBJECT_MAPPER.createObjectNode(); + alsEvent.put("id", UUID.randomUUID().toString()); + alsEvent.put("specversion", "1.0"); + String namespace = resolveNamespace(userInfo, payload); + alsEvent.put("source", String.format("/%s/%s/%s", communicator.getRegion(), namespace, resolveTenant(userInfo))); + alsEvent.put("type", type); + alsEvent.put("time", Instant.now().toString()); + return alsEvent; + } + + private ObjectNode buildLegacySecurityEventMetadata(UserInfo userInfo) { + ObjectNode metadata = OBJECT_MAPPER.createObjectNode(); + metadata.put("ts", Instant.now().toString()); + metadata.put("userInitiatorId", userInfo.getName() != null ? userInfo.getName() : ANONYMOUS_USER); + ObjectNode infraOther = metadata.putObject("infrastructure").putObject("other"); + infraOther.put("runtimeType", "Java"); + ObjectNode platformOther = metadata.putObject("platform").putObject("other"); + platformOther.put("platformName", "CAP"); + return metadata; + } + + private ObjectNode buildAuditLogEventDataV1(ObjectNode metadata, ObjectNode dataNode) { + ObjectNode alsData = OBJECT_MAPPER.createObjectNode(); + alsData.set("metadata", metadata); + alsData.set("data", dataNode); + return alsData; + } + /** * Creates a legacy security origin event as an ObjectNode containing audit log information. * @@ -191,13 +241,13 @@ private ObjectNode createLegacySecurityOrigEvent(UserInfo userInfo, SecurityLog setFieldIfNotNull(envelop, "user", userInfo.getName() != null ? userInfo.getName() : "unknown"); setFieldIfNotNull(envelop, "identityProvider", "$IDP"); setFieldIfNotNull(envelop, "time", Instant.now().toString()); - setFieldIfNotNull(envelop, "data", formattedData != null ? formattedData : ""); + setFieldIfNotNull(envelop, "data", formattedData); return envelop; } public void handleDataAccessEvent(DataAccessLogContext context) throws JsonProcessingException { ArrayNode alsEvents = createAlsDataAccessEvents(context); - communicator.sendBulkRequest(alsEvents); + communicator.sendBulkRequest(alsEvents, false); } /** @@ -267,7 +317,7 @@ private void addAttributeAccessEvents(UserInfo userInfo, ArrayNode eventArray, A public void handleConfigChangeEvent(ConfigChangeLogContext context) throws JsonProcessingException { ArrayNode alsEvents = createAlsConfigChangeEvents(context); - communicator.sendBulkRequest(alsEvents); + communicator.sendBulkRequest(alsEvents, false); } /** @@ -305,17 +355,24 @@ private ArrayNode createAlsConfigChangeEvents(ConfigChangeLogContext context) { * @return an ObjectNode representing the audit log event for the configuration change */ private ObjectNode buildConfigChangeEvent(UserInfo userInfo, ConfigChange configChanges, ChangedAttribute attribute, CdsData payload) { - ObjectNode metadata = buildEventMetadata(userInfo); - ObjectNode changeNode = OBJECT_MAPPER.createObjectNode(); - addValueDetails(changeNode, attribute, "propertyName"); - var dataObject = requireNonNull(configChanges.getDataObject(), "ConfigChange.getDataObject() is null"); - addObjectDetails(changeNode, dataObject); - return buildAlsEvent("configurationChange", userInfo, metadata, "configurationChange", changeNode, payload); + DataObject dataObject = requireNonNull(configChanges.getDataObject(), "ConfigChange.getDataObject() is null"); + ObjectNode common = buildEventCommonSection(userInfo); + ObjectNode dataNode = createConfigurationChangeDataNode(attribute, dataObject, common); + return buildAlsEvent(EVENT_TYPE_CONFIGURATION_CHANGE, userInfo, dataNode, payload); + } + + private ObjectNode createConfigurationChangeDataNode(ChangedAttribute attribute, DataObject dataObject, ObjectNode common) { + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + node.put("type", EVENT_TYPE_CONFIGURATION_CHANGE); + node.set("common", common); + addValueDetails(node, attribute, "propertyName"); + addObjectDetails(node, dataObject); + return node; } public void handleDataModificationEvent(DataModificationLogContext context) throws JsonProcessingException { ArrayNode alsEvents = createAlsDataModificationEvents(context); - communicator.sendBulkRequest(alsEvents); + communicator.sendBulkRequest(alsEvents, false); } /** @@ -370,32 +427,15 @@ private ArrayNode buildAttributeBasedAlsEvents(UserInfo userInfo, Collection objectIds = requireNonNull(dataObject.getId(), "Access.getDataObject().getId() is null"); - String formatedObjectIds = formatAlpabeticallyIds(objectIds); + String formatedObjectIds = formatAlphabeticallyIds(objectIds); node.put("objectId", formatedObjectIds); - node.put("objectType", dataObject.getType() != null ? dataObject.getType() : "null"); + node.put("objectType", dataObject.getType() != null ? dataObject.getType() : NULL_VALUE); } /** @@ -563,13 +598,13 @@ private void addObjectDetails(ObjectNode node, DataObject dataObject) { */ private void addDataSubjectDetails(ObjectNode node, DataSubject dataSubject) { if (dataSubject == null) { - node.put("dataSubjectType", "null"); - node.put("dataSubjectId", "null"); + node.put("dataSubjectType", NULL_VALUE); + node.put("dataSubjectId", NULL_VALUE); } else { Collection dataSubjectIds = requireNonNull(dataSubject.getId(), "Access.getDataSubject().getId() is null"); - String formatedDataSubjectIds = formatAlpabeticallyIds(dataSubjectIds); + String formatedDataSubjectIds = formatAlphabeticallyIds(dataSubjectIds); node.put("dataSubjectId", formatedDataSubjectIds); - node.put("dataSubjectType", dataSubject.getType() != null ? dataSubject.getType() : "null"); + node.put("dataSubjectType", dataSubject.getType() != null ? dataSubject.getType() : NULL_VALUE); } } @@ -578,18 +613,13 @@ private void addDataSubjectDetails(ObjectNode node, DataSubject dataSubject) { * * @param eventType the type of the event * @param userInfo the user information containing tenant and user details - * @param metadata the metadata node containing timestamp and other event-specific details - * @param dataKey the key representing the type of data in the event, e.g., "dppDataAccess" * @param dataValue the value node containing the event-specific data * @param payload the event payload that may contain a custom namespace, can be null * @return an ObjectNode representing the ALS event */ - private ObjectNode buildAlsEvent(String eventType, UserInfo userInfo, ObjectNode metadata, String dataKey, ObjectNode dataValue, CdsData payload) { - ObjectNode alsEvent = buildEventEnvelope(OBJECT_MAPPER, eventType, userInfo, payload); - ObjectNode dataNode = OBJECT_MAPPER.createObjectNode(); - dataNode.set(dataKey, dataValue); - ObjectNode alsData = buildAuditLogEventData(metadata, dataNode); - alsEvent.set("data", alsData); + private ObjectNode buildAlsEvent(String eventType, UserInfo userInfo, ObjectNode dataValue, CdsData payload) { + ObjectNode alsEvent = buildEventEnvelope(eventType, userInfo, payload); + alsEvent.set("data", dataValue); return alsEvent; } @@ -613,20 +643,6 @@ private void setFieldIfNotNull(ObjectNode node, String field, Object value) { } } - /** - * Builds an audit log event data object by combining the provided metadata and data nodes. - * - * @param metadata the metadata to include in the audit log event - * @param dataNode the data node containing the event-specific data - * @return an ObjectNode representing the combined audit log event data with "metadata" and "data" fields - */ - private ObjectNode buildAuditLogEventData(ObjectNode metadata, ObjectNode dataNode) { - ObjectNode alsData = OBJECT_MAPPER.createObjectNode(); - alsData.set("metadata", metadata); - alsData.set("data", dataNode); - return alsData; - } - /** * Helper method to build a readable objectId string with alphabetically ordered keys. * The returned string is a space-separated list of key-value pairs in the format "key:value". Example: "id:123 name:John". @@ -634,8 +650,14 @@ private ObjectNode buildAuditLogEventData(ObjectNode metadata, ObjectNode dataNo * @param ids the collection of key-value pairs representing object IDs * @return a formatted string of object IDs */ - private String formatAlpabeticallyIds(Collection ids) { + private String formatAlphabeticallyIds(Collection ids) { return ids.stream().sorted((a, b) -> a.getKeyName().compareToIgnoreCase(b.getKeyName())).map(kv -> kv.getKeyName() + ":" + kv.getValue()).collect(Collectors.joining(" ")); } + private String formatEventTypeToV2(String eventType) { + if (eventType.toLowerCase().startsWith("cmk")) { + return eventType.substring(0, 3).toUpperCase() + eventType.substring(3); + } + return eventType.substring(0, 1).toUpperCase() + eventType.substring(1); + } } From 138abb897466b4a45f2e654eccd58cb9331445f5 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Thu, 23 Jul 2026 13:55:11 +0300 Subject: [PATCH 03/10] Bump cds services version in order to be able to access sap support user functionality from userInfo. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 25799d1..d020ffb 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ ${java.version} UTF-8 2.0.0 - 4.6.1 + 5.0.0 2.7.2 2.8.3 1.61.0 From b23a73fab8b5133a71fad1f297fd0bfa79f59c6f Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Thu, 23 Jul 2026 13:58:58 +0300 Subject: [PATCH 04/10] Adapt unit tests and schemas, added a few more tests. --- .../auditlog/ng/AuditLogNGHandlerTest.java | 169 ++++++++++----- .../configuration-change-schema.json | 188 ++++++++++------- .../resources/dpp-data-access-schema.json | 190 +++++++++++------ .../dpp-data-modification-schema.json | 196 +++++++++++------- .../test/resources/general-event-schema.json | 187 ++++++++++++----- 5 files changed, 622 insertions(+), 308 deletions(-) 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 473065e..cce3bee 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 @@ -23,10 +23,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.networknt.schema.Error; import com.networknt.schema.Schema; import com.networknt.schema.SchemaRegistry; import com.networknt.schema.SpecificationVersion; -import com.networknt.schema.Error; import com.sap.cds.services.EventContext; import com.sap.cds.services.auditlog.Access; import com.sap.cds.services.auditlog.Attachment; @@ -59,10 +59,16 @@ public class AuditLogNGHandlerTest { private AuditLogNGHandler handler; + private static final SchemaRegistry SCHEMA_REGISTRY = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Map SCHEMA_CACHE = new HashMap<>(); + @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); + when(tenantService.readProviderTenant()).thenReturn("provider-tenant"); handler = new AuditLogNGHandler(communicator, tenantService); + clearInvocations(communicator); } @FunctionalInterface @@ -75,7 +81,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", true, () -> handler.handleSecurityEvent(context)); } @Test @@ -107,7 +113,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", false, () -> handler.handleDataAccessEvent(context)); } @Test @@ -126,7 +132,7 @@ public void testHandleConfigChangeEvent_MultiConfig() throws Exception { when(configChangeLog.getConfigurations()).thenReturn(List.of(config1, config2)); when(context.getData()).thenReturn(configChangeLog); when(context.getUserInfo()).thenReturn(userInfo); - runAndAssertEvent("src/test/resources/configuration-change-schema.json", () -> handler.handleConfigChangeEvent(context)); + runAndAssertEvent("src/test/resources/configuration-change-schema.json", false, () -> handler.handleConfigChangeEvent(context)); } @Test @@ -146,7 +152,7 @@ public void testHandleDataModificationEvent_MultiModification() throws Exception when(dataModificationLog.getModifications()).thenReturn(List.of(modification1, modification2)); when(context.getData()).thenReturn(dataModificationLog); when(context.getUserInfo()).thenReturn(userInfo); - runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json", () -> handler.handleDataModificationEvent(context)); + runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json", false, () -> handler.handleDataModificationEvent(context)); } @Test @@ -168,10 +174,10 @@ public void testObjectIdAndSubjectIdAreAlphabeticallyOrdered() throws Exception // Capture the event JSON ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleDataModificationEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); JsonNode events = captor.getValue(); JsonNode event = events.get(0); - JsonNode dppNode = event.get("data").get("data").get("dppDataModification"); + JsonNode dppNode = event.get("data"); String objectId = dppNode.get("objectId").asText(); String dataSubjectId = dppNode.get("dataSubjectId").asText(); assertEquals("aKey:aValue mKey:mValue zKey:zValue", objectId, "objectId should be alphabetically ordered by key"); @@ -201,7 +207,6 @@ public void testHandleConfigChangeEvent_NullAttributes() throws Exception { when(configChangeLog.getConfigurations()).thenReturn(List.of(config)); when(context.getData()).thenReturn(configChangeLog); when(context.getUserInfo()).thenReturn(userInfo); - NullPointerException npe = assertThrows(NullPointerException.class, () -> handler.handleConfigChangeEvent(context)); assertEquals("ConfigChange.getAttributes() is null", npe.getMessage()); } @@ -221,7 +226,7 @@ public void testHandleDataModificationEvent_LargeBulk() throws Exception { when(dataModificationLog.getModifications()).thenReturn(mods); when(context.getData()).thenReturn(dataModificationLog); when(context.getUserInfo()).thenReturn(userInfo); - ArrayNode actualEvents = runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json", + ArrayNode actualEvents = runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json", false, () -> handler.handleDataModificationEvent(context)); Assertions.assertEquals(100, actualEvents.size(), "Should produce 100 events"); } @@ -235,14 +240,9 @@ public void testHandleDataModificationEvent_CommunicatorThrows() throws Exceptio when(context.getData()).thenReturn(dataModificationLog); when(context.getUserInfo()).thenReturn(userInfo); // Simulate communicator throwing - Mockito.doThrow(new RuntimeException("Simulated failure")).when(communicator).sendBulkRequest(ArgumentMatchers.any()); - boolean failed = false; - try { - handler.handleDataModificationEvent(context); - } catch (RuntimeException e) { - failed = true; - } - Assertions.assertTrue(failed, "Handler should propagate communicator exception"); + Mockito.doThrow(new RuntimeException("Simulated failure")).when(communicator).sendBulkRequest(ArgumentMatchers.any(), ArgumentMatchers.anyBoolean()); + assertThrows(RuntimeException.class, () -> handler.handleDataModificationEvent(context), + "Handler should propagate communicator exception"); } @Test @@ -255,7 +255,7 @@ public void testHandleUserInfoWithNullFields() throws Exception { when(context.getUserInfo()).thenReturn(userInfoNull); when(context.getData()).thenReturn(securityLog); when(securityLog.getData()).thenReturn("security event data"); - runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", + runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", true, () -> handler.handleSecurityEvent(context)); } @@ -266,7 +266,7 @@ 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", true, () -> handler.handleSecurityEvent(context)); } @Test @@ -283,17 +283,10 @@ public void testHandleGeneralEvent_DataExportWrapping() throws Exception { when(generalContext.get("data")).thenReturn(outer); // Execute and schema-validate, capturing events - ArrayNode events = runAndAssertEvent("src/test/resources/general-event-schema.json", + ArrayNode events = runAndAssertEvent("src/test/resources/general-event-schema.json", false, () -> handler.handleGeneralEvent(generalContext)); 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()); + Assertions.assertTrue(events.get(0).get("type").asText().contains("DataExport"), "type should contain DataExport"); } // --- Tests for Payload-based Namespace --- @@ -313,7 +306,7 @@ public void testSecurityLog_UsesNamespaceFromPayload() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleSecurityEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); // Then: source should use namespace from payload JsonNode event = captor.getValue().get(0); @@ -346,7 +339,7 @@ public void testDataAccessLog_UsesNamespaceFromPayload() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleDataAccessEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); // Then: source should use namespace from payload JsonNode event = captor.getValue().get(0); @@ -375,7 +368,7 @@ public void testConfigChangeLog_UsesNamespaceFromPayload() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleConfigChangeEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); // Then: source should use namespace from payload JsonNode event = captor.getValue().get(0); @@ -405,7 +398,7 @@ public void testDataModificationLog_UsesNamespaceFromPayload() throws Exception ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleDataModificationEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); // Then: source should use namespace from payload JsonNode event = captor.getValue().get(0); @@ -429,7 +422,7 @@ public void testPayloadNamespace_TakesPrecedence_OverBindingNamespace() throws E ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleSecurityEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); // Then: payload namespace takes precedence over binding JsonNode event = captor.getValue().get(0); @@ -455,7 +448,7 @@ public void testPayloadNamespace_TrimsWhitespace() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleSecurityEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); // Then: namespace should be trimmed JsonNode event = captor.getValue().get(0); @@ -481,7 +474,7 @@ public void testPayloadNamespace_FallsBackToBinding_WhenPayloadEmpty() throws Ex ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleSecurityEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); // Then: should fall back to binding namespace JsonNode event = captor.getValue().get(0); @@ -505,7 +498,7 @@ public void testPayloadNamespace_FallsBackToBinding_WhenPayloadNull() throws Exc ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handler.handleSecurityEvent(context); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); // Then: should fall back to binding namespace JsonNode event = captor.getValue().get(0); @@ -571,32 +564,112 @@ private ConfigChange mockConfigChange(List attrs, DataObject o return cc; } - private ArrayNode runAndAssertEvent(String schemaPath, ThrowingRunnable handlerMethod) throws Exception { + @Test + public void testSapSupportUser_SetsUserInitiatorContext() throws Exception { + when(userInfo.getAdditionalAttribute("sap_support_user")).thenReturn(true); + + KeyValuePair id = mockKeyValuePair("userId", "user-1"); + DataObject dataObject = mockDataObject("User", List.of(id)); + DataSubject dataSubject = mockDataSubject("Person", List.of(id)); + Attribute attr = mockAttribute("email"); + Access access = mock(Access.class); + when(access.getDataObject()).thenReturn(dataObject); + when(access.getDataSubject()).thenReturn(dataSubject); + when(access.getAttributes()).thenReturn(List.of(attr)); + DataAccessLog dataAccessLog = mock(DataAccessLog.class); + when(dataAccessLog.getAccesses()).thenReturn(List.of(access)); + DataAccessLogContext context = mock(DataAccessLogContext.class); + when(context.getData()).thenReturn(dataAccessLog); + when(context.getUserInfo()).thenReturn(userInfo); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleDataAccessEvent(context); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); + + JsonNode common = captor.getValue().get(0).get("data").get("common"); + assertTrue(common.has("userInitiatorContext"), "userInitiatorContext should be set for SAP support user"); + assertEquals("USER_TYPE_SAP_SUPPORT_USER", common.get("userInitiatorContext").get("type").asText()); + } + + @Test + public void testNonSapSupportUser_DoesNotSetUserInitiatorContext() throws Exception { + when(userInfo.getAdditionalAttribute("sap_support_user")).thenReturn(null); + + KeyValuePair id = mockKeyValuePair("userId", "user-1"); + DataObject dataObject = mockDataObject("User", List.of(id)); + DataSubject dataSubject = mockDataSubject("Person", List.of(id)); + Attribute attr = mockAttribute("email"); + Access access = mock(Access.class); + when(access.getDataObject()).thenReturn(dataObject); + when(access.getDataSubject()).thenReturn(dataSubject); + when(access.getAttributes()).thenReturn(List.of(attr)); + DataAccessLog dataAccessLog = mock(DataAccessLog.class); + when(dataAccessLog.getAccesses()).thenReturn(List.of(access)); + DataAccessLogContext context = mock(DataAccessLogContext.class); + when(context.getData()).thenReturn(dataAccessLog); + when(context.getUserInfo()).thenReturn(userInfo); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleDataAccessEvent(context); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); + + JsonNode common = captor.getValue().get(0).get("data").get("common"); + assertFalse(common.has("userInitiatorContext"), "userInitiatorContext should not be set for non-SAP support user"); + } + + @Test + public void testResolveTenant_FallsBackToProviderTenant_WhenUserTenantNull() throws Exception { + when(userInfo.getTenant()).thenReturn(null); + + KeyValuePair id = mockKeyValuePair("userId", "user-1"); + DataObject dataObject = mockDataObject("User", List.of(id)); + DataSubject dataSubject = mockDataSubject("Person", List.of(id)); + Attribute attr = mockAttribute("email"); + Access access = mock(Access.class); + when(access.getDataObject()).thenReturn(dataObject); + when(access.getDataSubject()).thenReturn(dataSubject); + when(access.getAttributes()).thenReturn(List.of(attr)); + DataAccessLog dataAccessLog = mock(DataAccessLog.class); + when(dataAccessLog.getAccesses()).thenReturn(List.of(access)); + DataAccessLogContext context = mock(DataAccessLogContext.class); + when(context.getData()).thenReturn(dataAccessLog); + when(context.getUserInfo()).thenReturn(userInfo); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleDataAccessEvent(context); + verify(communicator).sendBulkRequest(captor.capture(), eq(false)); + + JsonNode common = captor.getValue().get(0).get("data").get("common"); + assertEquals("provider-tenant", common.get("tenantId").asText(), + "tenantId should fall back to provider tenant when userInfo.getTenant() is null"); + } + + private ArrayNode runAndAssertEvent(String schemaPath, boolean isLegacy, ThrowingRunnable handlerMethod) throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); handlerMethod.run(); - verify(communicator).sendBulkRequest(captor.capture()); + verify(communicator).sendBulkRequest(captor.capture(), eq(isLegacy)); ArrayNode actualEvents = captor.getValue(); assertJsonMatchesSchema(schemaPath, actualEvents); return actualEvents; } private void assertJsonMatchesSchema(String schemaPath, JsonNode dataNode) throws Exception { - Schema schema = buildSchema(schemaPath); + Schema schema = SCHEMA_CACHE.computeIfAbsent(schemaPath, path -> { + try { + JsonNode schemaContent = OBJECT_MAPPER.readTree(new File(path)); + return SCHEMA_REGISTRY.getSchema(schemaContent); + } catch (Exception e) { + throw new RuntimeException("Failed to load schema: " + path, e); + } + }); List errors = new ArrayList<>(); if (dataNode.isArray()) { for (JsonNode item : dataNode) { - errors.addAll(schema.validate(item, ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true)))); + errors.addAll(schema.validate(item)); } } else { - errors.addAll(schema.validate(dataNode, ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true)))); + errors.addAll(schema.validate(dataNode)); } assertEquals(0, errors.size(), "Schema validation errors: " + errors); } - - private Schema buildSchema(String schemaPath) throws Exception { - SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode schemaContent = objectMapper.readTree(new File(schemaPath)); - return schemaRegistry.getSchema(schemaContent); - } } diff --git a/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json b/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json index 7e5d995..484c669 100644 --- a/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json +++ b/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json @@ -1,72 +1,42 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { - "$ref": "#/definitions/EventEnvelope" + "$ref": "#/$defs/EventEnvelope" }, { - "$ref": "#/definitions/ConfigurationChange" + "$ref": "#/$defs/ConfigurationChange" + }, + { + "$ref": "#/$defs/_Common" } ], - "definitions": { + "$defs": { "ConfigurationChange": { "required": [ - "newValue", - "oldValue", + "common", "propertyName", - "objectType" + "type" ], "properties": { - "newValue": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "object" - }, - { - "type": "string" - } - ], - "title": "Value", - "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." - }, - "oldValue": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "object" - }, - { - "type": "string" - } - ], - "title": "Value", - "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + "common": { + "$ref": "#/$defs/_Common" }, - "propertyName": { + "newValue": {}, + "objectId": { "type": "string" }, "objectType": { + "maxLength": 20, "type": "string" }, - "objectId": { + "oldValue": {}, + "propertyName": { + "minLength": 1, "type": "string" + }, + "type": { + "const": "ConfigurationChange" } }, "additionalProperties": false, @@ -74,6 +44,96 @@ "title": "Configuration Change", "description": "ConfigurationChange states that Configuration has been modified." }, + "_UserContext": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Additional custom attributes", + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "type": { + "description": "UserType", + "enum": [ + "USER_TYPE_BUSINESS_USER", + "USER_TYPE_TECHNICAL_USER", + "USER_TYPE_SAP_SUPPORT_USER" + ], + "title": "User Type", + "type": "string" + } + }, + "title": "User Context", + "type": "object" + }, + "_Common": { + "additionalProperties": false, + "description": "\n\n| Annotation | Value |\n| --- | --- |\n", + "properties": { + "appContext": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "appId": { + "type": "string" + }, + "sourceIp": { + "description": "IP Address of the source in IPv4 or IPv6", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenantId": { + "description": "tenant ID of the source", + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "userGlobalId": { + "description": "contains the global user ID, who caused the audit log event", + "type": "string" + }, + "userImpersonatedContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for Impersonated User" + }, + "userImpersonatedGlobalId": { + "description": "contains the global user ID of the impersonated user", + "type": "string" + }, + "userImpersonatedId": { + "description": "contains the local user ID, which is used in case of impersonation", + "type": "string" + }, + "userInitiatorContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for User Initiator" + }, + "userInitiatorId": { + "description": "contains the local user ID, who caused the audit log event", + "type": "string" + }, + "userSessionContextId": { + "type": "string" + } + }, + "required": [], + "title": "Common", + "type": "object" + }, "EventEnvelope": { "type": "object", "properties": { @@ -92,29 +152,11 @@ "time": { "type": "string" }, + "dataschema": { + "type": "string" + }, "data": { - "type": "object", - "properties": { - "metadata": { - "type": "object" - }, - "data": { - "type": "object", - "properties": { - "configurationChange": { - "$ref": "#/definitions/ConfigurationChange" - } - }, - "required": [ - "configurationChange" - ], - "additionalProperties": true - } - }, - "required": [ - "data" - ], - "additionalProperties": true + "$ref": "#/$defs/ConfigurationChange" } }, "required": [ @@ -125,4 +167,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json b/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json index 893380e..5725479 100644 --- a/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json +++ b/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json @@ -1,78 +1,166 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { - "$ref": "#/definitions/EventEnvelope" + "$ref": "#/$defs/EventEnvelope" }, { - "$ref": "#/definitions/DppDataAccess" + "$ref": "#/$defs/DppDataAccess" + }, + { + "$ref": "#/$defs/_Common" } ], - "definitions": { + "$defs": { "DppDataAccess": { "required": [ - "channelType", + "common", + "attribute", "channelId", - "dataSubjectType", + "channelType", "dataSubjectId", - "objectType", + "dataSubjectType", "objectId", - "attribute" + "objectType", + "type" ], "properties": { - "channelType": { + "attachmentId": { + "type": "string" + }, + "attachmentType": { + "type": "string" + }, + "attribute": { + "minLength": 1, "type": "string" }, "channelId": { + "minLength": 1, "type": "string" }, - "dataSubjectType": { + "channelType": { + "minLength": 1, "type": "string" }, + "common": { + "$ref": "#/$defs/_Common" + }, "dataSubjectId": { + "minLength": 1, "type": "string" }, - "objectType": { + "dataSubjectType": { + "minLength": 1, "type": "string" }, "objectId": { + "minLength": 1, "type": "string" }, - "attribute": { + "objectType": { + "minLength": 1, "type": "string" }, - "value": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "object" - }, - { - "type": "string" - } + "value": {}, + "type": { + "const": "DppDataAccess" + } + }, + "additionalProperties": false, + "type": "object", + "title": "Dpp Data Access", + "description": "DppDataAccess states that DPP relevant data has been accessed." + }, + "_UserContext": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Additional custom attributes", + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "type": { + "description": "UserType", + "enum": [ + "USER_TYPE_BUSINESS_USER", + "USER_TYPE_TECHNICAL_USER", + "USER_TYPE_SAP_SUPPORT_USER" ], - "title": "Value", - "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + "title": "User Type", + "type": "string" + } + }, + "title": "User Context", + "type": "object" + }, + "_Common": { + "additionalProperties": false, + "description": "\n\n| Annotation | Value |\n| --- | --- |\n", + "properties": { + "appContext": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" }, - "attachmentType": { + "appId": { "type": "string" }, - "attachmentId": { + "sourceIp": { + "description": "IP Address of the source in IPv4 or IPv6", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenantId": { + "description": "tenant ID of the source", + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "userGlobalId": { + "description": "contains the global user ID, who caused the audit log event", + "type": "string" + }, + "userImpersonatedContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for Impersonated User" + }, + "userImpersonatedGlobalId": { + "description": "contains the global user ID of the impersonated user", + "type": "string" + }, + "userImpersonatedId": { + "description": "contains the local user ID, which is used in case of impersonation", + "type": "string" + }, + "userInitiatorContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for User Initiator" + }, + "userInitiatorId": { + "description": "contains the local user ID, who caused the audit log event", + "type": "string" + }, + "userSessionContextId": { "type": "string" } }, - "additionalProperties": false, - "type": "object", - "title": "Dpp Data Access", - "description": "DppDataAccess states that DPP relevant data has been accessed." + "required": [], + "title": "Common", + "type": "object" }, "EventEnvelope": { "type": "object", @@ -92,29 +180,11 @@ "time": { "type": "string" }, + "dataschema": { + "type": "string" + }, "data": { - "type": "object", - "properties": { - "metadata": { - "type": "object" - }, - "data": { - "type": "object", - "properties": { - "dppDataAccess": { - "$ref": "#/definitions/DppDataAccess" - } - }, - "required": [ - "dppDataAccess" - ], - "additionalProperties": true - } - }, - "required": [ - "data" - ], - "additionalProperties": true + "$ref": "#/$defs/DppDataAccess" } }, "required": [ @@ -125,4 +195,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json b/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json index f91da91..bf93976 100644 --- a/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json +++ b/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json @@ -1,79 +1,55 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { - "$ref": "#/definitions/EventEnvelope" + "$ref": "#/$defs/EventEnvelope" }, { - "$ref": "#/definitions/DppDataModification" + "$ref": "#/$defs/DppDataModification" + }, + { + "$ref": "#/$defs/_Common" } ], - "definitions": { + "$defs": { "DppDataModification": { "required": [ - "dataSubjectType", + "common", + "attribute", "dataSubjectId", - "objectType", + "dataSubjectType", "objectId", - "attribute" + "objectType", + "type" ], "properties": { - "dataSubjectType": { + "attribute": { + "minLength": 1, "type": "string" }, + "common": { + "$ref": "#/$defs/_Common" + }, "dataSubjectId": { + "minLength": 1, "type": "string" }, - "objectType": { + "dataSubjectType": { + "minLength": 1, "type": "string" }, + "newValue": {}, "objectId": { + "minLength": 1, "type": "string" }, - "attribute": { + "objectType": { + "minLength": 1, "type": "string" }, - "newValue": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "object" - }, - { - "type": "string" - } - ], - "title": "Value", - "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." - }, - "oldValue": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "object" - }, - { - "type": "string" - } - ], - "title": "Value", - "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + "oldValue": {}, + "type": { + "const": "DppDataModification" } }, "additionalProperties": false, @@ -81,6 +57,96 @@ "title": "Dpp Data Modification", "description": "DppDataModification states that DPP relevant data has been modified." }, + "_UserContext": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Additional custom attributes", + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "type": { + "description": "UserType", + "enum": [ + "USER_TYPE_BUSINESS_USER", + "USER_TYPE_TECHNICAL_USER", + "USER_TYPE_SAP_SUPPORT_USER" + ], + "title": "User Type", + "type": "string" + } + }, + "title": "User Context", + "type": "object" + }, + "_Common": { + "additionalProperties": false, + "description": "\n\n| Annotation | Value |\n| --- | --- |\n", + "properties": { + "appContext": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "appId": { + "type": "string" + }, + "sourceIp": { + "description": "IP Address of the source in IPv4 or IPv6", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenantId": { + "description": "tenant ID of the source", + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "userGlobalId": { + "description": "contains the global user ID, who caused the audit log event", + "type": "string" + }, + "userImpersonatedContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for Impersonated User" + }, + "userImpersonatedGlobalId": { + "description": "contains the global user ID of the impersonated user", + "type": "string" + }, + "userImpersonatedId": { + "description": "contains the local user ID, which is used in case of impersonation", + "type": "string" + }, + "userInitiatorContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for User Initiator" + }, + "userInitiatorId": { + "description": "contains the local user ID, who caused the audit log event", + "type": "string" + }, + "userSessionContextId": { + "type": "string" + } + }, + "required": [], + "title": "Common", + "type": "object" + }, "EventEnvelope": { "type": "object", "properties": { @@ -99,29 +165,11 @@ "time": { "type": "string" }, + "dataschema": { + "type": "string" + }, "data": { - "type": "object", - "properties": { - "metadata": { - "type": "object" - }, - "data": { - "type": "object", - "properties": { - "dppDataModification": { - "$ref": "#/definitions/DppDataModification" - } - }, - "required": [ - "dppDataModification" - ], - "additionalProperties": true - } - }, - "required": [ - "data" - ], - "additionalProperties": true + "$ref": "#/$defs/DppDataModification" } }, "required": [ @@ -132,4 +180,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} 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 index bf21117..b5f9904 100644 --- a/cds-feature-auditlog-ng/src/test/resources/general-event-schema.json +++ b/cds-feature-auditlog-ng/src/test/resources/general-event-schema.json @@ -1,57 +1,138 @@ { - "$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" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "_UserContext": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Additional custom attributes", + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "type": { + "description": "UserType", + "enum": [ + "USER_TYPE_BUSINESS_USER", + "USER_TYPE_TECHNICAL_USER", + "USER_TYPE_SAP_SUPPORT_USER" + ], + "title": "User Type", + "type": "string" + } + }, + "title": "User Context", + "type": "object" + }, + "_Common": { + "additionalProperties": false, + "description": "\n\n| Annotation | Value |\n| --- | --- |\n", + "properties": { + "appContext": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "appId": { + "type": "string" + }, + "sourceIp": { + "description": "IP Address of the source in IPv4 or IPv6", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenantId": { + "description": "tenant ID of the source", + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "userGlobalId": { + "description": "contains the global user ID, who caused the audit log event", + "type": "string" + }, + "userImpersonatedContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for Impersonated User" + }, + "userImpersonatedGlobalId": { + "description": "contains the global user ID of the impersonated user", + "type": "string" + }, + "userImpersonatedId": { + "description": "contains the local user ID, which is used in case of impersonation", + "type": "string" + }, + "userInitiatorContext": { + "$ref": "#/$defs/_UserContext", + "description": "Extended user context for User Initiator" + }, + "userInitiatorId": { + "description": "contains the local user ID, who caused the audit log event", + "type": "string" + }, + "userSessionContextId": { + "type": "string" + } + }, + "required": [], + "title": "Common", + "type": "object" + }, + "GeneralEventData": { + "type": "object", + "required": ["common", "type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "common": { + "$ref": "#/$defs/_Common" + } } - } }, - "data": { - "type": "object" + "EventEnvelope": { + "type": "object", + "required": ["specversion", "type", "data"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string" + }, + "specversion": { + "type": "string" + }, + "source": { + "type": "string" + }, + "type": { + "type": "string" + }, + "time": { + "type": "string" + }, + "dataschema": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/GeneralEventData" + } + } } - } - } - }, - "additionalProperties": false -} \ No newline at end of file + }, + "$ref": "#/$defs/EventEnvelope" +} From 217bd2ca6f3958f67f4825b53e457294e7e80849 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Mon, 27 Jul 2026 17:24:05 +0300 Subject: [PATCH 05/10] Add null/empty check on formatEventTypeToV2 method. --- .../com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java | 3 +++ 1 file changed, 3 insertions(+) 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 8f12696..592c11c 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 @@ -655,6 +655,9 @@ private String formatAlphabeticallyIds(Collection ids) { } private String formatEventTypeToV2(String eventType) { + if (eventType == null || eventType.isEmpty()) { + return eventType; + } if (eventType.toLowerCase().startsWith("cmk")) { return eventType.substring(0, 3).toUpperCase() + eventType.substring(3); } From 3b2111e7d6040443d89769ae00f9f0ca4ef10871 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Tue, 28 Jul 2026 11:11:27 +0300 Subject: [PATCH 06/10] Set cds-services version to 4.9.1 in order to use Java 17 as minimal required version. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d020ffb..8601678 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ ${java.version} UTF-8 2.0.0 - 5.0.0 + 4.9.1 2.7.2 2.8.3 1.61.0 From d717a411ed101889af985f04ff5b5262bfb99b78 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Tue, 28 Jul 2026 11:25:15 +0300 Subject: [PATCH 07/10] Improve request creation --- .../sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 6595e50..8e1a934 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 @@ -71,8 +71,7 @@ public AuditLogNGCommunicator(ServiceBinding binding) { String sendBulkRequest(Object auditLogEvents, final boolean isLegacyEvent) throws JsonProcessingException { logger.debug("Sending bulk request to audit log service"); String bulkRequestJson = serializeBulkRequest(auditLogEvents); - HttpPost request; - request = prepareRequest(isLegacyEvent, bulkRequestJson); + HttpPost request = prepareRequest(isLegacyEvent, bulkRequestJson); try { return ResilienceDecorator.executeCallable(() -> executeBulkRequest(request), resilienceConfig); } catch (ErrorStatusException ese) { From ed5a010c2428d65695abe48b02c3b8c2fd262aee Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Tue, 28 Jul 2026 12:24:23 +0300 Subject: [PATCH 08/10] Added sap_support_user for legacy Security event. --- .../com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java | 3 +++ 1 file changed, 3 insertions(+) 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 592c11c..4c56014 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 @@ -205,6 +205,9 @@ private ObjectNode buildLegacySecurityEventMetadata(UserInfo userInfo) { ObjectNode metadata = OBJECT_MAPPER.createObjectNode(); metadata.put("ts", Instant.now().toString()); metadata.put("userInitiatorId", userInfo.getName() != null ? userInfo.getName() : ANONYMOUS_USER); + if (Boolean.TRUE.equals(userInfo.getAdditionalAttribute(ATTRIBUTE_SAP_SUPPORT_USER))) { + metadata.put(ATTRIBUTE_SAP_SUPPORT_USER, true); + } ObjectNode infraOther = metadata.putObject("infrastructure").putObject("other"); infraOther.put("runtimeType", "Java"); ObjectNode platformOther = metadata.putObject("platform").putObject("other"); From 272d00bc872efc099b55aeb0c501dfd8618d37f5 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Tue, 28 Jul 2026 12:26:20 +0300 Subject: [PATCH 09/10] Improved code. --- .../auditlog/ng/AuditLogNGHandler.java | 3 +- .../auditlog/ng/AuditLogNGHandlerTest.java | 45 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) 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 4c56014..6a85157 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 @@ -472,7 +472,8 @@ private ObjectNode buildEventEnvelope(String type, UserInfo userInfo, CdsData pa } private String resolveTenant(UserInfo userInfo) { - return (userInfo.getTenant() == null || userInfo.getTenant().isEmpty()) ? tenantService.readProviderTenant() : userInfo.getTenant(); + final String tenant = (userInfo.getTenant() == null || userInfo.getTenant().isEmpty()) ? tenantService.readProviderTenant() : userInfo.getTenant(); + return tenant != null ? tenant : "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 cce3bee..3a587ca 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,25 +1,5 @@ package com.sap.cds.feature.auditlog.ng; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import static com.sap.cds.feature.auditlog.ng.AuditLogNG.NAMESPACE_ATTRIBUTE; - -import java.io.File; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Assertions; -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 org.mockito.MockitoAnnotations; - import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -47,6 +27,31 @@ import com.sap.cds.services.auditlog.SecurityLogContext; import com.sap.cds.services.mt.TenantProviderService; import com.sap.cds.services.request.UserInfo; +import org.junit.jupiter.api.Assertions; +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 org.mockito.MockitoAnnotations; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.sap.cds.feature.auditlog.ng.AuditLogNG.NAMESPACE_ATTRIBUTE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class AuditLogNGHandlerTest { From baaebc39ac3b828b7a02673d753e28570af861d9 Mon Sep 17 00:00:00 2001 From: Georgi Shakev Date: Tue, 28 Jul 2026 12:40:49 +0300 Subject: [PATCH 10/10] Added tests for legacy Security event sap_support_user functionality. --- .../auditlog/ng/AuditLogNGHandlerTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 3a587ca..5b1e0bb 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 @@ -622,6 +622,46 @@ public void testNonSapSupportUser_DoesNotSetUserInitiatorContext() throws Except assertFalse(common.has("userInitiatorContext"), "userInitiatorContext should not be set for non-SAP support user"); } + @Test + public void testSapSupportUser_SetsAttributeInLegacySecurityEventMetadata() throws Exception { + when(userInfo.getAdditionalAttribute("sap_support_user")).thenReturn(true); + when(userInfo.getName()).thenReturn("support-user"); + + SecurityLogContext context = mock(SecurityLogContext.class); + SecurityLog securityLog = mock(SecurityLog.class); + when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(securityLog); + when(securityLog.getData()).thenReturn("security event data"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleSecurityEvent(context); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); + + JsonNode metadata = captor.getValue().get(0).get("data").get("metadata"); + assertTrue(metadata.has("sap_support_user"), "sap_support_user should be present in metadata"); + assertTrue(metadata.get("sap_support_user").asBoolean(), "sap_support_user should be true"); + assertEquals("support-user", metadata.get("userInitiatorId").asText()); + } + + @Test + public void testNonSapSupportUser_DoesNotSetAttributeInLegacySecurityEventMetadata() throws Exception { + when(userInfo.getAdditionalAttribute("sap_support_user")).thenReturn(null); + when(userInfo.getName()).thenReturn("regular-user"); + + SecurityLogContext context = mock(SecurityLogContext.class); + SecurityLog securityLog = mock(SecurityLog.class); + when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(securityLog); + when(securityLog.getData()).thenReturn("security event data"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleSecurityEvent(context); + verify(communicator).sendBulkRequest(captor.capture(), eq(true)); + + JsonNode metadata = captor.getValue().get(0).get("data").get("metadata"); + assertFalse(metadata.has("sap_support_user"), "sap_support_user should NOT be present for non-support user"); + } + @Test public void testResolveTenant_FallsBackToProviderTenant_WhenUserTenantNull() throws Exception { when(userInfo.getTenant()).thenReturn(null);