Break-glass account role or group membership changed
Description
Identifies role and group membership changes on an account designated as an emergency break-glass account, whose access is meant to stay fixed to the single role documented in the tenant's emergency-access runbook.
Query · kql
let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
let BreakGlassAccounts = (
_GetWatchlist('BreakGlassAccounts')
| project AccountUPN = tolower(tostring(SearchKey))
);
// Role and group names are extracted from modifiedProperties as an enrichment step only.
// A base row is always kept even when the property lookup below finds nothing, so a
// removal event whose modifiedProperties happen to be shaped differently than expected
// still surfaces instead of silently disappearing.
// materialize() is required here: RoleChangesBase is referenced twice below (once to
// build the name lookup, once as the left side of the join back to it). Without pinning
// it to a single computed result, the two references would each re-evaluate new_guid()
// independently, so the RowId used to join would never match itself.
// The break-glass account and the role name are both read from whichever TargetResources
// entry describes the user, not from a fixed array index, for the same reason as the
// group section below.
let RoleChangesBase = materialize(
AuditLogs
| where TimeGenerated between (starttime .. endtime)
| where Category =~ "RoleManagement"
| where OperationName in~ ("Add member to role", "Add member to role.", "Remove member from role", "Remove member from role.")
| where Result =~ "success"
| mv-apply TargetResource = TargetResources on (
where TargetResource.type =~ "User"
| extend TargetUpn = tolower(tostring(TargetResource.userPrincipalName)),
RoleProps = TargetResource.modifiedProperties
)
| where TargetUpn in (BreakGlassAccounts)
| extend RowId = new_guid()
| extend ChangeType = iff(OperationName has "Add", "RoleAdded", "RoleRemoved")
| extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorApp = tostring(InitiatedBy.app.displayName)
| extend Actor = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
| extend ActorIp = iff(
isnotempty(tostring(InitiatedBy.user.ipAddress)),
tostring(InitiatedBy.user.ipAddress),
tostring(InitiatedBy.app.ipAddress))
);
let RoleNames =
RoleChangesBase
| project RowId, RoleProps
| mv-expand ModProp = RoleProps
| where tostring(ModProp.displayName) =~ "Role.DisplayName"
| project RowId, ChangedObject = trim('"', tostring(coalesce(ModProp.newValue, ModProp.oldValue)));
let RoleChanges =
RoleChangesBase
| join kind=leftouter RoleNames on RowId
| extend ChangedObject = iff(isempty(ChangedObject), "(role name unavailable)", ChangedObject)
| project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
// Same reasoning as RoleChangesBase above: materialize() pins the result, including
// RowId, so the two references below see identical values instead of two independent
// new_guid() evaluations that would never match each other.
let GroupChangesBase = materialize(
AuditLogs
| where TimeGenerated between (starttime .. endtime)
| where Category =~ "GroupManagement"
| where OperationName in~ ("Add member to group", "Add owner to group", "Remove member from group", "Remove owner from group")
| where Result =~ "success"
| extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorApp = tostring(InitiatedBy.app.displayName)
| extend Actor = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
| extend ActorIp = iff(
isnotempty(tostring(InitiatedBy.user.ipAddress)),
tostring(InitiatedBy.user.ipAddress),
tostring(InitiatedBy.app.ipAddress))
| extend ChangeType = case(
OperationName has_cs "Add" and OperationName has_cs "owner", "GroupOwnerAdded",
OperationName has_cs "Add", "GroupMemberAdded",
OperationName has_cs "owner", "GroupOwnerRemoved",
"GroupMemberRemoved")
// The break-glass account is identified from the same TargetResources entry that
// carries its own modifiedProperties, not from a fixed array index, since an audit
// event can carry multiple resources and their ordering is not guaranteed.
| mv-apply TargetResource = TargetResources on (
where TargetResource.type =~ "User"
| extend TargetUpn = tolower(tostring(TargetResource.userPrincipalName)),
Properties = TargetResource.modifiedProperties
)
| where TargetUpn in (BreakGlassAccounts)
| extend RowId = new_guid()
);
let GroupNames =
GroupChangesBase
| project RowId, Properties
| mv-expand Property = Properties
| where tostring(Property.displayName) =~ "Group.DisplayName"
| project RowId, ChangedObject = trim('"', tostring(coalesce(Property.newValue, Property.oldValue)));
let GroupChanges =
GroupChangesBase
| join kind=leftouter GroupNames on RowId
| extend ChangedObject = iff(isempty(ChangedObject), "(group name unavailable)", ChangedObject)
| project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
union RoleChanges, GroupChanges
| extend AccountName = tostring(split(TargetUpn, "@")[0])
| extend AccountUPNSuffix = tostring(split(TargetUpn, "@")[1])
| project
TimeGenerated,
OperationName,
ChangeType,
ChangedObject,
TargetUpn,
AccountName,
AccountUPNSuffix,
Actor,
ActorIp,
CorrelationId
| sort by TimeGenerated desc