Fixing the "Privileged Role Assigned Outside PIM" Analytic Rule

Fixing the "Privileged Role Assigned Outside PIM" Analytic Rule

Alright class.

There is a good chance you are using the original "Privileged Role Assigned Outside PIM" rule in the Microsoft Sentinel content hub that has been around since 2021. It does one thing: fires when someone adds a user to a role without going through PIM. That is fine as a starting point. The problem is that it stops there. You get an alert, two field names, no context about who did it, no signal about whether this is unusual, and no indication of severity. Every analyst who picks it up starts from zero.

The rewrite fixes that. Here is what changed and why each decision matters.

Score the Bypass

A direct assignment outside PIM skips approval, MFA and the time box, so the bypass itself carries a floor of two points. On top: three for a Critical tier role, three for a first time initiator, three for a disabled target (the staging pattern), two each for a fresh target, a service principal target or an application initiator, and one when PIM itself flagged the same target. Threshold five.

Two earlier lessons hold the neighbouring ground: "New User Assigned to Privileged Role" owns novelty, a target and role pairing AuditLogs has not seen through any channel, including create then elevate; the Azure RBAC lesson owns the resource plane. This rule owns the channel.

Known False Positives

IAM teams doing business as usual. An established human directly assigning a non critical role scores two and stays silent, the loudest benign process here, designed out rather than tuned out. Where any direct assignment counts as a violation, lower the threshold to four.

Provisioning automation. An established application scores four and stays silent. A new one fires once at seven, which is the review: migrate the workflow to the PIM APIs, or make a conscious TrustedInitiators entry. Observe it before you trust it.

Break glass drills. A critical role assigned directly fires at five, every time, on purpose. If the drill does not page anyone, the drill has failed.

The Rebuild

| where RiskScore >= FireThreshold

Fires alone: any Critical tier role (five) and any role assigned to a disabled account (five). In pairs: a first time initiator touching any listed role (five), a new automation identity (seven), a fresh account plus a first time initiator (seven). Silent on purpose: the established IAM human at two, the established provisioning app at four. Expect a handful of alerts a week mid migration, near zero once it finishes.

Degradation: without UEBA the fresh, disabled, service account and MFA signals fall silent while floor, role tier, first time initiator, application initiator and service principal target keep working. The first time signal only runs when the baseline holds data, and a missing lookup never becomes a false alarm.

The RiskIndicators Field

Privileged role assigned directly, outside PIM | Critical role: Global Administrator | First direct assignment by this initiator in the baseline window

The analyst reads the story in one field. The combination to chase: a Critical role, a first time initiator and a fresh target together. That is create then elevate in front of you; the companion novelty rule should be ringing too.

The Blind Spots You Should Know About

  • Eligible assignments and everything PIM manages are out of scope; PIM's own approval trail owns the sanctioned path.
  • Membership changes to role assignable groups never appear here; all three role lessons share this gap.
  • A compromised established initiator staying non critical and in pattern scores two; the novelty lesson owns the pairing it eventually produces.
  • Custom directory roles and anything outside the tier list are invisible; the list is a conscious boundary.
  • An initiator quiet for more than fourteen days refires as first time; for a bypass channel that is a feature.

MITRE Mappings for the Updated Rule

  • T1098 Account Manipulation and T1098.003 Additional Cloud Roles: a direct privileged grant is the definition, serving Privilege Escalation and Persistence.
  • T1078 Valid Accounts and T1078.004 Cloud Accounts: the assigning account is real and authorised; the channel condemns it.
  • T1136 is dropped: this rule never observes account creation, and the novelty lesson covers that chain.

Rule Settings

  • Frequency: 1 hour
  • Query period: 14 days, the largest lookup (the initiator baseline) and the platform maximum
  • Severity: High
  • Event grouping: alert per result (one row per initiator, target and role)
  • Incident grouping: by Account entity, 6 hour window
  • Entity mappings: initiator Account (Name, UPNSuffix, AadUserId), target Account (same three), initiator IP (Address)
  • Custom details: Role, RoleTier, RoleTemplateId, Initiator, InitiatorType, HistAssignments, TargetAccount, TargetType, TargetCreated, TargetEnabled, TargetIsSvcAcct, TargetHomeDomain, RiskScore, RiskIndicators

KQL

// =====================================================================
// Privileged Role Assignment Outside PIM - Composite Risk
// =====================================================================
// Description : Scores every successful direct assignment of a privileged
// Entra ID role made outside PIM; role tier, first time
// initiator, fresh or disabled target, service principal
// target and unreviewed automation rank each event, so
// routine IAM activity stays silent while Tier 0 grants and
// staging patterns fire on their own.
// Type : Detection
//
// Tables : AuditLogs, IdentityInfo
// Connectors : Microsoft Entra ID (Diagnostic Settings - AuditLogs);
// UEBA (Behavior Analytics) for IdentityInfo
// License : Microsoft Sentinel; UEBA required for IdentityInfo (fresh,
// disabled, service account and MFA signals degrade to
// silence without it)
//
// Tuning : - Set the rule query period to P14D; the 14d initiator
// baseline only resolves if the rule looks back that far
// - TrustedInitiators - reviewed IAM automation only;
// inventory first, then decide, and prefer migrating the
// workflow to the PIM APIs over excluding it
// - RoleTiers - current display names only; renamed roles
// silently fall out, so review against the permissions
// reference and pin RoleTemplateId where names drift
// - FireThreshold - 5 by default; lower to 4 to surface
// established initiators touching sensitive tiers
//
// Known FPs : - IAM teams making routine direct assignments - the floor
// scores 2 and stays silent without stacked evidence
// - Provisioning automation - established apps score 4;
// new apps fire once for review, then TrustedInitiators
// - Break glass drills - critical roles fire at 5 on
// purpose; that page is the point of the drill
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-12
// =====================================================================
let DetectionWindow = 1h; // rule runs hourly; only the last run interval is scored
let BaselineWindow = 14d; // initiator history depth; equals the rule query period, the platform maximum
let IdentityLookback = 14d; // how far back IdentityInfo snapshots are read
let FreshAccountDays = 7d; // target accounts younger than this count as fresh
let FireThreshold = 5; // minimum RiskScore for a row to alert
let TrustedInitiators = dynamic([]); // reviewed IAM automation allowed to assign directly
let PimServiceIdentities = dynamic(["MS-PIM", "MS-PIM-Fairfax"]); // PIM provisioning identities; their writes are PIM managed
// Scoring weights - serious signals clear the threshold alone, soft signals stack
let W_OutsidePimFloor = 2; // floor: a privileged role assigned directly, without PIM
let W_CriticalRole = 3; // role sits in the Critical tier below
let W_FirstTimeInitiator = 3; // initiator has no direct assignment history before this window
let W_FreshTarget = 2; // target account created within FreshAccountDays
let W_DisabledTarget = 3; // target account is disabled, the staging pattern
let W_TargetServicePrincipal = 2; // directory role landed on a service principal
let W_AppInitiated = 2; // assignment initiated by an application rather than a person
let W_PimAlertRaised = 1; // PIM itself flagged an out of band assignment for the same target
// Role tiers - current display names only; renamed roles silently fall out, check against the permissions reference
let RoleTiers = datatable(RoleName: string, RoleTier: string) [
"Global Administrator", "Critical",
"Privileged Role Administrator", "Critical",
"Privileged Authentication Administrator", "Critical",
"Partner Tier2 Support", "Critical",
"Partner Tier1 Support", "Privileged",
"Security Administrator", "Privileged",
"Exchange Administrator", "Privileged",
"SharePoint Administrator", "Privileged",
"User Administrator", "Privileged",
"Password Administrator", "Privileged",
"Authentication Administrator", "Privileged",
"Conditional Access Administrator", "Privileged",
"Application Administrator", "Privileged",
"Cloud Application Administrator", "Privileged",
"Hybrid Identity Administrator", "Privileged",
"Intune Administrator", "Privileged",
"Helpdesk Administrator", "Privileged",
"Directory Writers", "Privileged",
"Global Reader", "Privileged",
"Security Operator", "Privileged",
"Security Reader", "Privileged"
];
// Lookup - direct assigners in the 13 days before the detection window, so events cannot baseline themselves
let InitiatorHistory = AuditLogs
| where TimeGenerated between (ago(BaselineWindow) .. ago(DetectionWindow))
| where Category =~ "RoleManagement"
| where LoggedByService =~ "Core Directory"
| where OperationName =~ "Add member to role"
| extend HistInitiator = tostring(iff(isnotempty(InitiatedBy.app.displayName), InitiatedBy.app.displayName, InitiatedBy.user.userPrincipalName))
| where isnotempty(HistInitiator) and HistInitiator !in~ (PimServiceIdentities)
| summarize HistoricalCount = count() by HistInitiator;
// Guard - the first time signal needs the baseline to hold data
let HasHistory = toscalar(InitiatorHistory | summarize count());
// Lookup - latest identity snapshot per object id, read for target and initiator
let IdentitySnapshot = materialize(IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| extend AccountObjectId = tolower(AccountObjectId)
| summarize arg_max(TimeGenerated, AccountUPN, AccountDisplayName, AccountCreationTime, IsAccountEnabled, IsServiceAccount, IsMFARegistered, Department, JobTitle, Manager, AssignedRoles, UserType) by AccountObjectId);
// Lookup - targets PIM itself flagged inside the detection window
let PimFlagged = AuditLogs
| where TimeGenerated > ago(DetectionWindow)
| where Category =~ "RoleManagement"
| where OperationName has_cs "outside of PIM"
| mv-apply FlaggedResource = TargetResources on (
where FlaggedResource.type in~ ("User", "ServicePrincipal")
| extend FlaggedTargetId = tolower(tostring(FlaggedResource.id)))
| where isnotempty(FlaggedTargetId)
| distinct FlaggedTargetId
| extend PimAlerted = true;
// Main pipeline - one row per initiator, target and role, scored on positive evidence only
AuditLogs
| where TimeGenerated > ago(DetectionWindow)
| where Category =~ "RoleManagement"
| where LoggedByService =~ "Core Directory"
| where OperationName =~ "Add member to role"
| where ResultType =~ "success" or Result =~ "success"
| extend InitiatorApp = tostring(InitiatedBy.app.displayName)
| extend InitiatorUpn = tostring(InitiatedBy.user.userPrincipalName)
| extend InitiatorId = tolower(tostring(InitiatedBy.user.id))
| extend InitiatorIp = tostring(iff(isnotempty(InitiatedBy.user.ipAddress), InitiatedBy.user.ipAddress, InitiatedBy.app.ipAddress))
| extend Initiator = iff(isnotempty(InitiatorApp), InitiatorApp, InitiatorUpn)
| where isnotempty(Initiator)
| where Initiator !in~ (PimServiceIdentities)
| where Initiator !in~ (TrustedInitiators)
| mv-apply TargetResource = TargetResources on (
where TargetResource.type in~ ("User", "ServicePrincipal")
| extend TargetId = tolower(tostring(TargetResource.id)),
TargetType = tostring(TargetResource.type),
Target = tostring(iff(TargetResource.type =~ "ServicePrincipal", TargetResource.displayName, TargetResource.userPrincipalName)),
TargetProps = TargetResource.modifiedProperties)
| mv-apply Property = TargetProps on (
where Property.displayName in~ ("Role.DisplayName", "Role.TemplateId")
| summarize AssignedRoleName = take_anyif(trim('"', tostring(Property.newValue)), Property.displayName =~ "Role.DisplayName"),
RoleTemplateId = take_anyif(tolower(trim('"', tostring(Property.newValue))), Property.displayName =~ "Role.TemplateId"))
| where isnotempty(AssignedRoleName)
| lookup kind=leftouter (RoleTiers) on $left.AssignedRoleName == $right.RoleName
| where isnotempty(RoleTier)
| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated),
arg_max(TimeGenerated, CorrelationId, InitiatorApp, InitiatorUpn, InitiatorId, InitiatorIp, Target, TargetType, RoleTier, RoleTemplateId)
by TargetId, AssignedRoleName, Initiator
| join kind=leftouter (InitiatorHistory) on $left.Initiator == $right.HistInitiator
| join kind=leftouter (IdentitySnapshot) on $left.TargetId == $right.AccountObjectId
| join kind=leftouter (IdentitySnapshot
| project InitAccountObjectId = AccountObjectId, InitDepartment = Department, InitJobTitle = JobTitle, InitCurrentRoles = tostring(AssignedRoles), InitMfaRegistered = IsMFARegistered)
on $left.InitiatorId == $right.InitAccountObjectId
| join kind=leftouter (PimFlagged) on $left.TargetId == $right.FlaggedTargetId
| extend S_Floor = W_OutsidePimFloor
| extend S_Critical = iff(RoleTier == "Critical", W_CriticalRole, 0)
| extend S_FirstTime = iff(HasHistory > 0 and isnull(HistoricalCount), W_FirstTimeInitiator, 0)
| extend S_FreshTarget = iff(isnotempty(AccountCreationTime) and AccountCreationTime > ago(FreshAccountDays), W_FreshTarget, 0)
| extend S_DisabledTarget = iff(IsAccountEnabled == false, W_DisabledTarget, 0)
| extend S_SpTarget = iff(TargetType =~ "ServicePrincipal", W_TargetServicePrincipal, 0)
| extend S_AppInitiated = iff(isnotempty(InitiatorApp), W_AppInitiated, 0)
| extend S_PimAlert = iff(PimAlerted == true, W_PimAlertRaised, 0)
| extend RiskScore = S_Floor + S_Critical + S_FirstTime + S_FreshTarget + S_DisabledTarget + S_SpTarget + S_AppInitiated + S_PimAlert
| where RiskScore >= FireThreshold
| extend Risk_1 = "Privileged role assigned directly, outside PIM"
| extend Risk_2 = iff(S_Critical > 0, strcat("Critical role: ", AssignedRoleName), "")
| extend Risk_3 = iff(S_FirstTime > 0, "First direct assignment by this initiator in the baseline window", "")
| extend Risk_4 = iff(S_FreshTarget > 0, strcat("Target account created ", format_datetime(AccountCreationTime, "yyyy-MM-dd")), "")
| extend Risk_5 = iff(S_DisabledTarget > 0, "Target account is disabled", "")
| extend Risk_6 = iff(S_SpTarget > 0, "Target is a service principal", "")
| extend Risk_7 = iff(S_AppInitiated > 0, strcat("Assignment initiated by application: ", InitiatorApp), "")
| extend Risk_8 = iff(S_PimAlert > 0, "PIM flagged an out of band assignment for this target", "")
| 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, " | "), ""),
iff(isnotempty(Risk_8), strcat(Risk_8, " | "), "")))
| extend InitiatorType = iff(isnotempty(InitiatorApp), "Application", "User")
| extend TargetHomeDomain = iff(Target contains "#EXT#", extract(@"([^]+)#EXT#", 1, Target), "")
| extend TargetName = tostring(split(Target, "@", 0)[0])
| extend TargetUPNSuffix = tostring(split(Target, "@", 1)[0])
| extend Name = tostring(split(InitiatorUpn, "@", 0)[0])
| extend UPNSuffix = tostring(split(InitiatorUpn, "@", 1)[0])
| project StartTime, EndTime, Initiator, InitiatorType, InitiatorUpn, InitiatorId, InitiatorIp, InitiatorApp, Name, UPNSuffix,
InitDepartment, InitJobTitle, InitCurrentRoles, InitMfaRegistered,
HistAssignments = coalesce(HistoricalCount, 0),
Target, TargetId, TargetType, TargetName, TargetUPNSuffix, TargetHomeDomain,
TargetUpn = AccountUPN, TargetDisplay = AccountDisplayName, TargetCreated = AccountCreationTime,
TargetEnabled = IsAccountEnabled, TargetIsSvcAcct = IsServiceAccount, TargetMfaRegistered = IsMFARegistered,
TargetDepartment = Department, TargetJobTitle = JobTitle, TargetManager = Manager,
TargetCurrentRoles = tostring(AssignedRoles), TargetUserType = UserType,
AssignedRoleName, RoleTier, RoleTemplateId, CorrelationId, RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc

What You Should Do Next

  1. Run the query for fourteen days with the score gate removed and summarise by Initiator. That is your migration hit list, your TrustedInitiators candidates, and it decides whether the threshold drops to four.
  2. Review RoleTiers against the Entra permissions reference. Renamed roles fall out of string lists silently; you just watched it happen to me twice.
  3. Confirm IdentityInfo is populated; without UEBA the disabled and fresh target signals never fire, and you should know rather than assume.
  4. The real fix is upstream: move automation to the PIM APIs and shrink the channel until watching it becomes boring.

Class dismissed.

Consent Preferences