← Back to SOC feed Coverage →

Bulk role assignments performed by the same actor in a short window

kql MEDIUM Azure-Sentinel
T1098.003
AuditLogsSigninLogs
huntingmicrosoftofficialpersistence
This rule was pulled from an open-source repository and enriched with AI. Validate in a test environment before deploying to production.
View original rule at Azure-Sentinel →
Retrieved: 2026-05-28T11:00:00Z · Confidence: medium

Hunt Hypothesis

Adversaries may use automated scripts to rapidly assign directory roles within a short timeframe to establish persistent access and escalate privileges. SOC teams should proactively hunt for this behavior in Azure Sentinel to detect potential post-compromise activity and mitigate lateral movement risks.

KQL Query

let timeframe = 1d;
let window = 10m;
let threshold = 3;
// Role assignment events expanded to extract the target user per event
let RoleAssignments =
    AuditLogs
    | where TimeGenerated >= ago(timeframe)
    | where OperationName =~ "Add member to role."
    | where Result =~ "success"
    | extend ActorUpn = tolower(tostring(InitiatedBy.user.userPrincipalName))
    | extend ActorApp = tostring(InitiatedBy.app.displayName)
    | extend ActorIp  = iff(
          isnotempty(tostring(InitiatedBy.user.ipAddress)),
          tostring(InitiatedBy.user.ipAddress),
          tostring(InitiatedBy.app.ipAddress))
    | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
    | mv-expand TargetResource = TargetResources
    | where tostring(TargetResource.type) =~ "User"
    | extend TargetUpn = tostring(TargetResource.userPrincipalName)
    | where isnotempty(TargetUpn);
// Aggregate by actor and fixed ten-minute bucket, flag where count reaches threshold
// Note: bin() uses fixed time buckets; assignments at bucket boundaries may be undercounted.
// For sliding-window accuracy, consider using a range join approach.
let BulkActors =
    RoleAssignments
    | summarize
        FirstAssignment = min(TimeGenerated),
        LastAssignment  = max(TimeGenerated),
        AssignedUsers   = make_set(TargetUpn),
        AssignmentCount = count(),
        ActorIp         = any(ActorIp)
        by Actor, bin(TimeGenerated, window)
    | where AssignmentCount >= threshold
    | extend WindowDurationSeconds = datetime_diff('second', LastAssignment, FirstAssignment);
// Enrich with actor most recent sign-in country for geographic context
let ActorSignIns =
    SigninLogs
    | where TimeGenerated >= ago(timeframe)
    | where ResultType == 0
    | extend ActorUpn = tolower(UserPrincipalName)
    | summarize LastSignInTime = max(TimeGenerated) by ActorUpn, Location
    | summarize arg_max(LastSignInTime, Location) by ActorUpn
    | project ActorUpn, LastSignInCountry = Location;
BulkActors
| join kind=leftouter ActorSignIns on $left.Actor == $right.ActorUpn
| extend AccountName      = iff(Actor has "@", tostring(split(Actor, "@")[0]), Actor)
| extend AccountUPNSuffix = iff(Actor has "@", tostring(split(Actor, "@")[1]), "")
| project
    FirstAssignment,
    LastAssignment,
    WindowDurationSeconds,
    Actor,
    AccountName,
    AccountUPNSuffix,
    ActorIp,
    AssignmentCount,
    AssignedUsers,
    LastSignInCountry
| sort by FirstAssignment desc

Analytic Rule Definition

id: 8d2cc40f-f0e0-49bf-8983-164f7be3975d
name: Bulk role assignments performed by the same actor in a short window
description: |
  Identifies actors who perform three or more Entra ID directory role assignments
  within a ten-minute window, consistent with automated post-compromise persistence.
  Results are enriched with the actor's most recent sign-in country for analyst triage.
  Adjust the threshold variable for environments with routine bulk provisioning workflows.
  References:
  - https://learn.microsoft.com/azure/active-directory/roles/permissions-reference
  - https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities
  - https://attack.mitre.org/techniques/T1098/003/
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
      - SigninLogs
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098.003
query: |
  let timeframe = 1d;
  let window = 10m;
  let threshold = 3;
  // Role assignment events expanded to extract the target user per event
  let RoleAssignments =
      AuditLogs
      | where TimeGenerated >= ago(timeframe)
      | where OperationName =~ "Add member to role."
      | where Result =~ "success"
      | extend ActorUpn = tolower(tostring(InitiatedBy.user.userPrincipalName))
      | extend ActorApp = tostring(InitiatedBy.app.displayName)
      | extend ActorIp  = iff(
            isnotempty(tostring(InitiatedBy.user.ipAddress)),
            tostring(InitiatedBy.user.ipAddress),
            tostring(InitiatedBy.app.ipAddress))
      | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
      | mv-expand TargetResource = TargetResources
      | where tostring(TargetResource.type) =~ "User"
      | extend TargetUpn = tostring(TargetResource.userPrincipalName)
      | where isnotempty(TargetUpn);
  // Aggregate by actor and fixed ten-minute bucket, flag where count reaches threshold
  // Note: bin() uses fixed time buckets; assignments at bucket boundaries may be undercounted.
  // For sliding-window accuracy, consider using a range join approach.
  let BulkActors =
      RoleAssignments
      | summarize
          FirstAssignment = min(TimeGenerated),
          LastAssignment  = max(TimeGenerated),
          AssignedUsers   = make_set(TargetUpn),
          AssignmentCount = count(),
          ActorIp         = any(ActorIp)
          by Actor, bin(TimeGenerated, window)
      | where AssignmentCount >= threshold
      | extend WindowDurationSeconds = datetime_diff('second', LastAssignment, FirstAssignment);
  // Enrich with actor most recent sign-in country for geographic context
  let ActorSignIns =
      SigninLogs
      | where TimeGenerated >= ago(timeframe)
      | where ResultType == 0
      | extend ActorUpn = tolower(UserPrincipalName)
      | summarize LastSignInTime = max(TimeGenerated) by ActorUpn, Location
      | summarize arg_max(LastSignInTime, Location) by ActorUpn
      | project ActorUpn, LastSig

Required Data Sources

Sentinel TableNotes
AuditLogsEnsure this data connector is enabled
SigninLogsEnsure this data connector is enabled

MITRE ATT&CK Context

References

False Positive Guidance

Original source: https://github.com/Azure/Azure-Sentinel/blob/main/Hunting Queries/MultipleDataSources/BulkRoleAssignmentsInShortWindow.yaml