PIM Auditing in Microsoft Sentinel: High Value Detections
All right class.
PIM is a just-in-time access control. The idea is sound. Instead of an admin account permanently holding Global Administrator, the user is eligible for the role and activates it only when needed, for a limited window, with MFA and sometimes approval required. When the window expires, the privilege disappears.
The problem is that most organisations deploy PIM, tick the box, and assume the hard work is done. It is not. PIM protects exactly one moment in time: the activation. Everything before and after that moment is largely unprotected, and if you are not watching what happens at that moment and what follows from it, you have a control that exists on paper and nowhere else.
This post is about building actual detection engineering around PIM in Sentinel.
Detection 1: Tier-0 Activation by a Risky User
This is your highest confidence detection. Identity Protection already thinks the account is compromised. Now it is activating Global Administrator. Two independent signals on the same actor at the same time. This fires as a confirmed incident until proven otherwise.
let Tier0Roles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Application Administrator",
"Cloud Application Administrator"
]);
let RiskyUsers =
AADRiskyUsers
| where TimeGenerated >= ago(7d)
| where RiskState in ("atRisk", "confirmedCompromised")
| where RiskLevel in ("high", "medium")
| project RiskyUPN = UserPrincipalName, RiskLevel, RiskState, RiskLastUpdatedDateTime;
AuditLogs
| where TimeGenerated >= ago(7d)
| where OperationName == "Add member to role completed (PIM activation)"
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| extend RoleName = tostring(parse_json(TargetResources)[0].displayName)
| where RoleName in (Tier0Roles)
| extend ActorIP = tostring(parse_json(AdditionalDetails)[0].value)
| where isnotempty(Actor)
| join kind=inner RiskyUsers on $left.Actor == $right.RiskyUPN
| project
TimeGenerated,
Actor,
ActorIP,
RoleName,
RiskLevel,
RiskState,
RiskLastUpdatedDateTime,
CorrelationId,
ResultReason,
AADTenantId
| order by TimeGenerated desc
Detection 2: First-Time Tier-0 Eligible Assignment by an Unprivileged Actor
A new eligible assignment to a Tier-0 role is not inherently malicious. Known PIM administrators make these assignments routinely. The alert triggers when an actor with no prior Tier-0 assignment history in the last 30 days and no privileged role history makes an eligible assignment. This catches compromised accounts establishing persistence, insider threats, and privilege escalation by unauthorized users.
let Tier0Roles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Application Administrator",
"Cloud Application Administrator"
]);
let Tier0RoleWeight = datatable(RoleName:string, SeverityScore:int)
[
"Global Administrator", 100,
"Privileged Role Administrator", 90,
"Privileged Authentication Administrator", 85,
"Security Administrator", 80,
"Application Administrator", 50,
"Cloud Application Administrator", 50
];
let KnownAssignors =
AuditLogs
| where TimeGenerated between (ago(30d) .. ago(1h))
| where OperationName == "Add eligible member to role in PIM completed (permanent)"
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| where isnotempty(Actor)
| summarize by Actor;
let PrivilegedActors =
AuditLogs
| where TimeGenerated >= ago(30d)
| where OperationName in ("Add member to role completed (PIM activation)", "Add eligible member to role in PIM completed (permanent)")
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| extend RoleName = tostring(parse_json(TargetResources)[0].displayName)
| where RoleName in (Tier0Roles)
| summarize PrivilegedRoleCount = dcount(RoleName) by Actor
| where PrivilegedRoleCount >= 1
| project Actor, IsPrivileged = 1;
let RiskyUsers =
AADRiskyUsers
| summarize arg_max(RiskLastUpdatedDateTime, RiskLevel, RiskState, UserPrincipalName) by UserPrincipalName
| where RiskState in ("atRisk", "confirmedCompromised")
| extend RiskWeight = case(
RiskLevel == "high" and RiskState == "confirmedCompromised", 100,
RiskLevel == "high" and RiskState == "atRisk", 80,
RiskLevel == "medium" and RiskState == "confirmedCompromised", 70,
RiskLevel == "medium" and RiskState == "atRisk", 50,
0)
| project RiskyUPN = UserPrincipalName, RiskLevel, RiskState, RiskWeight;
AuditLogs
| where TimeGenerated between (ago(1h) .. ago(5m))
| where OperationName == "Add eligible member to role in PIM completed (permanent)"
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| extend RoleName = tostring(parse_json(TargetResources)[0].displayName)
| where RoleName in (Tier0Roles)
| where isnotempty(Actor)
| mv-apply Target = parse_json(TargetResources) on (
where Target.type == "User"
| summarize TargetUser = take_any(tostring(Target.userPrincipalName))
)
| mv-apply Details = parse_json(AdditionalDetails) on (
summarize ActorIP = take_anyif(tostring(Details.value), Details.key == "ipaddr")
)
| join kind=leftanti KnownAssignors on Actor
| lookup kind=leftouter PrivilegedActors on Actor
| lookup kind=leftouter RiskyUsers on $left.Actor == $right.RiskyUPN
| lookup kind=leftouter Tier0RoleWeight on RoleName
| extend IsPrivileged = coalesce(IsPrivileged, 0)
| extend RiskWeight = coalesce(RiskWeight, 0)
| extend AlertScore = SeverityScore + RiskWeight + (IsPrivileged * -20)
| extend DetectionReason = case(
RiskWeight >= 80, "Risky actor assigned Tier-0 eligible role",
IsPrivileged == 0 and SeverityScore >= 80, "Non-privileged actor assigned high-value Tier-0 role",
SeverityScore >= 80 and AlertScore >= 70, "First-time assignor + high-value role",
"Low severity anomaly"
)
| project
TimeGenerated,
Actor,
ActorIP,
TargetUser,
RoleName,
IsPrivileged,
RiskLevel,
RiskState,
RiskWeight,
SeverityScore,
AlertScore,
DetectionReason,
CorrelationId
| where AlertScore >= 70
| order by AlertScore desc
Detection 3: Unified PIM Risk Correlation Rule
Replaces all standalone detections. Builds a weighted risk score per actor across multiple signals within a 60-minute window. Triggers only when the risk score threshold is met.
Available signals: identity risk state from AADRiskyUsers (weighted by RiskLevel and RiskState), country deviation from 30-day SigninLogs baseline, and severity-weighted privileged follow-on activity in AuditLogs. A single low-severity signal is noise. Risk score of 3 or higher requires investigation.
let Tier0Roles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Application Administrator",
"Cloud Application Administrator"
]);
let LookbackWindow = 60m;
let PIMActivations =
AuditLogs
| where TimeGenerated > ago(LookbackWindow)
| where OperationName == "Add member to role completed (PIM activation)"
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| extend ActorId = tostring(parse_json(InitiatedBy).user.id)
| extend RoleName = tostring(parse_json(TargetResources)[0].displayName)
| where RoleName in (Tier0Roles)
| where isnotempty(Actor)
| mv-apply Details = parse_json(AdditionalDetails) on (
summarize
ActorIP = take_anyif(tostring(Details.value), Details.key == "ipaddr"),
Justification = take_anyif(tostring(Details.value), Details.key == "Justification"),
ExpirationTime = take_anyif(tostring(Details.value), Details.key == "ExpirationTime")
)
| project ActivationTime = TimeGenerated, Actor, ActorId, ActorIP,
RoleName, Justification, ExpirationTime, ActivationCorrelationId = CorrelationId;
let RiskyActors =
AADRiskyUsers
| project RiskUPN = UserPrincipalName, RiskLevel, RiskState, RiskLastUpdatedDateTime;
let CountryBaseline =
SigninLogs
| where TimeGenerated between (ago(30d) .. ago(1d))
| where ResultType == "0"
| extend UserIdentity = Identity
| extend LocationCountry = tostring(split(Location, ",")[-1])
| where isnotempty(LocationCountry)
| summarize KnownCountries = make_set(LocationCountry) by UserIdentity;
let RecentSignIns =
SigninLogs
| where TimeGenerated > ago(LookbackWindow)
| where ResultType == "0"
| extend UserIdentity = Identity
| extend LocationCountry = tostring(split(Location, ",")[-1])
| extend SignInTime = TimeGenerated
| summarize arg_max(SignInTime, LocationCountry) by UserIdentity;
let HighSeverityOps = dynamic([
"Add user",
"Add member to role",
"Update conditional access policy",
"Create application",
"Update directory settings",
"Add service principal"
]);
let FollowOnActivity =
AuditLogs
| where TimeGenerated > ago(LookbackWindow)
| where OperationName != "Add member to role completed (PIM activation)"
| extend Actor = tostring(parse_json(InitiatedBy).user.userPrincipalName)
| where isnotempty(Actor)
| extend OpSeverity = case(
OperationName in (HighSeverityOps), 3,
OperationName contains "Update", 2,
OperationName contains "Delete", 2,
OperationName contains "Create", 2,
1)
| summarize
FollowOnOps = make_set(OperationName, 10),
FollowOnCount = count(),
MaxSeverity = max(OpSeverity)
by Actor;
PIMActivations
| join kind=leftouter RiskyActors on $left.Actor == $right.RiskUPN
| join kind=leftouter RecentSignIns on $left.ActorId == $right.UserIdentity
| join kind=leftouter CountryBaseline on $left.ActorId == $right.UserIdentity
| join kind=leftouter FollowOnActivity on Actor
| extend RiskAtTime = case(
RiskLastUpdatedDateTime between (ActivationTime - 1h .. ActivationTime + 1h), 1,
RiskLastUpdatedDateTime < ActivationTime, 1,
0)
| extend RiskWeight = case(
RiskAtTime == 1 and RiskState == "confirmedCompromised", 4,
RiskAtTime == 1 and RiskState == "atRisk" and RiskLevel == "high", 3,
RiskAtTime == 1 and RiskState == "atRisk", 2,
0)
| extend IsNewCountry = coalesce(isnotempty(LocationCountry) and not(set_has_element(KnownCountries, LocationCountry)), false)
| extend FollowOnWeight = coalesce(MaxSeverity, 0)
| extend RiskScore = RiskWeight + toint(IsNewCountry) + FollowOnWeight
| where RiskScore >= 3
| project
ActivationTime,
Actor,
ActorIP,
RoleName,
RiskScore,
RiskWeight,
RiskState,
RiskLevel,
RiskAtTime,
IsNewCountry,
LocationCountry,
FollowOnWeight,
FollowOnOps,
Justification,
ExpirationTime,
ActivationCorrelationId
| order by RiskScore desc, ActivationTime desc
Detection 1: Tier-0 Activation by Risky User
Alert when a user with Identity Protection risk (high/medium, atRisk/confirmedCompromised) activates a Tier-0 role.
Detection 2: First-Time Eligible Assignment by Unprivileged Actor
Alert when an actor with no Tier-0 assignment history in 30 days AND no prior privileged role history makes an eligible Tier-0 assignment. Uses AlertScore 0-200. Alert threshold = 70. Alert severity = medium.
Detection 3: Unified Risk Correlation
Combines three signals within 60 minutes: identity risk (0-4), new country (0-1), privileged follow-on activity (0-3). Max score = 7. Alert threshold = 3. Run every 60 minutes, look back 60 minutes.
Scoring threshold guide for Detection 3:
Score 6-7 = All signals high severity. Page immediately.
Score 4-5 = Three signals or high-risk combo. Investigate in 15 minutes.
Score 3 = Two signals. Investigate in 1 hour.
Score 1-2 = Single signal. No alert. Hunting only.
Rule settings for all detections:
Start with threshold as written. Run for 1-2 weeks. If false positives, raise threshold by one level. Map Actor to Account, ActorIP to IP. Group alerts by Actor with 2 hour suppression.
Deploy, test & adjust thresholds.
Class dismissed.
