-
Notifications
You must be signed in to change notification settings - Fork 3
feat(stream): Microsoft 365 directory-audit event type and deserializer #1603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6d1fe68
d753f27
62a28ce
c2fb18e
e20cf5e
2110825
2485e4d
6ad81c3
4be1ac3
708eccb
ef5c6ce
4be8c47
3f05d6b
bd12939
61067ce
5ee448a
38ba4cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| public enum DataEnrichmentServiceType { | ||
|
|
||
| INTEGRATED_TOOLS_EVENTS, | ||
| RMM_RESULTS | ||
| RMM_RESULTS, | ||
| PRE_ENRICHED | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| package com.openframe.stream.deserializer; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.databind.node.ObjectNode; | ||
| import com.openframe.data.cassandra.model.enums.UnifiedEventType; | ||
| import com.openframe.data.model.enums.MessageType; | ||
| import com.openframe.kafka.model.debezium.CommonDebeziumMessage; | ||
| import com.openframe.stream.mapping.EventTypeMapper; | ||
| import com.openframe.stream.model.fleet.debezium.DeserializedDebeziumMessage; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.apache.commons.lang3.StringUtils; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.Instant; | ||
| import java.time.ZoneId; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.Optional; | ||
|
|
||
| /** | ||
| * Deserializes Google Workspace directory audit events polled from the Admin SDK Reports API | ||
| * {@code activities.list("admin")} endpoint. Events are hand-built by the poller (not CDC): a | ||
| * single polled activity's {@code events[]} array is fanned out by the poller into one Kafka | ||
| * record per event, so this deserializer stays 1:1 with {@link KafkaMessageDeserializer#deserialize} | ||
| * (that interface is single-result). The poller writes a FLAT {@code after} object per event with | ||
| * fields {@code uniqueQualifier}, {@code eventIndex}, {@code activityTime}, {@code eventType}, | ||
| * {@code eventName}, {@code actorEmail}, {@code ipAddress}, {@code event} (nested JSON carrying the | ||
| * raw event's {@code parameters}), plus tenant/organization passthrough fields ({@code tenantId}, | ||
| * {@code organizationId}, {@code organizationName}) and multi-connection fields | ||
| * ({@code connectionId}, {@code connectionName}) — hence | ||
| * {@link com.openframe.data.model.enums.DataEnrichmentServiceType#PRE_ENRICHED}. {@code toolEventId} | ||
| * is {@code uniqueQualifier + "-" + eventIndex}: Reports API activities are uniquely identified by | ||
| * {@code uniqueQualifier}, but a single activity can carry multiple events, so the pair keeps | ||
| * replays from the poller's cursor overlap window upsert idempotent per event. Events carry no | ||
| * agent reference. | ||
| * {@code connectionId}/{@code connectionName} (multi-connection orgs) are passed through into details. | ||
| * <p> | ||
| * {@code event.parameters[]} is an UNTOUCHED passthrough of the Reports API parameter union: each | ||
| * entry always has {@code name}, but its value key varies — {@code value} (string), | ||
| * {@code boolValue}, {@code intValue}, {@code multiValue} (array of strings) or | ||
| * {@code multiMessageValue}. The write-audit publisher (saas-lib | ||
| * {@code GoogleWorkspaceWriteAuditPublisher}) emits only the string {@code {name,value}} member of | ||
| * that union. Consumers rendering details must read | ||
| * {@code value ?? boolValue ?? intValue ?? multiValue} for the scalar/array members; nothing may | ||
| * assume {@code value} alone. {@code multiMessageValue} is deliberately NOT part of that fallback | ||
| * chain — it is an array of nested {@code {parameter: [{name, value, ...}]}} objects, so a consumer | ||
| * that needs it must render it recursively rather than coerce it to a scalar. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class GoogleWorkspaceAuditEventDeserializer implements KafkaMessageDeserializer { | ||
|
|
||
| private static final DateTimeFormatter DAY_FORMATTER = | ||
| DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("UTC")); | ||
| // Reports API does not carry a structured success/failure result field; failure is signalled | ||
| // by eventName itself (e.g. LOGIN_FAILURE, login_failure). | ||
| private static final String FAILURE_MARKER = "_FAILURE"; | ||
| private static final String UNKNOWN = "unknown"; | ||
|
|
||
| private final ObjectMapper mapper; | ||
|
|
||
| @Override | ||
| public MessageType getType() { | ||
| return MessageType.GOOGLE_WORKSPACE_AUDIT_EVENT; | ||
| } | ||
|
|
||
| @Override | ||
| public DeserializedDebeziumMessage deserialize(CommonDebeziumMessage debeziumMessage, MessageType messageType) { | ||
| JsonNode after = debeziumMessage.getPayload().getAfter(); | ||
| if (after == null || after.isNull()) { | ||
| return null; | ||
| } | ||
| long eventTimestamp = getEventTimestamp(after) | ||
| .orElse(debeziumMessage.getPayload().getTimestamp()); | ||
| String eventType = textField(after, "eventType").orElse(UNKNOWN); | ||
|
|
||
| return DeserializedDebeziumMessage.builder() | ||
| .payload(debeziumMessage.getPayload()) | ||
| .agentId(null) | ||
| .ingestDay(DAY_FORMATTER.format(Instant.ofEpochMilli(eventTimestamp))) | ||
| .sourceEventType(eventType) | ||
| .toolEventId(buildToolEventId(after)) | ||
| .unifiedEventType(resolveEventType(after, eventType)) | ||
| .message(textField(after, "eventName").orElse(null)) | ||
| .integratedToolType(messageType.getIntegratedToolType()) | ||
| .debeziumMessage(after.toString()) | ||
| .details(buildDetails(after)) | ||
| .eventTimestamp(eventTimestamp) | ||
| .skipProcessing(false) | ||
| .isVisible(true) | ||
| .tenantId(textField(after, "tenantId").orElse(null)) | ||
| .organizationId(textField(after, "organizationId").orElse(null)) | ||
| .organizationName(textField(after, "organizationName").orElse(null)) | ||
| .userId(textField(after, "actorEmail").orElse(null)) | ||
| .build(); | ||
| } | ||
|
|
||
| private UnifiedEventType resolveEventType(JsonNode after, String eventType) { | ||
| String eventName = textField(after, "eventName").orElse(""); | ||
| if (StringUtils.containsIgnoreCase(eventName, FAILURE_MARKER)) { | ||
| return UnifiedEventType.GWS_AUDIT_FAILURE; | ||
| } | ||
| UnifiedEventType mapped = EventTypeMapper.mapToUnifiedType(getType().getIntegratedToolType(), eventType); | ||
| return mapped == UnifiedEventType.UNKNOWN ? UnifiedEventType.GWS_AUDIT_OTHER : mapped; | ||
| } | ||
|
|
||
| private String buildToolEventId(JsonNode after) { | ||
| return textField(after, "uniqueQualifier") | ||
| .map(uniqueQualifier -> uniqueQualifier + "-" + textField(after, "eventIndex").orElse("0")) | ||
| .orElse(null); | ||
| } | ||
|
|
||
| private Optional<Long> getEventTimestamp(JsonNode after) { | ||
| return textField(after, "activityTime") | ||
| .flatMap(value -> { | ||
| try { | ||
| return Optional.of(Instant.parse(value).toEpochMilli()); | ||
| } catch (Exception e) { | ||
| log.warn("Unparseable activityTime '{}', falling back to processing timestamp", value); | ||
| return Optional.empty(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private String buildDetails(JsonNode after) { | ||
| ObjectNode details = mapper.createObjectNode(); | ||
| JsonNode event = after.get("event"); | ||
| if (event != null && !event.isNull()) { | ||
| details.set("event", event); | ||
| } | ||
| textField(after, "ipAddress").ifPresent(value -> details.put("ipAddress", value)); | ||
| textField(after, "connectionId").ifPresent(value -> details.put("connectionId", value)); | ||
| textField(after, "connectionName").ifPresent(value -> details.put("connectionName", value)); | ||
| return details.toString(); | ||
| } | ||
|
|
||
| private Optional<String> textField(JsonNode node, String fieldName) { | ||
| return Optional.ofNullable(node.get(fieldName)) | ||
| .filter(field -> !field.isNull()) | ||
| .map(JsonNode::asText) | ||
| .filter(StringUtils::isNotBlank); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| package com.openframe.stream.deserializer; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.databind.node.ObjectNode; | ||
| import com.openframe.data.cassandra.model.enums.UnifiedEventType; | ||
| import com.openframe.data.model.enums.MessageType; | ||
| import com.openframe.kafka.model.debezium.CommonDebeziumMessage; | ||
| import com.openframe.stream.mapping.EventTypeMapper; | ||
| import com.openframe.stream.model.fleet.debezium.DeserializedDebeziumMessage; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.apache.commons.lang3.StringUtils; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.Instant; | ||
| import java.time.ZoneId; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * Deserializes Microsoft 365 Entra directory audit events polled from Graph | ||
| * {@code auditLogs/directoryAudits}. Events are hand-built by the poller (not CDC), arrive | ||
| * pre-enriched with tenant/organization fields in the payload and carry no agent reference — | ||
| * hence {@link com.openframe.data.model.enums.DataEnrichmentServiceType#PRE_ENRICHED}. | ||
| * {@code toolEventId} is the Graph audit record id, so replays from the poller's cursor | ||
| * overlap window upsert idempotently. | ||
| * {@code connectionId}/{@code connectionName} (multi-connection orgs) are passed through into details. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class Microsoft365AuditEventDeserializer implements KafkaMessageDeserializer { | ||
|
|
||
| private static final DateTimeFormatter DAY_FORMATTER = | ||
| DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("UTC")); | ||
| // Graph result values that mean the operation did not succeed (result can be | ||
| // success | failure | timeout | unknownFutureValue). | ||
| private static final Set<String> FAILED_RESULTS = Set.of("failure", "timeout"); | ||
| private static final String UNKNOWN = "unknown"; | ||
|
|
||
| private final ObjectMapper mapper; | ||
|
|
||
| @Override | ||
| public MessageType getType() { | ||
| return MessageType.MICROSOFT_365_AUDIT_EVENT; | ||
| } | ||
|
|
||
| @Override | ||
| public DeserializedDebeziumMessage deserialize(CommonDebeziumMessage debeziumMessage, MessageType messageType) { | ||
| JsonNode after = debeziumMessage.getPayload().getAfter(); | ||
| if (after == null || after.isNull()) { | ||
| return null; | ||
| } | ||
| long eventTimestamp = getEventTimestamp(after) | ||
| .orElse(debeziumMessage.getPayload().getTimestamp()); | ||
| String category = textField(after, "category").orElse(UNKNOWN); | ||
|
|
||
| return DeserializedDebeziumMessage.builder() | ||
| .payload(debeziumMessage.getPayload()) | ||
| .agentId(null) | ||
| .ingestDay(DAY_FORMATTER.format(Instant.ofEpochMilli(eventTimestamp))) | ||
| .sourceEventType(category) | ||
| .toolEventId(textField(after, "auditId").orElse(null)) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| .unifiedEventType(resolveEventType(after, category)) | ||
| .message(textField(after, "activityDisplayName").orElse(null)) | ||
| .integratedToolType(messageType.getIntegratedToolType()) | ||
| .debeziumMessage(after.toString()) | ||
| .details(buildDetails(after)) | ||
| .eventTimestamp(eventTimestamp) | ||
| .skipProcessing(false) | ||
| .isVisible(true) | ||
| .tenantId(textField(after, "tenantId").orElse(null)) | ||
| .organizationId(textField(after, "organizationId").orElse(null)) | ||
| .organizationName(textField(after, "organizationName").orElse(null)) | ||
| .userId(textPath(after.path("initiatedBy").path("user"), "userPrincipalName")) | ||
| .build(); | ||
| } | ||
|
|
||
| private UnifiedEventType resolveEventType(JsonNode after, String category) { | ||
| if (textField(after, "result").map(String::toLowerCase).filter(FAILED_RESULTS::contains).isPresent()) { | ||
| return UnifiedEventType.M365_AUDIT_FAILURE; | ||
| } | ||
| UnifiedEventType mapped = EventTypeMapper.mapToUnifiedType(getType().getIntegratedToolType(), category); | ||
| return mapped == UnifiedEventType.UNKNOWN ? UnifiedEventType.M365_AUDIT_OTHER : mapped; | ||
| } | ||
|
|
||
| private Optional<Long> getEventTimestamp(JsonNode after) { | ||
| return textField(after, "activityDateTime") | ||
| .flatMap(value -> { | ||
| try { | ||
| return Optional.of(Instant.parse(value).toEpochMilli()); | ||
| } catch (Exception e) { | ||
| log.warn("Unparseable activityDateTime '{}', falling back to processing timestamp", value); | ||
| return Optional.empty(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private String buildDetails(JsonNode after) { | ||
| ObjectNode details = mapper.createObjectNode(); | ||
| JsonNode initiatedBy = after.get("initiatedBy"); | ||
| if (initiatedBy != null && !initiatedBy.isNull()) { | ||
| details.set("initiatedBy", initiatedBy); | ||
| } | ||
| JsonNode targetResources = after.get("targetResources"); | ||
| if (targetResources != null && !targetResources.isNull()) { | ||
| details.set("targetResources", targetResources); | ||
| } | ||
| JsonNode additionalDetails = after.get("additionalDetails"); | ||
| if (additionalDetails != null && !additionalDetails.isNull()) { | ||
| details.set("additionalDetails", additionalDetails); | ||
| } | ||
| textField(after, "connectionId").ifPresent(value -> details.put("connectionId", value)); | ||
| textField(after, "connectionName").ifPresent(value -> details.put("connectionName", value)); | ||
| return details.toString(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private Optional<String> textField(JsonNode node, String fieldName) { | ||
| return Optional.ofNullable(node.get(fieldName)) | ||
| .filter(field -> !field.isNull()) | ||
| .map(JsonNode::asText) | ||
| .filter(StringUtils::isNotBlank); | ||
| } | ||
|
|
||
| private String textPath(JsonNode node, String fieldName) { | ||
| String value = node.path(fieldName).asText(null); | ||
| return StringUtils.isNotBlank(value) ? value : null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -250,4 +250,29 @@ interface Fleet { | |
| String POLICY_MEMBERSHIP_PASS = "policy_membership_pass"; | ||
| String POLICY_MEMBERSHIP_FAIL = "policy_membership_fail"; | ||
| } | ||
|
|
||
| /** | ||
| * Microsoft 365 Entra directory audit event types (Graph directoryAudits {@code category} values). | ||
| */ | ||
| interface Microsoft365 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See no value at the interfaces.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It just practice that we had before. We already have Fleet, MeshCentral, Rmm interfaces there, so not to mess it up we should have separate interfaces here
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if u want can create ticket for this refactoring
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's convention of this service - that's fine |
||
|
|
||
| String USER_MANAGEMENT = "UserManagement"; | ||
| String GROUP_MANAGEMENT = "GroupManagement"; | ||
| String APPLICATION_MANAGEMENT = "ApplicationManagement"; | ||
| String ROLE_MANAGEMENT = "RoleManagement"; | ||
| String POLICY = "Policy"; | ||
| String DIRECTORY_MANAGEMENT = "DirectoryManagement"; | ||
| } | ||
|
|
||
| /** | ||
| * Google Workspace directory audit event types (Reports API {@code admin} application event types). | ||
| */ | ||
| interface GoogleWorkspace { | ||
|
|
||
| String USER_SETTINGS = "USER_SETTINGS"; | ||
| String GROUP_SETTINGS = "GROUP_SETTINGS"; | ||
| String SECURITY_SETTINGS = "SECURITY_SETTINGS"; | ||
| String DOMAIN_SETTINGS = "DOMAIN_SETTINGS"; | ||
| String DELEGATED_ADMIN_SETTINGS = "DELEGATED_ADMIN_SETTINGS"; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Arsenii-Malov seams we get more and more events non related to the tools.
If we need to refactor events?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that all of them are events and it doesn't really matter whether they are tools or not
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🥇