Fixing the "New User Assigned to Privileged Role" Analytic Rule

Fixing the "New User Assigned to Privileged Role" Analytic Rule

Alright class.

Third lesson in this series. We have already fixed "MFA Rejected by User" and "Privileged Role Assigned Outside PIM". Today we are taking apart the "New User Assigned to Privileged Role" rule from the Sentinel content hub.

This one is more interesting than the previous two, because the core idea is actually good. The rule queries the last hour of role assignments, queries the previous 14 days, and runs a left anti-join so it only fires when a target and role combination has never been seen before. That is real detection logic, not just a filter on an operation name. The problem is everything around it.

Small aside before we start. The reusable function in the original template is called awsFunc. This is an Entra ID role assignment rule. There is no AWS anywhere near it. Someone at Microsoft copied this from an AWS rule years ago, renamed nothing, and it has shipped to thousands of tenants like that ever since. Read vendor content critically. It is a starting point, never a finished product.

Here is what changed and why.

The contains "Admin" Problem

The original rule decides what counts as privileged with this line:

| where RoleName contains "Admin" and Result == "success"

This is the worst line in the query, and it fails in both directions.

It misses roles that do not have "Admin" in the name. Partner Tier2 Support is the famous one: it carries rights equivalent to Global Administrator, it has been abused in real intrusions, and the string "Admin" appears nowhere in it. Same story for Partner Tier1 Support, Directory Writers, Global Reader, Security Operator, and Security Reader. An attacker who knows your detection is doing substring matching on "Admin" will pick exactly these roles.

It also matches roles you almost certainly do not want an hourly alert for, because every minor "Administrator" role in the directory passes the filter with the same severity as Global Administrator.

The rewrite uses the same explicit PrivilegedRoles list approach as the previous post, extended with the roles that substring matching misses. A separate CriticalRoles list drives the severity signal. Same advice as last time: the list is a conscious decision about what you protect, so tune it for your environment.

Eligible Is Not Active

The rule matches both "Add member to role" and "Add eligible member to role", and the original treats them identically in the output. They are not the same event.

An eligible assignment means the user can activate the role through PIM later, with whatever approval and MFA requirements you have configured. An active assignment means the user holds the privilege right now, permanently, with no activation step. If an attacker has compromised an account that can assign roles, they assign active, because eligible still leaves PIM in the way.

The rewrite extends an AssignmentType field from AADOperationType and raises an ActiveAssignment risk indicator when the assignment is direct. Your analyst sees the difference in the incident without opening the raw event.

Unmasking MS-PIM

The original ships with a commented-out filter for excluding MS-PIM as the initiator. For the "Outside PIM" rule we covered last time, filtering PIM made sense because the entire point was the channel. This rule is different. A brand new privileged assignment is interesting regardless of whether it went through PIM, so we keep those events.

That creates a readability problem. When an admin assigns a role through the PIM blade, the event that matches our filter is initiated by the MS-PIM service principal. Your analyst gets an incident where the initiator is "MS-PIM", which tells them nothing about which human did it.

The fix is in the audit trail itself. The PIM assignment flow writes a separate request event in the same CorrelationId, operation names along the lines of "Add member to role requested (PIM admin assignment)", and that event carries the real requester in InitiatedBy.user. The rewrite builds a small PimRequesters lookup keyed on CorrelationId and swaps the requester in whenever the initiator is MS-PIM. The analyst sees the human, the baseline counts the human, and the entity mapping points at the human.

The Initiator Baseline and IdentityInfo

Both patterns carry over from the previous post, so I will keep this short.

The InitiatorHistory subquery counts how many privileged role assignments each UPN has made in the last 14 days, giving you IsFirstTimeInitiator and HistoricalAssignmentCount. Your IAM team lead assigning their twentieth role this month reads very differently to a UPN that has never assigned anything suddenly handing out Exchange Administrator.

IdentityInfo enrichment pulls department, job title, manager, current roles, MFA registration, service account flag, enabled state, and entity risk score for both the target and the initiator.

The new addition for this rule is AccountCreationTime from IdentityInfo. The classic attack chain this detection should catch is create account, then assign role. If the target account was created in the last 7 days, the rewrite raises a RecentlyCreatedAccount risk indicator and surfaces TargetAccountAgeDays. A three-day-old account receiving Privileged Authentication Administrator is not a ticket; it is an incident (well, or a new starter perhaps, either way - good to know)

The RiskIndicators Field

Same structured string as last time, mapped as a custom detail so it lands directly in the incident. Seven signals this time:

CriticalRoleAssigned | ActiveAssignment | FirstTimeInitiator | RecentlyCreatedAccount | TargetIsServiceAccount | TargetIsServicePrincipal | NoIPAddressLogged

A row carrying CriticalRoleAssigned, ActiveAssignment, and RecentlyCreatedAccount together is the strongest signal this rule can produce. That combination is what the whole rewrite exists to surface in the first line of the incident.

And to be clear about what is not here: there is no "confirm with the admin whether this was expected" step. That is an audit process, not a detection. The rule's job is to give the analyst enough context to make the call themselves from the incident panel.

The Blind Spot You Should Know About

One honest limitation. This rule watches direct role assignments. It does not see membership changes to role-assignable groups. If a privileged role is assigned to a group, adding a user to that group grants the privilege through a completely different operation, "Add member to group", which never touches this query. If you use role-assignable groups, you need a companion detection on group membership changes scoped to those group IDs. That is a post of its own, but do not deploy this rewrite and assume your privileged assignment coverage is complete.

MITRE Mappings for the Updated Rule

Tactics: PrivilegeEscalation and Persistence. A permanent role assignment is a persistence mechanism as much as an escalation.

T1098 Account Manipulation, sub-technique T1098.003 Additional Cloud Roles. The most precise mapping, this is literally the event.

T1078 Valid Accounts, sub-technique T1078.004 Cloud Accounts. A legitimate cloud account doing the assigning is the scenario the initiator baseline exists for.

T1136 Create Account, sub-technique T1136.003 Cloud Account. The RecentlyCreatedAccount indicator maps directly to the create-then-elevate chain.

Rule Settings

Run every 60 minutes with a query period of 14 days. The anti-join needs the full window, and 14 days is the maximum lookback a scheduled rule allows, which is exactly why the original was built around it. High severity. Alert per result. Group by Account entity with a 2 hour lookback window.

Entity mapping:

  • InitiatedByName and InitiatedByUPNSuffix to Account (initiator)
  • TargetName and TargetUPNSuffix to Account (target)
  • InitiatingIpAddress to IP

Custom details to surface in the incident: AssignedRoleName, AssignmentType, RiskIndicators, IsFirstTimeInitiator, HistoricalAssignmentCount, TargetAccountAgeDays, TargetAccountEnabled, TargetIsServiceAccount.

KQL

// =====================================================================
// First Privileged Role Assignment - New Target or Initiator
// =====================================================================
// Description : Detects the first time a user or service principal receives a privileged
//               Entra ID role assignment, using a 14-day anti-join baseline to suppress
//               known pairs, with risk indicator enrichment and identity context.
// Type        : Detection
//
// Tables      : AuditLogs, IdentityInfo
// Connectors  : Microsoft Entra ID (Diagnostic Settings - AuditLogs), Microsoft Sentinel UEBA (IdentityInfo)
// License     : Microsoft Sentinel (Log Analytics workspace) + Microsoft Entra ID P2 (UEBA / IdentityInfo)
//
// Tuning      : - PrivilegedRoles list - controls which role names trigger the rule; add/remove per your Tier-1 definition
//               - CriticalRoles list - subset that sets CriticalRoleAssigned flag; align with your Tier-0 boundary
//               - LookbackWindow variables (ago(14d), ago(1h), ago(2h)) - adjust baseline and detection windows
//               - TargetAccountAgeDays < 7 threshold - lower to catch older accounts, raise to reduce noise
//               - IsFirstTimeInitiator logic - remove if first-time initiator signal produces excessive FPs
//
// Known FPs   : - Legitimate role grants by IAM/IT teams during provisioning or offboarding - validate against change tickets
//               - Break-glass or emergency access account activations - exclude known break-glass UPNs
//               - MS-PIM initiated automated assignments - handled by PimRequesters join; verify if Initiator still shows MS-PIM
//               - Automated provisioning tools or HR-sync apps granting roles as service principals - baseline clears over 14 days
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-06-14
// =====================================================================
let PrivilegedRoles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Partner Tier1 Support",
"Partner Tier2 Support",
"Security Administrator",
"Exchange Administrator",
"SharePoint Administrator",
"User Administrator",
"Authentication Administrator",
"Conditional Access Administrator",
"Application Administrator",
"Cloud Application Administrator",
"Hybrid Identity Administrator",
"Intune Administrator",
"Password Administrator",
"Directory Writers",
"Global Reader",
"Security Operator",
"Security Reader"
]);
let CriticalRoles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Partner Tier2 Support"
]);
// Reusable parser for successful privileged role assignments
let RoleAssignments = (start:datetime, end:datetime) {
    AuditLogs
    | where TimeGenerated between (start .. end)
    | where Category =~ "RoleManagement"
    | where AADOperationType in ("Assign", "AssignEligibleRole")
    | where ActivityDisplayName has_any ("Add eligible member to role", "Add member to role")
    | where ResultType =~ "success" or Result =~ "success"
    | mv-apply TargetResource = TargetResources on (
        where TargetResource.type in~ ("User", "ServicePrincipal")
        | extend
            TargetId = tostring(TargetResource.id),
            TargetType = tostring(TargetResource.type),
            Target = iff(TargetResource.type =~ "ServicePrincipal", tostring(TargetResource.displayName), tostring(TargetResource.userPrincipalName)),
            props = TargetResource.modifiedProperties
    )
    | mv-apply Property = props on (
        where Property.displayName =~ "Role.DisplayName"
        | extend AssignedRoleName = trim('"', tostring(Property.newValue))
    )
    | where AssignedRoleName in~ (PrivilegedRoles)
};
// Historical leg: only the columns the anti-join needs
let HistoricalAssignments = RoleAssignments(ago(14d), ago(1h))
    | distinct TargetId, AssignedRoleName;
// 14-day baseline of who assigns privileged roles
let InitiatorHistory = AuditLogs
    | where TimeGenerated > ago(14d)
    | where Category =~ "RoleManagement"
    | where AADOperationType in ("Assign", "AssignEligibleRole")
    | where ActivityDisplayName has_any ("Add eligible member to role", "Add member to role")
    | extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
    | where isnotempty(InitiatingUserPrincipalName)
    | summarize HistoricalAssignmentCount = count(), FirstSeenAssigning = min(TimeGenerated), LastSeenAssigning = max(TimeGenerated) by InitiatingUserPrincipalName;
let IdentityContext = IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated, *) by AccountUPN
    | project AccountUPN, AccountCreationTime, Department, JobTitle, Manager, AssignedRoles, IsMFARegistered, IsAccountEnabled, IsServiceAccount, EntityRiskScore, UserType;
// Recover the human requester behind MS-PIM initiated assignments
let PimRequesters = AuditLogs
    | where TimeGenerated > ago(2h)
    | where Category =~ "RoleManagement"
    | where OperationName has "PIM" and OperationName has "requested"
    | extend RequestedBy = tostring(InitiatedBy.user.userPrincipalName)
    | where isnotempty(RequestedBy) and isnotempty(CorrelationId)
    | distinct CorrelationId, RequestedBy;
RoleAssignments(ago(1h), now())
| join kind=leftanti HistoricalAssignments on TargetId, AssignedRoleName
| extend AssignmentType = iff(AADOperationType =~ "AssignEligibleRole", "Eligible", "Active")
| extend InitiatingAppName = tostring(InitiatedBy.app.displayName)
| extend InitiatingAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend InitiatingAadUserId = tostring(InitiatedBy.user.id)
| extend InitiatingIpAddress = tostring(iff(isnotempty(InitiatedBy.user.ipAddress), InitiatedBy.user.ipAddress, InitiatedBy.app.ipAddress))
| extend Initiator = iif(isnotempty(InitiatingAppName), InitiatingAppName, InitiatingUserPrincipalName)
| join kind=leftouter PimRequesters on CorrelationId
| extend Initiator = iff(Initiator in ("MS-PIM", "MS-PIM-Fairfax") and isnotempty(RequestedBy), RequestedBy, Initiator)
| extend InitiatingUserPrincipalName = iff(isempty(InitiatingUserPrincipalName) and isnotempty(RequestedBy), RequestedBy, InitiatingUserPrincipalName)
| join kind=leftouter InitiatorHistory on InitiatingUserPrincipalName
| extend IsFirstTimeInitiator = isnull(HistoricalAssignmentCount) or HistoricalAssignmentCount == 0
| extend HistoricalAssignmentCount = coalesce(HistoricalAssignmentCount, 0)
| join kind=leftouter IdentityContext on $left.Target == $right.AccountUPN
| extend TargetAccountAgeDays = datetime_diff('day', now(), AccountCreationTime)
| extend TargetDepartment = Department
| extend TargetJobTitle = JobTitle
| extend TargetManager = Manager
| extend TargetCurrentRoles = tostring(AssignedRoles)
| extend TargetMFARegistered = IsMFARegistered
| extend TargetIsServiceAccount = IsServiceAccount
| extend TargetAccountEnabled = IsAccountEnabled
| extend TargetEntityRiskScore = tostring(EntityRiskScore)
| extend TargetUserType = UserType
| join kind=leftouter (
    IdentityContext
    | project InitiatorAccountUPN = AccountUPN, InitiatorDepartment = Department, InitiatorJobTitle = JobTitle, InitiatorCurrentRoles = tostring(AssignedRoles), InitiatorMFARegistered = IsMFARegistered, InitiatorEntityRiskScore = tostring(EntityRiskScore)
) on $left.InitiatingUserPrincipalName == $right.InitiatorAccountUPN
| extend TargetName = tostring(split(Target, '@', 0)[0])
| extend TargetUPNSuffix = tostring(split(Target, '@', 1)[0])
| extend InitiatedByName = tostring(split(InitiatingUserPrincipalName, '@', 0)[0])
| extend InitiatedByUPNSuffix = tostring(split(InitiatingUserPrincipalName, '@', 1)[0])
| extend Risk_1 = iff(AssignedRoleName in~ (CriticalRoles), "CriticalRoleAssigned", "")
| extend Risk_2 = iff(AssignmentType == "Active", "ActiveAssignment", "")
| extend Risk_3 = iff(IsFirstTimeInitiator, "FirstTimeInitiator", "")
| extend Risk_4 = iff(TargetAccountAgeDays < 7, "RecentlyCreatedAccount", "")
| extend Risk_5 = iff(TargetIsServiceAccount == true, "TargetIsServiceAccount", "")
| extend Risk_6 = iff(TargetType =~ "ServicePrincipal", "TargetIsServicePrincipal", "")
| extend Risk_7 = iff(isempty(InitiatingIpAddress), "NoIPAddressLogged", "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
    iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
    iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
    iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
    iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
    iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
    iff(isnotempty(Risk_6), strcat(Risk_6, " | "), ""),
    iff(isnotempty(Risk_7), strcat(Risk_7, " | "), "")
))
| project
    TimeGenerated, ActivityDateTime, OperationName, AADOperationType,
    AssignedRoleName, AssignmentType,
    Target, TargetId, TargetType, TargetName, TargetUPNSuffix,
    TargetDepartment, TargetJobTitle, TargetManager, TargetCurrentRoles,
    TargetMFARegistered, TargetIsServiceAccount, TargetAccountEnabled,
    TargetEntityRiskScore, TargetUserType, TargetAccountAgeDays,
    Initiator, InitiatingUserPrincipalName, InitiatedByName, InitiatedByUPNSuffix,
    InitiatingIpAddress, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingAadUserId,
    InitiatorDepartment, InitiatorJobTitle, InitiatorCurrentRoles,
    InitiatorMFARegistered, InitiatorEntityRiskScore,
    IsFirstTimeInitiator, HistoricalAssignmentCount, FirstSeenAssigning, LastSeenAssigning,
    RiskIndicators, CorrelationId, LoggedByService, Result
| sort by TimeGenerated desc

You can also download this as an analytic rule and import it directly to Sentinel

Follow my repo - GitHub

What You Should Do Next

  1. Review both role lists. Add or remove roles to PrivilegedRoles based on what you actually consider privileged, and make sure CriticalRoles reflects your genuine Tier-0. If you removed Partner Tier2 Support because you have never heard of it, go read about it first.
  2. Run the query manually over the last 14 days before deploying. The anti-join means your first scheduled run will only fire on genuinely unseen pairs, but a manual run tells you how many assignments per week your environment produces and whether the role list is scoped sensibly.
  3. Check whether AccountCreationTime is populated in your IdentityInfo table. It requires UEBA to be enabled. If it is empty, the RecentlyCreatedAccount indicator silently never fires, and you should know that rather than assume coverage you do not have.
  4. If you use role-assignable groups, build the companion detection on membership changes to those groups (or wait for me to do it . Until you do, this rule covers direct assignments only.

Class dismissed

Consent Preferences