Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6d1fe68
feat(stream): microsoft 365 directory-audit event type and deserializer
andriikachur Jul 30, 2026
d753f27
fix(stream): classify timeout audit results as failures, preserve add…
andriikachur Jul 30, 2026
62a28ce
Merge branch 'main' into feature/microsoft-365-audit-events
andriikachur Jul 30, 2026
c2fb18e
feat: pass Microsoft 365 audit connection identity through to log det…
andriikachur Aug 3, 2026
e20cf5e
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 3, 2026
2110825
Merge branch 'main' into feature/microsoft-365-audit-events
andriikachur Aug 4, 2026
2485e4d
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 5, 2026
6ad81c3
feat(kafka,cassandra): Google Workspace audit event enums
andriikachur Aug 10, 2026
4be1ac3
feat(stream): Google Workspace source event types + mappings
andriikachur Aug 10, 2026
708eccb
feat(stream): Google Workspace directory-audit deserializer
andriikachur Aug 10, 2026
ef5c6ce
docs(stream): document the Reports API parameter union in the GW audi…
andriikachur Aug 11, 2026
4be8c47
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 11, 2026
3f05d6b
chore(stream): drop autogenerated deserializer docs from PR
andriikachur Aug 12, 2026
bd12939
docs(stream): clarify multiMessageValue handling in GWS deserializer
andriikachur Aug 12, 2026
61067ce
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 18, 2026
5ee448a
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 19, 2026
38ba4cb
Merge remote-tracking branch 'origin/main' into feature/microsoft-365…
andriikachur Aug 19, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,25 @@ public enum UnifiedEventType {
INTEGRATION_UPDATED(Severity.INFO, "Integration updated"),
INTEGRATION_DELETED(Severity.INFO, "Integration deleted"),

// Microsoft 365 directory audit events
M365_USER_MANAGEMENT(Severity.INFO, "Microsoft 365 user management"),

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥇

M365_GROUP_MANAGEMENT(Severity.INFO, "Microsoft 365 group management"),
M365_APPLICATION_MANAGEMENT(Severity.INFO, "Microsoft 365 application management"),
M365_ROLE_MANAGEMENT(Severity.WARNING, "Microsoft 365 role management"),
M365_POLICY(Severity.INFO, "Microsoft 365 policy change"),
M365_DIRECTORY_MANAGEMENT(Severity.INFO, "Microsoft 365 directory management"),
M365_AUDIT_FAILURE(Severity.WARNING, "Microsoft 365 failed directory operation"),
M365_AUDIT_OTHER(Severity.INFO, "Microsoft 365 directory audit event"),

// Google Workspace directory audit events
GWS_USER_MANAGEMENT(Severity.INFO, "Google Workspace user management"),
GWS_GROUP_MANAGEMENT(Severity.INFO, "Google Workspace group management"),
GWS_SECURITY_SETTINGS(Severity.WARNING, "Google Workspace security settings change"),
GWS_DOMAIN_SETTINGS(Severity.INFO, "Google Workspace domain settings change"),
GWS_DELEGATED_ADMIN(Severity.WARNING, "Google Workspace delegated admin change"),
GWS_AUDIT_FAILURE(Severity.WARNING, "Google Workspace failed directory operation"),
GWS_AUDIT_OTHER(Severity.INFO, "Google Workspace directory audit event"),

// Unknown events
UNKNOWN(Severity.WARNING, "Unknown event");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -4,7 +4,9 @@ public enum IntegratedToolType {

RMM("rmm"),
MESHCENTRAL ("meshcentral"),
FLEET ("fleet-mdm");
FLEET ("fleet-mdm"),
MICROSOFT_365("microsoft-365"),
GOOGLE_WORKSPACE("google-workspace");

private final String dbName;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public enum MessageType {
FLEET_MDM_POLICY_ACTIVITY_EVENT(IntegratedToolType.FLEET, DataEnrichmentServiceType.INTEGRATED_TOOLS_EVENTS,
List.of(Destination.CASSANDRA_EVENT_LOG, Destination.KAFKA_PINOT), EventHandlerType.COMMON_TYPE),
FLEET_MDM_POLICY_MEMBERSHIP_EVENT(IntegratedToolType.FLEET, DataEnrichmentServiceType.INTEGRATED_TOOLS_EVENTS,
List.of(Destination.CASSANDRA_EVENT_LOG, Destination.KAFKA_PINOT), EventHandlerType.COMMON_TYPE),
MICROSOFT_365_AUDIT_EVENT(IntegratedToolType.MICROSOFT_365, DataEnrichmentServiceType.PRE_ENRICHED,
List.of(Destination.CASSANDRA_EVENT_LOG, Destination.KAFKA_PINOT), EventHandlerType.COMMON_TYPE),
GOOGLE_WORKSPACE_AUDIT_EVENT(IntegratedToolType.GOOGLE_WORKSPACE, DataEnrichmentServiceType.PRE_ENRICHED,
List.of(Destination.CASSANDRA_EVENT_LOG, Destination.KAFKA_PINOT), EventHandlerType.COMMON_TYPE);

private final IntegratedToolType integratedToolType;
Expand Down
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))
Comment thread
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();
Comment thread
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
Expand Up @@ -269,5 +269,20 @@ private static void initializeDefaultMappings() {
// Policy Membership
registerMapping(IntegratedToolType.FLEET, SourceEventTypes.Fleet.POLICY_MEMBERSHIP_PASS, UnifiedEventType.POLICY_APPLIED);
registerMapping(IntegratedToolType.FLEET, SourceEventTypes.Fleet.POLICY_MEMBERSHIP_FAIL, UnifiedEventType.POLICY_VIOLATION);

// Microsoft 365 Entra directory audit mappings (directoryAudits category values)
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.USER_MANAGEMENT, UnifiedEventType.M365_USER_MANAGEMENT);
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.GROUP_MANAGEMENT, UnifiedEventType.M365_GROUP_MANAGEMENT);
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.APPLICATION_MANAGEMENT, UnifiedEventType.M365_APPLICATION_MANAGEMENT);
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.ROLE_MANAGEMENT, UnifiedEventType.M365_ROLE_MANAGEMENT);
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.POLICY, UnifiedEventType.M365_POLICY);
registerMapping(IntegratedToolType.MICROSOFT_365, SourceEventTypes.Microsoft365.DIRECTORY_MANAGEMENT, UnifiedEventType.M365_DIRECTORY_MANAGEMENT);

// Google Workspace directory audit mappings (admin application event types)
registerMapping(IntegratedToolType.GOOGLE_WORKSPACE, SourceEventTypes.GoogleWorkspace.USER_SETTINGS, UnifiedEventType.GWS_USER_MANAGEMENT);
registerMapping(IntegratedToolType.GOOGLE_WORKSPACE, SourceEventTypes.GoogleWorkspace.GROUP_SETTINGS, UnifiedEventType.GWS_GROUP_MANAGEMENT);
registerMapping(IntegratedToolType.GOOGLE_WORKSPACE, SourceEventTypes.GoogleWorkspace.SECURITY_SETTINGS, UnifiedEventType.GWS_SECURITY_SETTINGS);
registerMapping(IntegratedToolType.GOOGLE_WORKSPACE, SourceEventTypes.GoogleWorkspace.DOMAIN_SETTINGS, UnifiedEventType.GWS_DOMAIN_SETTINGS);
registerMapping(IntegratedToolType.GOOGLE_WORKSPACE, SourceEventTypes.GoogleWorkspace.DELEGATED_ADMIN_SETTINGS, UnifiedEventType.GWS_DELEGATED_ADMIN);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See no value at the interfaces.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if u want can create ticket for this refactoring

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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";
}
}
Loading
Loading