Detection Engineering  ·  Microsoft Sentinel  ·  Insider Threat

Building an Insider Threat Detection Program for Employee Offboarding in Microsoft Sentinel

A practical walkthrough: dynamic watchlists, ten KQL analytics rules, alert fusion, a monitoring workbook, and RBAC isolation.

Insider threat during employee offboarding is one of the hardest risks to operationalise detection for. The window between HR initiating separation and IT completing revocation is where most incidents happen, and most organisations have nothing watching it.


The Problem This Solves

When an employee with privileged access is leaving, there's a window between when HR initiates offboarding and when IT fully revokes access. That window is the risk.

During that period, the user still has credentials, still has permissions, and knows they're leaving. Security teams need visibility into what's happening while the investigation remains operationally intact.


Architecture Overview

  • A dynamic watchlist — CSV-managed, containing the user's UPN, display name, and offboarding date. Rules reference it directly, so adding or removing a user is a single CSV update.
  • Ten scheduled KQL analytics rules — each targeting a specific exfiltration or persistence path, from bulk file downloads to federated identity backdoors.
  • Alert fusion via entity grouping — all rules group by Account entity with an 8-hour correlation window, so the SOC sees one incident per user containing all suspicious activity rather than dozens of separate alerts.
  • RBAC isolation — table-level access controls ensure the monitoring program remains operationally intact — the rules, watchlist, and active alerts are not accessible to the monitored account.

The Watchlist

Go to Sentinel → Watchlists → + New. Create a watchlist named HighRiskDepartures with three columns:

UPN,DisplayName,OffboardingDate
[email protected],John Doe,2026-01-15T00:00:00Z

Use ISO 8601 format for OffboardingDate exactly as shown. todatetime() silently returns null on anything else — short dates, regional formats, Excel auto-formatting — and the date filtering disappears with no error. Monitoring begins when a UPN is added; OffboardingDate is available for custom filtering but the core rules don't require a date join in every query.

Verify it loaded correctly:

_GetWatchlist('HighRiskDepartures')
| project UPN, DisplayName, OffboardingDate = todatetime(OffboardingDate)
| where isnotnull(OffboardingDate)

Prerequisites

Each rule depends on specific data connectors being active in your Sentinel workspace. Rules will return empty results silently if the underlying table isn't populated — no error, just no alerts.

  • Microsoft 365 (OfficeActivity) — required for Rules 1, 2, 3, 9. Covers SharePoint, OneDrive, Teams, and Exchange audit events.
  • Microsoft Defender for Endpoint (DeviceFileEvents, DeviceEvents) — required for Rules 1, 5. Covers endpoint file activity and USB device events.
  • Microsoft Defender for Office 365 (EmailEvents, EmailAttachmentInfo) — required for Rule 9. Covers outbound email telemetry and attachment metadata.
  • Azure Activity (AzureActivity) — required for Rules 1, 6, 7, 10. Covers Azure control-plane operations.
  • Azure Active Directory (AuditLogs, SigninLogs) — required for Rules 4, 6, 8. Covers identity events, role assignments, and app registrations.
  • Entra ID Identity Protection — required for Rule 4 risk signal enrichment. Without it, RiskLevelDuringSignIn will be empty and risky sign-in scoring won't fire.

Retention matters. Rule 4 builds a 60-day baseline from SigninLogs. If your workspace retention is set to 30 days, the baseline will be incomplete and location anomaly scoring will be less accurate. Check retention settings in Log Analytics → Usage and estimated costs → Data retention before deploying Rule 4.

Verify each connector in Sentinel → Data connectors before deploying. Run a quick table check in Logs — OfficeActivity | take 1 — to confirm data is flowing before building rules that depend on it.

Detection Coverage: Ten KQL Rules

Rule 1 — Bulk File Activity Across All Locations

Four-source union: SharePoint/OneDrive/Teams, Exchange attachments, MDE endpoint, and Azure Storage. Fires when total file operations exceed 50 in the window. Activity across more than two sources simultaneously triggers Critical regardless of volume.

let watchlist = _GetWatchlist('HighRiskDepartures') | project UPN;
let timeWindow = ago(1h);
union
(
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where Operation in (
        "FileDownloaded","FileSyncDownloadedFull",
        "FileAccessed","FileCopied","FileMoved"
    )
    | where OfficeWorkload in ("SharePoint","OneDrive","MicrosoftTeams")
    | extend
        Actor = UserId,
        FileName = SourceFileName,
        FileExtension = SourceFileExtension,
        Location = strcat(OfficeWorkload, " - ", Site_Url),
        Source = OfficeWorkload,
        EventOperation = Operation
    | project TimeGenerated, Actor, FileName,
        FileExtension, Location, Source, EventOperation
),
(
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload == "Exchange"
    | where Operation == "Send"
    | where tostring(Parameters) has "Attachment"
    | extend
        Actor = UserId,
        FileName = tostring(Parameters),
        FileExtension = "",
        Location = "Exchange - Email Attachment",
        Source = "Exchange",
        EventOperation = Operation
    | project TimeGenerated, Actor, FileName,
        FileExtension, Location, Source, EventOperation
),
(
    DeviceFileEvents
    | where TimeGenerated > timeWindow
    | where InitiatingProcessAccountUpn in~ (watchlist)
    | where ActionType in ("FileCreated","FileCopied","FileRenamed")
    | extend
        Actor = InitiatingProcessAccountUpn,
        FileExtension = tostring(split(FileName, ".")[1]),
        Location = strcat("Endpoint - ", DeviceName, " - ", FolderPath),
        Source = "MDE Endpoint",
        EventOperation = ActionType
    | project TimeGenerated, Actor, FileName,
        FileExtension, Location, Source, EventOperation
),
(
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read",
        "Microsoft.Storage/storageAccounts/listkeys/action",
        "Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey/action"
    )
    | extend
        Actor = Caller,
        FileName = Resource,
        FileExtension = "",
        Location = strcat("Azure Storage - ", ResourceGroup, " - ", Resource),
        Source = "Azure Storage",
        EventOperation = OperationNameValue
    | project TimeGenerated, Actor, FileName,
        FileExtension, Location, Source, EventOperation
)
| summarize
    TotalCount = count(),
    UniqueFiles = dcount(FileName),
    Sources = make_set(Source),
    LocationList = make_set(Location, 10),
    FileList = make_set(FileName, 20),
    Operations = make_set(EventOperation)
    by Actor
| where TotalCount > 50
| extend RiskScore = case(
    array_length(Sources) > 2, "Critical - Multiple Sources",
    TotalCount > 200,          "Critical - Very High Volume",
    TotalCount > 100,          "High - High Volume",
    "Medium - Elevated Volume"
)
| project Actor, TotalCount, UniqueFiles,
    Sources, LocationList, FileList, Operations, RiskScore
| order by TotalCount desc

Run every 1 hour | MITRE: T1048 — Exfiltration Over Alternative Protocol | Severity: High

Rule 2 — Email Forwarding Rule Created

Uses mv-expand to properly parse the Parameters array rather than a simple string match — catches forwarding rules that a has_any approach would miss. Covers rule creation and modification across four operations. Any single match fires immediately with no volume threshold.

let watchlist = _GetWatchlist('HighRiskDepartures') | project UPN;
let timeWindow = ago(1h);
OfficeActivity
| where TimeGenerated > timeWindow
| where UserId in~ (watchlist)
| where Operation in (
    "New-InboxRule","Set-InboxRule",
    "UpdateInboxRules","Set-Mailbox"
)
| mv-expand ParsedParameters = todynamic(tostring(Parameters))
| extend
    ParamName  = tostring(ParsedParameters.Name),
    ParamValue = tostring(ParsedParameters.Value)
| where ParamName in (
    "ForwardTo","RedirectTo",
    "ForwardAsAttachmentTo",
    "AutoForwardEnabled","DeliverToMailboxAndForward"
)
| where isnotempty(ParamValue)
| extend
    Actor      = UserId,
    RuleAction = ParamName,
    Destination = ParamValue
| summarize
    RuleCount   = count(),
    RuleActions = make_set(RuleAction),
    Destinations = make_set(Destination),
    FirstSeen   = min(TimeGenerated),
    LastSeen    = max(TimeGenerated)
    by Actor, ClientIP
| extend RiskScore = case(
    RuleActions has "ForwardTo",             "Critical - Direct Forwarding",
    RuleActions has "RedirectTo",            "Critical - Redirect Rule",
    RuleActions has "ForwardAsAttachmentTo", "Critical - Forward as Attachment",
    RuleActions has "AutoForwardEnabled",    "High - Auto Forward Enabled",
    "High - Suspicious Mailbox Rule"
)
| project Actor, RuleCount, RuleActions,
    Destinations, RiskScore, ClientIP, FirstSeen, LastSeen
| order by RuleCount desc

Run every 1 hour | MITRE: T1114.003 — Email Forwarding Rule | Severity: High

Rule 3 — External File Sharing Detected

Four-part union covering SharePoint, Teams, Exchange, and OneDrive full folder sync. Anonymous link creation triggers Critical immediately. Replace the domain placeholders with your actual tenant domains before deploying.

let watchlist = _GetWatchlist('HighRiskDepartures') | project UPN;
let timeWindow = ago(30m);
let internalDomain = "contoso.com";
let internalDomainAlt = "contoso.onmicrosoft.com";
let SharePointSharing =
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload in ("SharePoint","OneDrive")
    | where Operation in (
        "SharingInvitationCreated","SharingSet",
        "AddedToSecureLink","SecureLinkCreated",
        "AnonymousLinkCreated","AnonymousLinkUpdated",
        "SharingInvitationAccepted","AddedToGroup"
    )
    | extend
        IsAnonymous = Operation in (
            "AnonymousLinkCreated","AnonymousLinkUpdated"),
        IsExternal = iff(
            TargetUserOrGroupType == "Guest"
            or (isnotempty(TargetUserOrGroupName)
            and tostring(split(TargetUserOrGroupName,"@")[1])
                !~ internalDomain
            and tostring(split(TargetUserOrGroupName,"@")[1])
                !~ internalDomainAlt),
            true, false)
    | where IsAnonymous == true or IsExternal == true
    | extend
        Actor = UserId, SharedItem = SourceFileName,
        SharedLocation = Site_Url,
        ShareTarget = iff(IsAnonymous,"Anonymous Link",
            TargetUserOrGroupName),
        SharingMethod = Operation, Source = OfficeWorkload,
        RiskWeight = case(
            IsAnonymous == true, 5,
            TargetUserOrGroupType == "Guest", 4, 3)
    | project TimeGenerated, Actor, SharedItem, SharedLocation,
        ShareTarget, SharingMethod, Source, RiskWeight,
        IsAnonymous, IsExternal, ClientIP;
let TeamsSharing =
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload == "MicrosoftTeams"
    | where Operation in (
        "MemberAdded","TeamsSessionStarted",
        "MessageCreatedHasLink","FileUploaded","FileSyncUploadedFull"
    )
    | extend
        IsExternal = iff(
            isnotempty(TargetUserOrGroupName)
            and tostring(split(TargetUserOrGroupName,"@")[1])
                !~ internalDomain
            and tostring(split(TargetUserOrGroupName,"@")[1])
                !~ internalDomainAlt,
            true, false)
    | where IsExternal == true
    | extend
        Actor = UserId, SharedItem = SourceFileName,
        SharedLocation = strcat("Teams - ",
            tostring(split(Site_Url,"/")[4])),
        ShareTarget = TargetUserOrGroupName,
        SharingMethod = Operation,
        Source = "MicrosoftTeams",
        RiskWeight = 3, IsAnonymous = false
    | project TimeGenerated, Actor, SharedItem, SharedLocation,
        ShareTarget, SharingMethod, Source, RiskWeight,
        IsAnonymous, IsExternal, ClientIP;
let ExchangeSharing =
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload == "Exchange"
    | where Operation == "Send"
    | where tostring(Parameters) !has internalDomain
    | where tostring(Parameters) has_any (
        "gmail.com","yahoo.com","hotmail.com",
        "outlook.com","protonmail.com","icloud.com","mail.com"
    )
    | extend
        IsExternal = true, Actor = UserId,
        SharedItem = tostring(Parameters),
        SharedLocation = "Exchange - Outbound Email",
        ShareTarget = tostring(Parameters),
        SharingMethod = "EmailSentExternal",
        Source = "Exchange", RiskWeight = 4, IsAnonymous = false
    | project TimeGenerated, Actor, SharedItem, SharedLocation,
        ShareTarget, SharingMethod, Source, RiskWeight,
        IsAnonymous, IsExternal, ClientIP;
let OneDriveSyncActivity =
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload == "OneDrive"
    | where Operation in (
        "FileSyncDownloadedFull","FileSyncUploadedFull")
    | extend
        IsAnonymous = false, IsExternal = true,
        Actor = UserId, SharedItem = SourceFileName,
        SharedLocation = Site_Url,
        ShareTarget = strcat("Local Sync - ", UserId),
        SharingMethod = Operation, Source = "OneDrive Sync",
        RiskWeight = case(
            Operation == "FileSyncDownloadedFull", 4, 3)
    | project TimeGenerated, Actor, SharedItem, SharedLocation,
        ShareTarget, SharingMethod, Source, RiskWeight,
        IsAnonymous, IsExternal, ClientIP;
union SharePointSharing, TeamsSharing,
    ExchangeSharing, OneDriveSyncActivity
| summarize
    TotalSharingEvents = count(),
    AnonymousLinks = countif(IsAnonymous == true),
    ExternalShares = countif(IsExternal == true),
    UniqueTargets  = dcount(ShareTarget),
    UniqueFiles    = dcount(SharedItem),
    SharedItems    = make_set(SharedItem, 20),
    ShareTargets   = make_set(ShareTarget, 20),
    SharingMethods = make_set(SharingMethod),
    Sources        = make_set(Source),
    WeightedScore  = sum(RiskWeight),
    FirstSeen      = min(TimeGenerated),
    LastSeen       = max(TimeGenerated)
    by Actor, ClientIP
| extend RiskLevel = case(
    AnonymousLinks > 0,  "Critical - Anonymous Link Created",
    WeightedScore >= 20, "Critical - High Volume External Sharing",
    WeightedScore >= 10, "High - Multiple External Shares",
    "High - External Share Detected"
)
| project Actor, TotalSharingEvents, AnonymousLinks,
    ExternalShares, UniqueTargets, UniqueFiles, SharedItems,
    ShareTargets, SharingMethods, Sources, WeightedScore,
    RiskLevel, ClientIP, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 30 mins | MITRE: T1567.002 — Exfiltration to Cloud Storage | Severity: High

Rule 4 — Sign-In from Unusual Location

Builds a 60-day historical baseline per user using set_has_element for accurate set membership checks. Flags new countries, new cities, risky sign-ins from Entra ID Identity Protection, and unmanaged or non-compliant devices. Weighted scoring — new country combined with a risky sign-in scores 10, the highest single-event weight in the program.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(1h);
let lookbackWindow = ago(60d);
let HistoricalBaseline =
    SigninLogs
    | where TimeGenerated between (lookbackWindow .. ago(7d))
    | where UserPrincipalName in~ (watchlist)
    | where ResultType == 0
    | extend
        Country = tostring(LocationDetails.countryOrRegion),
        City    = tostring(LocationDetails.city)
    | summarize
        KnownCountries = make_set(Country),
        KnownCities    = make_set(City),
        KnownIPs       = make_set(IPAddress, 50)
        by UserPrincipalName;
let RecentSignIns =
    SigninLogs
    | where TimeGenerated > timeWindow
    | where UserPrincipalName in~ (watchlist)
    | where ResultType == 0
    | extend
        Country     = tostring(LocationDetails.countryOrRegion),
        City        = tostring(LocationDetails.city),
        DeviceOS    = tostring(DeviceDetail.operatingSystem),
        IsCompliant = tostring(DeviceDetail.isCompliant),
        IsManaged   = tostring(DeviceDetail.isManaged)
    | project TimeGenerated, UserPrincipalName, IPAddress,
        Country, City, AppDisplayName, DeviceOS,
        IsCompliant, IsManaged, RiskLevelDuringSignIn,
        RiskLevelAggregated, ConditionalAccessStatus;
RecentSignIns
| join kind=leftouter HistoricalBaseline on UserPrincipalName
| extend
    IsNewCountry = iff(isnull(KnownCountries) or not(set_has_element(
        KnownCountries, Country)), true, false),
    IsNewCity    = iff(isnull(KnownCities) or not(set_has_element(
        KnownCities, City)), true, false),
    IsNewIP      = iff(isnull(KnownIPs) or not(set_has_element(
        KnownIPs, IPAddress)), true, false),
    IsRisky      = iff(RiskLevelDuringSignIn
        in ("medium","high"), true, false),
    IsUnmanaged  = iff(IsManaged == "false"
        or IsCompliant == "false", true, false)
| extend RiskWeight = case(
    IsNewCountry == true and IsRisky == true,    10,
    IsNewCountry == true,                         7,
    IsNewCity    == true and IsRisky == true,     8,
    IsNewCity    == true,                         5,
    IsRisky      == true,                         6,
    IsNewIP      == true and IsUnmanaged == true, 5,
    IsNewIP      == true,                         3,
    IsUnmanaged  == true,                         2,
    1
)
| where IsNewCountry == true or IsNewCity == true
    or IsRisky == true
    or (IsNewIP == true and IsUnmanaged == true)
| summarize
    TotalSignIns     = count(),
    NewCountries     = make_set_if(Country, IsNewCountry == true),
    NewCities        = make_set_if(City, IsNewCity == true),
    NewIPs           = make_set_if(IPAddress, IsNewIP == true),
    RiskySignIns     = countif(IsRisky == true),
    UnmanagedSignIns = countif(IsUnmanaged == true),
    AppsAccessed     = make_set(AppDisplayName, 10),
    KnownCountries   = take_any(KnownCountries),
    WeightedScore    = sum(RiskWeight),
    FirstSeen        = min(TimeGenerated),
    LastSeen         = max(TimeGenerated)
    by UserPrincipalName
| extend RiskLevel = case(
    WeightedScore >= 15, "Critical - Multiple High Risk Indicators",
    WeightedScore >= 10, "High - New Country or Risky Sign-In",
    WeightedScore >= 5,  "Medium - New Location Detected",
    "Low - Minor Anomaly"
)
| where WeightedScore >= 3
| project UserPrincipalName, TotalSignIns, NewCountries,
    NewCities, NewIPs, RiskySignIns, UnmanagedSignIns,
    AppsAccessed, KnownCountries, WeightedScore,
    RiskLevel, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 1 hour | MITRE: T1078 — Valid Accounts | Severity: Medium

Rule 5 — USB and Removable Storage Write

Three parts: USB device connection events, file writes detected via drive letter regex ([E-Z]:\ — more reliable than path string matching), and sensitive file type detection on removable storage. The regex approach catches mapped network drives and non-standard removable paths that a simple has "Removable" filter misses.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(30m);
let USBConnections =
    DeviceEvents
    | where TimeGenerated > timeWindow
    | where InitiatingProcessAccountUpn in~ (watchlist)
    | where ActionType in (
        "PnpDeviceConnected","UsbDriveMounted",
        "RemovableStoragePolicyTriggered"
    )
    | extend
        Actor        = InitiatingProcessAccountUpn,
        DeviceAction = ActionType,
        USBDetails   = tostring(AdditionalFields),
        Source       = "USB Connection Event",
        RiskWeight   = case(
            ActionType == "RemovableStoragePolicyTriggered", 5,
            ActionType == "UsbDriveMounted", 4, 3)
    | project TimeGenerated, Actor, DeviceName,
        DeviceAction, USBDetails, Source, RiskWeight;
let RemovableStorageWrites =
    DeviceFileEvents
    | where TimeGenerated > timeWindow
    | where InitiatingProcessAccountUpn in~ (watchlist)
    | where ActionType in (
        "FileCreated","FileCopied","FileRenamed","FileModified")
    | where FolderPath matches regex @"(?i)^[E-Z]:\"
    | where FolderPath !startswith "C:\"
        and FolderPath !startswith "D:\"
        and FolderPath !contains "Windows"
        and FolderPath !contains "Program Files"
    | extend
        Actor        = InitiatingProcessAccountUpn,
        DeviceAction = ActionType,
        USBDetails   = strcat(FileName, " → ", FolderPath),
        Source       = "Removable Storage Write",
        RiskWeight   = case(
            ActionType == "FileCopied",  4,
            ActionType == "FileCreated", 3, 2)
    | project TimeGenerated, Actor, DeviceName,
        DeviceAction, USBDetails, Source, RiskWeight;
let SensitiveFileWrites =
    DeviceFileEvents
    | where TimeGenerated > timeWindow
    | where InitiatingProcessAccountUpn in~ (watchlist)
    | where ActionType in ("FileCreated","FileCopied")
    | where FolderPath matches regex @"(?i)^[E-Z]:\"
    | where FileName has_any (
        ".docx",".xlsx",".pptx",".pdf",
        ".csv",".sql",".bak",".zip",
        ".rar",".7z",".tar",".gz",
        ".pst",".ost",".db",".mdb",
        ".key",".pem",".pfx",".p12",
        ".py",".ps1",".sh",".json",
        ".xml",".conf",".config",".env"
    )
    | extend
        Actor        = InitiatingProcessAccountUpn,
        DeviceAction = strcat("SensitiveFile - ", ActionType),
        USBDetails   = strcat(FileName, " → ", FolderPath),
        Source       = "Sensitive File on USB",
        RiskWeight   = 6
    | project TimeGenerated, Actor, DeviceName,
        DeviceAction, USBDetails, Source, RiskWeight;
union USBConnections, RemovableStorageWrites, SensitiveFileWrites
| summarize
    TotalEvents        = count(),
    USBConnections     = countif(Source == "USB Connection Event"),
    FileWrites         = countif(Source == "Removable Storage Write"),
    SensitiveFileCount = countif(Source == "Sensitive File on USB"),
    AffectedDevices    = make_set(DeviceName),
    FileDetails        = make_set(USBDetails, 30),
    Actions            = make_set(DeviceAction),
    WeightedScore      = sum(RiskWeight),
    FirstSeen          = min(TimeGenerated),
    LastSeen           = max(TimeGenerated)
    by Actor
| extend RiskLevel = case(
    SensitiveFileCount > 0
        and WeightedScore >= 10, "Critical - Sensitive Files on USB",
    SensitiveFileCount > 0,     "High - Sensitive File Types Detected",
    WeightedScore >= 15,        "High - High Volume USB Write Activity",
    WeightedScore >= 8,         "Medium - USB Write Activity Detected",
    "Medium - USB Device Connected"
)
| project Actor, TotalEvents, USBConnections, FileWrites,
    SensitiveFileCount, AffectedDevices, FileDetails,
    Actions, WeightedScore, RiskLevel, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 30 mins | MITRE: T1052 — Exfiltration Over Physical Medium | Severity: High

Rule 6 — Privileged Role Assignment Initiated

Four parts: Entra ID direct and eligible role assignments, application and service principal owner changes, Azure RBAC assignments at subscription level, and privileged group membership changes. The group membership part filters by group name keywords (Admin, Global, Privileged, etc.) — targeted to reduce noise from routine group changes.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(15m);
let RoleAssignments =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add member to role","Add eligible member to role",
        "Add member to role.","Add eligible member to role.",
        "Add scoped member to role","Add scoped member to role."
    )
    | extend
        Actor          = Initiator,
        TargetUser     = tostring(TargetResources[0].userPrincipalName),
        TargetResource = tostring(TargetResources[0].displayName),
        RoleAssigned   = tostring(
            TargetResources[0].modifiedProperties[0].newValue),
        InitiatorIP    = tostring(InitiatedBy.user.ipAddress),
        Source         = "Entra ID Role Assignment",
        RiskWeight     = 8
    | project TimeGenerated, Actor, TargetUser,
        TargetResource, RoleAssigned, InitiatorIP,
        Source, RiskWeight;
let AppOwnerChanges =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add owner to application","Add owner to service principal",
        "Add owner to group",
        "Add app role assignment to service principal",
        "Add delegated permission grant","Add OAuth2PermissionGrant"
    )
    | extend
        Actor          = Initiator,
        TargetUser     = tostring(TargetResources[0].userPrincipalName),
        TargetResource = tostring(TargetResources[0].displayName),
        RoleAssigned   = OperationName,
        InitiatorIP    = tostring(InitiatedBy.user.ipAddress),
        Source         = "App/SP Owner Change",
        RiskWeight     = 9
    | project TimeGenerated, Actor, TargetUser,
        TargetResource, RoleAssigned, InitiatorIP,
        Source, RiskWeight;
let AzureRBAC =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue in (
        "Microsoft.Authorization/roleAssignments/write",
        "Microsoft.Authorization/roleDefinitions/write",
        "Microsoft.Authorization/policyAssignments/write"
    )
    | extend
        Actor          = Caller,
        TargetUser     = tostring(parse_json(Properties).requestbody),
        TargetResource = Resource,
        RoleAssigned   = OperationNameValue,
        InitiatorIP    = CallerIpAddress,
        Source         = "Azure RBAC Assignment",
        RiskWeight     = 8
    | project TimeGenerated, Actor, TargetUser,
        TargetResource, RoleAssigned, InitiatorIP,
        Source, RiskWeight;
let PrivilegedGroupChanges =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add member to group","Add member to group.",
        "Add guest to group"
    )
    | extend GroupName = tostring(TargetResources[0].displayName)
    | where GroupName has_any (
        "Admin","Administrator","Global","Privileged",
        "Security","Owner","Root","Superuser","Elevated"
    )
    | extend
        Actor          = Initiator,
        TargetUser     = tostring(TargetResources[1].userPrincipalName),
        TargetResource = GroupName,
        RoleAssigned   = strcat("Added to Group: ", GroupName),
        InitiatorIP    = tostring(InitiatedBy.user.ipAddress),
        Source         = "Privileged Group Membership",
        RiskWeight     = 7
    | project TimeGenerated, Actor, TargetUser,
        TargetResource, RoleAssigned, InitiatorIP,
        Source, RiskWeight;
union RoleAssignments, AppOwnerChanges,
    AzureRBAC, PrivilegedGroupChanges
| summarize
    TotalEvents      = count(),
    RoleChanges      = countif(Source == "Entra ID Role Assignment"),
    AppOwnerChanges  = countif(Source == "App/SP Owner Change"),
    AzureRBACChanges = countif(Source == "Azure RBAC Assignment"),
    GroupChanges     = countif(Source == "Privileged Group Membership"),
    TargetUsers      = make_set(TargetUser, 20),
    TargetResources  = make_set(TargetResource, 20),
    RolesAssigned    = make_set(RoleAssigned, 20),
    Sources          = make_set(Source),
    IPAddresses      = make_set(InitiatorIP),
    WeightedScore    = sum(RiskWeight),
    FirstSeen        = min(TimeGenerated),
    LastSeen         = max(TimeGenerated)
    by Actor
| extend RiskLevel = case(
    AppOwnerChanges > 0
        and RoleChanges > 0,       "Critical - Role + App Owner Change",
    AppOwnerChanges > 0,           "Critical - App/SP Owner Modification",
    AzureRBACChanges > 0
        and RoleChanges > 0,       "Critical - Multiple RBAC Changes",
    WeightedScore >= 15,           "Critical - High Volume Privilege Changes",
    WeightedScore >= 8,            "High - Privilege Assignment Detected",
    "High - Role Change Initiated"
)
| project Actor, TotalEvents, RoleChanges, AppOwnerChanges,
    AzureRBACChanges, GroupChanges, TargetUsers,
    TargetResources, RolesAssigned, Sources, IPAddresses,
    WeightedScore, RiskLevel, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 15 mins | MITRE: TA0004 — Privilege Escalation | Severity: High

Rule 7 — Sensitive Azure Resource Access

Five parts covering Key Vault (secrets, keys, certificates), Storage Account (key listing, SAS generation, deletions), Database exports and firewall rule changes, Compute snapshot and disk export operations, and Resource Group deletions. Deletion operations score 9-10 — the highest risk weights in this rule — because destruction can be as damaging as exfiltration.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(15m);
let KeyVaultAccess =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.KeyVault/vaults/secrets/read",
        "Microsoft.KeyVault/vaults/secrets/write",
        "Microsoft.KeyVault/vaults/secrets/delete",
        "Microsoft.KeyVault/vaults/keys/read",
        "Microsoft.KeyVault/vaults/keys/write",
        "Microsoft.KeyVault/vaults/certificates/read",
        "Microsoft.KeyVault/vaults/certificates/write"
    )
    | extend
        Actor            = Caller,
        ResourceAccessed = Resource,
        Operation        = OperationNameValue,
        Location         = strcat("KeyVault - ",
            ResourceGroup, " - ", Resource),
        Source           = "Key Vault",
        RiskWeight       = case(
            OperationNameValue has "secrets/read",      7,
            OperationNameValue has "keys/read",         7,
            OperationNameValue has "certificates/read", 6,
            OperationNameValue has "write",             8,
            OperationNameValue has "delete",            9, 5)
    | project TimeGenerated, Actor, ResourceAccessed,
        Operation, Location, Source, RiskWeight, CallerIpAddress;
let StorageAccess =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.Storage/storageAccounts/listkeys/action",
        "Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey/action",
        "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read",
        "Microsoft.Storage/storageAccounts/blobServices/containers/delete",
        "Microsoft.Storage/storageAccounts/delete",
        "Microsoft.Storage/storageAccounts/write"
    )
    | extend
        Actor            = Caller,
        ResourceAccessed = Resource,
        Operation        = OperationNameValue,
        Location         = strcat("Storage - ",
            ResourceGroup, " - ", Resource),
        Source           = "Storage Account",
        RiskWeight       = case(
            OperationNameValue has "listkeys",               9,
            OperationNameValue has "generateUserDelegation", 8,
            OperationNameValue has "delete",                 9,
            OperationNameValue has "write",                  6, 5)
    | project TimeGenerated, Actor, ResourceAccessed,
        Operation, Location, Source, RiskWeight, CallerIpAddress;
let DatabaseAccess =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.Sql/servers/databases/export/action",
        "Microsoft.Sql/servers/databases/delete",
        "Microsoft.Sql/servers/databases/write",
        "Microsoft.Sql/servers/firewallRules/write",
        "Microsoft.DocumentDB/databaseAccounts/listKeys/action",
        "Microsoft.DBforPostgreSQL/servers/delete",
        "Microsoft.DBforMySQL/servers/delete"
    )
    | extend
        Actor            = Caller,
        ResourceAccessed = Resource,
        Operation        = OperationNameValue,
        Location         = strcat("Database - ",
            ResourceGroup, " - ", Resource),
        Source           = "Database",
        RiskWeight       = case(
            OperationNameValue has "export",        9,
            OperationNameValue has "delete",        9,
            OperationNameValue has "listKeys",      8,
            OperationNameValue has "firewallRules", 7, 5)
    | project TimeGenerated, Actor, ResourceAccessed,
        Operation, Location, Source, RiskWeight, CallerIpAddress;
let ComputeAccess =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.Compute/snapshots/write",
        "Microsoft.Compute/disks/write",
        "Microsoft.Compute/disks/beginGetAccess/action",
        "Microsoft.Compute/virtualMachines/capture/action",
        "Microsoft.Compute/virtualMachines/delete",
        "Microsoft.Compute/snapshots/beginGetAccess/action"
    )
    | extend
        Actor            = Caller,
        ResourceAccessed = Resource,
        Operation        = OperationNameValue,
        Location         = strcat("Compute - ",
            ResourceGroup, " - ", Resource),
        Source           = "Compute/Snapshot",
        RiskWeight       = case(
            OperationNameValue has "beginGetAccess",  9,
            OperationNameValue has "capture",         9,
            OperationNameValue has "snapshots/write", 8,
            OperationNameValue has "delete",          8, 5)
    | project TimeGenerated, Actor, ResourceAccessed,
        Operation, Location, Source, RiskWeight, CallerIpAddress;
let ManagementOps =
    AzureActivity
    | where TimeGenerated > timeWindow
    | where Caller in~ (watchlist)
    | where OperationNameValue has_any (
        "Microsoft.Resources/subscriptions/resourceGroups/delete",
        "Microsoft.Resources/subscriptions/resourceGroups/write",
        "Microsoft.Resources/deployments/write",
        "Microsoft.Management/managementGroups/write",
        "Microsoft.Billing/billingAccounts/write"
    )
    | extend
        Actor            = Caller,
        ResourceAccessed = Resource,
        Operation        = OperationNameValue,
        Location         = strcat("Management - ", ResourceGroup),
        Source           = "Resource Management",
        RiskWeight       = case(
            OperationNameValue has "delete", 10,
            OperationNameValue has "write",   7, 5)
    | project TimeGenerated, Actor, ResourceAccessed,
        Operation, Location, Source, RiskWeight, CallerIpAddress;
union KeyVaultAccess, StorageAccess, DatabaseAccess,
    ComputeAccess, ManagementOps
| summarize
    TotalEvents       = count(),
    KeyVaultEvents    = countif(Source == "Key Vault"),
    StorageEvents     = countif(Source == "Storage Account"),
    DatabaseEvents    = countif(Source == "Database"),
    ComputeEvents     = countif(Source == "Compute/Snapshot"),
    ManagementEvents  = countif(Source == "Resource Management"),
    ResourcesAccessed = make_set(ResourceAccessed, 20),
    OperationsList    = make_set(Operation, 20),
    Sources           = make_set(Source),
    IPAddresses       = make_set(CallerIpAddress),
    WeightedScore     = sum(RiskWeight),
    FirstSeen         = min(TimeGenerated),
    LastSeen          = max(TimeGenerated)
    by Actor
| extend RiskLevel = case(
    ManagementEvents > 0
        and WeightedScore >= 15, "Critical - Resource Deletion Detected",
    DatabaseEvents > 0
        and StorageEvents > 0,   "Critical - Multi-Source Data Access",
    KeyVaultEvents > 0
        and StorageEvents > 0,   "Critical - Credential + Storage Access",
    WeightedScore >= 20,         "Critical - High Volume Sensitive Access",
    WeightedScore >= 12,         "High - Multiple Sensitive Operations",
    WeightedScore >= 6,          "High - Sensitive Resource Accessed",
    "Medium - Azure Resource Activity"
)
| project Actor, TotalEvents, KeyVaultEvents, StorageEvents,
    DatabaseEvents, ComputeEvents, ManagementEvents,
    ResourcesAccessed, OperationsList, Sources, IPAddresses,
    WeightedScore, RiskLevel, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 15 mins | MITRE: T1530 — Data from Cloud Storage | Severity: High

Rule 8 — Backdoor App Registration Detected

Five parts: new app registrations, credential additions to existing apps, new service principals, OAuth permission grants, and federated identity credentials. Credential additions and federated identity creds both score 10 — they enable non-interactive auth that survives account termination. Alert grouping is disabled; every event gets individual review.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(15m);
let NewAppRegistration =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add application","Add application.","Update application")
    | extend
        Actor        = Initiator,
        TargetApp    = tostring(TargetResources[0].displayName),
        TargetAppId  = tostring(TargetResources[0].id),
        ActionDetail = OperationName,
        InitiatorIP  = tostring(InitiatedBy.user.ipAddress),
        Source       = "New App Registration",
        RiskWeight   = 8
    | project TimeGenerated, Actor, TargetApp, TargetAppId,
        ActionDetail, InitiatorIP, Source, RiskWeight;
let CredentialsAdded =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Update application – Certificates and secrets management",
        "Add service principal credentials",
        "Update service principal",
        "Add service principal credentials.",
        "Restore application",
        "Add key credentials to service principal",
        "Add password credentials to service principal"
    )
    | extend
        Actor        = Initiator,
        TargetApp    = tostring(TargetResources[0].displayName),
        TargetAppId  = tostring(TargetResources[0].id),
        ActionDetail = OperationName,
        InitiatorIP  = tostring(InitiatedBy.user.ipAddress),
        Source       = "Credentials Added to App",
        RiskWeight   = 10
    | project TimeGenerated, Actor, TargetApp, TargetAppId,
        ActionDetail, InitiatorIP, Source, RiskWeight;
let NewServicePrincipal =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add service principal","Add service principal.",
        "Update service principal.")
    | extend
        Actor        = Initiator,
        TargetApp    = tostring(TargetResources[0].displayName),
        TargetAppId  = tostring(TargetResources[0].id),
        ActionDetail = OperationName,
        InitiatorIP  = tostring(InitiatedBy.user.ipAddress),
        Source       = "New Service Principal",
        RiskWeight   = 9
    | project TimeGenerated, Actor, TargetApp, TargetAppId,
        ActionDetail, InitiatorIP, Source, RiskWeight;
let OAuthGrants =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add OAuth2PermissionGrant","Add OAuth2PermissionGrant.",
        "Add delegated permission grant",
        "Consent to application",
        "Add app role assignment to service principal",
        "Add app role assignment grant to user"
    )
    | extend
        Actor        = Initiator,
        TargetApp    = tostring(TargetResources[0].displayName),
        TargetAppId  = tostring(TargetResources[0].id),
        ActionDetail = OperationName,
        InitiatorIP  = tostring(InitiatedBy.user.ipAddress),
        Source       = "OAuth Permission Grant",
        RiskWeight   = 8
    | project TimeGenerated, Actor, TargetApp, TargetAppId,
        ActionDetail, InitiatorIP, Source, RiskWeight;
let FederatedCredentials =
    AuditLogs
    | where TimeGenerated > timeWindow
    | extend Initiator = tostring(InitiatedBy.user.userPrincipalName)
    | where Initiator in~ (watchlist)
    | where OperationName in (
        "Add federated identity credential",
        "Update federated identity credential",
        "Add federated identity credential."
    )
    | extend
        Actor        = Initiator,
        TargetApp    = tostring(TargetResources[0].displayName),
        TargetAppId  = tostring(TargetResources[0].id),
        ActionDetail = OperationName,
        InitiatorIP  = tostring(InitiatedBy.user.ipAddress),
        Source       = "Federated Identity Credential",
        RiskWeight   = 10
    | project TimeGenerated, Actor, TargetApp, TargetAppId,
        ActionDetail, InitiatorIP, Source, RiskWeight;
union NewAppRegistration, CredentialsAdded,
    NewServicePrincipal, OAuthGrants, FederatedCredentials
| summarize
    TotalEvents          = count(),
    NewApps              = countif(Source == "New App Registration"),
    CredentialAdditions  = countif(Source == "Credentials Added to App"),
    NewServicePrincipals = countif(Source == "New Service Principal"),
    OAuthPermissions     = countif(Source == "OAuth Permission Grant"),
    FederatedCreds       = countif(Source == "Federated Identity Credential"),
    AffectedApps         = make_set(TargetApp, 20),
    AffectedAppIds       = make_set(TargetAppId, 20),
    ActionsTaken         = make_set(ActionDetail, 20),
    Sources              = make_set(Source),
    IPAddresses          = make_set(InitiatorIP),
    WeightedScore        = sum(RiskWeight),
    FirstSeen            = min(TimeGenerated),
    LastSeen             = max(TimeGenerated)
    by Actor
| extend RiskLevel = case(
    FederatedCreds > 0,
        "Critical - Federated Identity Backdoor",
    CredentialAdditions > 0 and NewApps > 0,
        "Critical - New App with Credentials",
    CredentialAdditions > 0 and NewServicePrincipals > 0,
        "Critical - New SP with Credentials",
    CredentialAdditions > 0,
        "Critical - Credentials Added to App",
    NewServicePrincipals > 0 and OAuthPermissions > 0,
        "Critical - SP with OAuth Grants",
    WeightedScore >= 15,
        "Critical - Multiple Backdoor Indicators",
    WeightedScore >= 9,
        "High - App Registration Activity",
    "High - Suspicious App Activity"
)
| project Actor, TotalEvents, NewApps, CredentialAdditions,
    NewServicePrincipals, OAuthPermissions, FederatedCreds,
    AffectedApps, AffectedAppIds, ActionsTaken, Sources,
    IPAddresses, WeightedScore, RiskLevel, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 15 mins | MITRE: TA0003 — Persistence | Severity: Critical — Alert grouping disabled, every event reviewed individually

Rule 9 — Mass Email Send with Attachments

Three sources: OfficeActivity Exchange Send events, Defender EmailEvents for attachment metadata, and EmailAttachmentInfo for file type detection. Sensitive file to personal domain scores 10. Fires on any single match — no volume threshold required.

let watchlist = materialize(
    _GetWatchlist('HighRiskDepartures') | project UPN);
let timeWindow = ago(30m);
let internalDomain = "contoso.com";
let internalDomainAlt = "contoso.onmicrosoft.com";
let personalDomains = dynamic([
    "gmail.com","yahoo.com","hotmail.com",
    "outlook.com","protonmail.com","icloud.com",
    "mail.com","aol.com","yandex.com",
    "tutanota.com","zoho.com"
]);
let BulkEmailSend =
    OfficeActivity
    | where TimeGenerated > timeWindow
    | where UserId in~ (watchlist)
    | where OfficeWorkload == "Exchange"
    | where Operation == "Send"
    | extend
        RecipientList   = tostring(Parameters),
        HasAttachment   = iff(tostring(Parameters) has_any (
            "Attachment","HasAttachment","AttachmentCount"),
            true, false),
        RecipientDomain = tostring(split(
            tostring(Parameters),"@")[1])
    | extend
        IsPersonal = iff(RecipientDomain in (personalDomains),
            true, false),
        IsExternal = iff(
            tostring(Parameters) !has internalDomain
            and tostring(Parameters) !has internalDomainAlt,
            true, false)
    | extend
        Actor      = UserId,
        Source     = "Exchange Send",
        RiskWeight = case(
            IsPersonal == true and HasAttachment == true, 8,
            IsPersonal == true,                           6,
            IsExternal == true and HasAttachment == true, 5,
            HasAttachment == true,                        3, 2)
    | project TimeGenerated, Actor, RecipientList,
        HasAttachment, RecipientDomain, IsPersonal,
        IsExternal, Source, RiskWeight, ClientIP;
let DefenderEmailEvents =
    EmailEvents
    | where TimeGenerated > timeWindow
    | where SenderFromAddress in~ (watchlist)
    | where EmailDirection == "Outbound"
    | extend
        RecipientDomain = tostring(split(
            RecipientEmailAddress,"@")[1]),
        HasAttachment   = iff(AttachmentCount > 0, true, false)
    | extend
        IsPersonal = iff(RecipientDomain in (personalDomains),
            true, false),
        IsExternal = iff(
            RecipientDomain !~ internalDomain
            and RecipientDomain !~ internalDomainAlt,
            true, false)
    | extend
        Actor      = SenderFromAddress,
        Source     = "Defender Email Events",
        RiskWeight = case(
            IsPersonal == true and AttachmentCount > 0, 8,
            IsPersonal == true,                         6,
            IsExternal == true and AttachmentCount > 0, 5,
            AttachmentCount > 0,                        3, 2)
    | project TimeGenerated, Actor,
        RecipientList = RecipientEmailAddress,
        HasAttachment, RecipientDomain, IsPersonal,
        IsExternal, Source, RiskWeight,
        ClientIP = tostring(SenderIPv4);
let SensitiveAttachments =
    EmailAttachmentInfo
    | where TimeGenerated > timeWindow
    | where SenderFromAddress in~ (watchlist)
    | where FileName has_any (
        ".docx",".xlsx",".pptx",".pdf",
        ".csv",".sql",".bak",".zip",
        ".rar",".7z",".tar",".gz",
        ".pst",".ost",".db",".mdb",
        ".key",".pem",".pfx",".p12",
        ".py",".ps1",".sh",".json",
        ".xml",".conf",".config",".env"
    )
    | extend
        RecipientDomain = tostring(split(
            RecipientEmailAddress,"@")[1])
    | extend
        IsPersonal    = iff(RecipientDomain in (personalDomains),
            true, false),
        IsExternal    = iff(
            RecipientDomain !~ internalDomain
            and RecipientDomain !~ internalDomainAlt,
            true, false),
        HasAttachment = true
    | extend
        Actor      = SenderFromAddress,
        Source     = "Sensitive Attachment",
        RiskWeight = case(
            IsPersonal == true, 10,
            IsExternal == true,  7, 3)
    | project TimeGenerated, Actor,
        RecipientList = RecipientEmailAddress,
        HasAttachment, RecipientDomain, IsPersonal,
        IsExternal, Source, RiskWeight, ClientIP = "";
union BulkEmailSend, DefenderEmailEvents, SensitiveAttachments
| summarize
    TotalEmails              = count(),
    EmailsWithAttachments    = countif(HasAttachment == true),
    PersonalDomainEmails     = countif(IsPersonal == true),
    ExternalEmails           = countif(IsExternal == true),
    SensitiveAttachmentCount = countif(Source == "Sensitive Attachment"),
    UniqueRecipients         = dcount(RecipientList),
    RecipientList            = make_set(RecipientList, 20),
    RecipientDomains         = make_set(RecipientDomain, 20),
    Sources                  = make_set(Source),
    WeightedScore            = sum(RiskWeight),
    FirstSeen                = min(TimeGenerated),
    LastSeen                 = max(TimeGenerated)
    by Actor, ClientIP
| extend RiskLevel = case(
    SensitiveAttachmentCount > 0
        and PersonalDomainEmails > 0,
        "Critical - Sensitive Files to Personal Email",
    SensitiveAttachmentCount > 0
        and ExternalEmails > 0,
        "Critical - Sensitive Files Sent Externally",
    PersonalDomainEmails > 5,
        "Critical - Mass Send to Personal Domains",
    WeightedScore >= 30,
        "Critical - High Volume Suspicious Email",
    PersonalDomainEmails > 0
        and EmailsWithAttachments > 0,
        "High - Attachments Sent to Personal Email",
    WeightedScore >= 15,
        "High - Suspicious Email Pattern Detected",
    EmailsWithAttachments > 20,
        "High - Bulk Email with Attachments",
    "Medium - Elevated Email Activity"
)
| where WeightedScore >= 5
    or PersonalDomainEmails > 0
    or SensitiveAttachmentCount > 0
| project Actor, TotalEmails, EmailsWithAttachments,
    PersonalDomainEmails, ExternalEmails,
    SensitiveAttachmentCount, UniqueRecipients,
    RecipientList, RecipientDomains, Sources,
    WeightedScore, RiskLevel, ClientIP, FirstSeen, LastSeen
| order by WeightedScore desc

Run every 30 mins | MITRE: T1114 — Email Collection | Severity: High

Rule 10 — After Hours Activity Detected

Flags sign-ins and Azure activity outside normal working hours (07:00–20:00). Covers a behavioural detection gap the other rules don't address. Behavioural baseline: occasional late activity is normal for IT staff. The same user active every night during their final week is a different pattern. Adjust the hour thresholds to match your organisation's actual working hours before deploying.

let watchlist = _GetWatchlist('HighRiskDepartures') | project UPN;
union
(
    SigninLogs
    | where TimeGenerated > ago(1h)
    | where UserPrincipalName in~ (watchlist)
    | where ResultType == 0
    | extend HourOfDay = datetime_part("hour", TimeGenerated)
    | where HourOfDay < 7 or HourOfDay > 20
    | extend
        Actor    = UserPrincipalName,
        Activity = AppDisplayName,
        Source   = "SignIn"
),
(
    AzureActivity
    | where TimeGenerated > ago(1h)
    | where Caller in~ (watchlist)
    | extend HourOfDay = datetime_part("hour", TimeGenerated)
    | where HourOfDay < 7 or HourOfDay > 20
    | extend
        Actor    = Caller,
        Activity = OperationNameValue,
        Source   = "Azure"
)
| summarize
    EventCount  = count(),
    Activities  = make_set(Activity, 10),
    Sources     = make_set(Source),
    HoursActive = make_set(datetime_part("hour", TimeGenerated)),
    FirstSeen   = min(TimeGenerated),
    LastSeen    = max(TimeGenerated)
    by Actor
| where EventCount > 3
| extend RiskLevel = case(
    EventCount > 20, "High - Sustained After Hours Activity",
    EventCount > 10, "Medium - Elevated After Hours Activity",
    "Low - After Hours Activity Detected"
)
| project Actor, EventCount, Activities,
    Sources, HoursActive, RiskLevel, FirstSeen, LastSeen
| order by EventCount desc

Run every 1 hour | MITRE: T1078 — Valid Accounts | Severity: Medium


Coverage Map

Exfiltration Path                           Rules
─────────────────────────────────────────────────────────
Cloud storage — SharePoint, OneDrive        Rule 1, Rule 3
Email exfiltration                          Rule 2, Rule 9
Physical exfiltration via USB               Rule 5
Azure credential theft                      Rule 7
Identity backdoor / persistence             Rule 6, Rule 8
Behavioural anomalies                       Rule 4, Rule 10

Alert Fusion

With ten rules running, a single active user generates alerts across multiple activity types simultaneously. Entity-based grouping consolidates them into one incident per user.

In each rule's Incident settings tab, configure:

Entity grouping:    Enabled
Group by:           Account
Grouping window:    8 hours
Reopen closed:      Enabled

Exception: Rule 8 has grouping disabled. App registration and credential events need immediate individual review — consolidation buries the most critical persistence indicators.


Monitoring Workbook

Five tiles built in Sentinel → Workbooks → + New for daily analyst review:

  • Sign-in timeline — authentication activity hour by hour
  • File activity volume — daily file operation count across SharePoint, OneDrive, and endpoint
  • Active alerts table — live view of all open Sentinel incidents for the monitored user
  • Geographic sign-in map — login locations, new countries immediately visible
  • Top accessed files — specific files touched most frequently in the monitoring window

RBAC Isolation

IT staff often have Azure or Sentinel access. Without this step, a monitored user can see the watchlist, the rules, and their own active alerts.

Sentinel RBAC

Remove all Sentinel role assignments from the monitored user. Any role granting read access to Analytics or Watchlists needs to go. The monitored user should have zero Sentinel role assignments during the monitoring period.

Log Analytics Table-Level RBAC

If the user has Log Analytics workspace access, restrict table-level permissions to block queries against SecurityAlert, SecurityIncident, and the watchlist tables. Workspace access bypasses Sentinel UI restrictions entirely.


Performance Considerations

Several rules run heavy operations — multi-source unions, joins against SigninLogs, and make_set aggregations across large result sets. In large tenants these add up.

Use materialize() on watchlist calls. Rules 4–9 already do this. Rules 1–3 don't — fine for small watchlists, worth adding if you're monitoring many users at once.

Rule 4 is the most expensive. The 60-day baseline join on SigninLogs touches a large table twice. If query timeouts occur, reduce the lookback window to 30 days or increase the rule's run frequency buffer. Alternatively, pre-compute the baseline into a separate summary table on a daily schedule.

Watchlist scale limit. Sentinel watchlists cap at 10,000 rows. Not a concern for routine offboarding, but if you're running cohort-scale monitoring, split by department or region.

Analytics rule execution limits. Sentinel scheduled rules have a query execution timeout. Complex joins across large SigninLogs tables — particularly Rule 4's 60-day baseline join — can hit this silently: the rule runs, finds no results, and logs no error. Monitor rule run history in Sentinel → Analytics → Rule name → Run history in the first week after deployment. If you see consistent empty results with no failures, a timeout is the likely cause — reduce the lookback window or pre-compute the baseline.

Alert volume. A high-activity user can generate significant alert volume before the 8-hour grouping window consolidates it. Watch incident counts in the first 48 hours and tune thresholds if needed.

Operational Notes

ISO 8601 for the watchlist date or nothing. todatetime() in KQL silently returns null on any other format. Short dates, regional formats, Excel auto-formatting — all break the date-aware filtering with no error message.

Replace the domain placeholders in Rules 3 and 9 with your actual tenant domains. The placeholders flag all emails as external.

Remove users from the watchlist once revocation is confirmed. Stale entries degrade signal quality for active users.

The 8-hour correlation window is a starting point. Shorten for high-activity users, widen for longer monitoring periods.

Get documented authorisation before the watchlist goes live. Written sign-off from HR and Legal is required before any name is added. Your employment law, works council obligations, and data protection framework determine what monitoring is permissible — get legal review specific to your jurisdiction, not a generic assumption that it's fine.

Most offboarding employees don't do anything wrong. The goal isn't to catch everyone — it's to have clear visibility and a documented evidence trail if something does happen, and to know within hours rather than weeks.

This article reflects generalized detection engineering patterns and does not represent any specific organisation's internal security architecture. All identifying details have been anonymized and technical configurations generalized.