PIM Auditing in Microsoft Sentinel: High Value Detections

PIM Auditing in Microsoft Sentinel: High Value Detections

Alright class.

Most lessons in this series are usually autopsies. We take a content hub rule, find the line that breaks it, and put it back together properly. Today is different. There is no single rule to fix here, because the honest answer to "how do I audit PIM" is that one rule was never going to do it. PIM is the control plane for standing privilege in Entra ID, and the events it produces are, by design, the same whether the person activating Global Administrator is your IAM lead or an attacker sitting on a compromised account that already held the eligibility.

That is the trap. A rule that matches on "Add member to role completed (PIM activation)" and alerts is not a detection. It is a noise generator that fires every time anyone does their job. The events are not the anomaly. The context around them is.

So we are building three detections. Each starts from a normal PIM event and adds the context that separates routine from hostile: whether the identity is already flagged as risky, whether the person handing out the role has ever done it before, and what the account actually did with the privilege once it had it. Every rule produces a single score, so the analyst opens the highest number first instead of reading a flat list of "look at this".

Here is what we are building.

Detection One: A Risky Identity Activating a Tier-0 Role

This is the highest fidelity rule of the three, and the one to turn on first. The premise is deliberately narrow. An account that Microsoft Entra ID Protection currently rates as atRisk or confirmedCompromised activates a Tier-0 role through PIM. That is account takeover walking straight into privilege, and the false positive surface is small enough that you can run it at a low threshold without drowning (assuming you are taking good care of your risky users' statuses, because you do, right?)

The score combines a role weight with a risk weight. Global Administrator outscores Cloud Application Administrator, and a confirmedCompromised state outscores a medium atRisk one. A confirmed account hitting Global Administrator sits at the top of the table; a medium-risk account on a lesser role sits near the gate where it belongs. IdentityInfo gives the analyst department, manager, and MFA registration in the incident without a second query.

let DetectionWindow = 1h;
let RiskLookback = 7d;
let ExcludedActorRegex = @"\b\B";   // never-matches by default; add break-glass UPNs
let MinScore = 80;
let Tier0Roles = dynamic([
    "Global Administrator", "Privileged Role Administrator",
    "Privileged Authentication Administrator", "Security Administrator",
    "Application Administrator", "Cloud Application Administrator"
]);
let Tier0RoleWeight = datatable(RoleName:string, RoleScore:int)
[
    "Global Administrator", 100, "Privileged Role Administrator", 90,
    "Privileged Authentication Administrator", 85, "Security Administrator", 80,
    "Application Administrator", 50, "Cloud Application Administrator", 50
];
let RiskyUsers = materialize(
    AADRiskyUsers
    | where TimeGenerated > ago(RiskLookback)
    | summarize arg_max(RiskLastUpdatedDateTime, RiskLevel, RiskState) by UserPrincipalName
    | where RiskState in ("atRisk", "confirmedCompromised")
    | extend RiskWeight = case(
        RiskLevel == "high"   and RiskState == "confirmedCompromised", 100,
        RiskLevel == "high",                                            80,
        RiskLevel == "medium" and RiskState == "confirmedCompromised",  70,
        RiskLevel == "medium",                                          50,
        30)
    | project RiskyUPN = UserPrincipalName, RiskLevel, RiskState, RiskLastUpdatedDateTime, RiskWeight
);
let IdentityContext =
    IdentityInfo
    | where TimeGenerated > ago(14d)
    | summarize arg_max(TimeGenerated, Department, JobTitle, Manager, IsMFARegistered, IsAccountEnabled, IsServiceAccount, EntityRiskScore) by AccountUPN;
let PIMTier0Activations = (start:datetime, end:datetime) {
    AuditLogs
    | where TimeGenerated between (start .. end)
    | where Category =~ "RoleManagement"
    | where OperationName == "Add member to role completed (PIM activation)"
    | where ResultType =~ "success" or Result =~ "success"
    | extend Actor = tostring(InitiatedBy.user.userPrincipalName)
    | extend ActorId = tostring(InitiatedBy.user.id)
    | extend ActorIPInline = tostring(InitiatedBy.user.ipAddress)
    | where isnotempty(Actor)
    | where not(Actor matches regex ExcludedActorRegex)
    | mv-apply TR = TargetResources on (
        where tostring(TR.type) =~ "Role"
        | extend RoleName = tostring(TR.displayName)
        | where isnotempty(RoleName)
    )
    | where RoleName in (Tier0Roles)
    | mv-apply D = AdditionalDetails on (
        summarize ActorIPDetail = take_anyif(tostring(D.value), tostring(D.key) == "ipaddr")
    )
    | extend ActorIP = coalesce(ActorIPDetail, ActorIPInline)
};
PIMTier0Activations(ago(DetectionWindow), now())
| join kind=inner RiskyUsers on $left.Actor == $right.RiskyUPN
| lookup kind=leftouter Tier0RoleWeight on RoleName
| join kind=leftouter IdentityContext on $left.Actor == $right.AccountUPN
| extend RoleScore = coalesce(RoleScore, 50)
| extend AlertScore = RoleScore + RiskWeight
| where AlertScore >= MinScore
| extend ActorName      = tostring(split(Actor, "@", 0)[0])
| extend ActorUPNSuffix = tostring(split(Actor, "@", 1)[0])
| extend Risk_1 = iff(RiskState == "confirmedCompromised", "ActorConfirmedCompromised", "")
| extend Risk_2 = iff(RoleScore >= 90, "CriticalTier0Role", "")
| extend Risk_3 = iff(IsMFARegistered == false, "ActorMFANotRegistered", "")
| extend Risk_4 = iff(IsServiceAccount == true, "ActorIsServiceAccount", "")
| extend Risk_5 = iff(isempty(ActorIP), "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, " | "), "")
))
| project
    TimeGenerated, Actor, ActorName, ActorUPNSuffix, ActorId, ActorIP,
    RoleName, RoleScore, RiskLevel, RiskState, RiskWeight, AlertScore,
    RiskLastUpdatedDateTime, Department, JobTitle, Manager,
    IsMFARegistered, IsServiceAccount, IsAccountEnabled, EntityRiskScore,
    RiskIndicators, CorrelationId
| sort by AlertScore desc, TimeGenerated desc

The break-glass problem is real here, because the one time your emergency account should be activating Global Administrator is during an incident, which is exactly when Identity Protection might be lighting it up. Put those UPNs in ExcludedActorRegex rather than raising the threshold and losing the signal.

Detection Two: An Eligible Assignment From an Unexpected Hand

Activation is the loud step. Assignment is the quiet one. If an attacker can grant eligibility, they no longer need to compromise an existing privileged account. They make their own, hand it a standing eligible Tier-0 role, and activate it later through the front door whenever they like. This rule watches who hands out eligible Tier-0 roles and fires when the hand is unexpected.

The baseline does the work. A 14-day anti-join on the assignor and role pair suppresses every approver who routinely grants that role, so your IAM lead assigning Security Administrator for the twentieth time this month stays silent while a help-desk account doing it once lights up. A second check asks whether the assignor has any Tier-0 assignment history at all, which carries more weight than simply being new for one specific role.

let DetectionWindow = 1h;
let BaselineWindow = 14d;
let ExcludedActorRegex = @"\b\B";
let MinScore = 70;
let Tier0Roles = dynamic([
    "Global Administrator", "Privileged Role Administrator",
    "Privileged Authentication Administrator", "Security Administrator",
    "Application Administrator", "Cloud Application Administrator"
]);
let Tier0RoleWeight = datatable(RoleName:string, RoleScore:int)
[
    "Global Administrator", 100, "Privileged Role Administrator", 90,
    "Privileged Authentication Administrator", 85, "Security Administrator", 80,
    "Application Administrator", 50, "Cloud Application Administrator", 50
];
let EligibleTier0Assignments = (start:datetime, end:datetime) {
    AuditLogs
    | where TimeGenerated between (start .. end)
    | where Category =~ "RoleManagement"
    | where OperationName has "Add eligible member to role" and OperationName has "completed"
    | where ResultType =~ "success" or Result =~ "success"
    | extend Assignor    = tostring(InitiatedBy.user.userPrincipalName)
    | extend AssignorApp = tostring(InitiatedBy.app.displayName)
    | mv-apply TR = TargetResources on (
        where tostring(TR.type) =~ "Role"
        | extend RoleName = tostring(TR.displayName)
        | where isnotempty(RoleName)
    )
    | where RoleName in (Tier0Roles)
    | mv-apply TU = TargetResources on (
        where tostring(TU.type) =~ "User"
        | extend TargetUser = tostring(TU.userPrincipalName)
    )
};
let PimRequesters = materialize(
    AuditLogs
    | where TimeGenerated > ago(2h)
    | where Category =~ "RoleManagement"
    | where OperationName has "PIM" and OperationName has "request"
    | extend RequestedBy = tostring(InitiatedBy.user.userPrincipalName)
    | where isnotempty(RequestedBy) and isnotempty(CorrelationId)
    | distinct CorrelationId, RequestedBy
);
let KnownAssignorPairs = materialize(
    EligibleTier0Assignments(ago(BaselineWindow), ago(DetectionWindow))
    | distinct Assignor, RoleName
);
let PrivilegedAssignors = materialize(
    EligibleTier0Assignments(ago(BaselineWindow), ago(DetectionWindow))
    | summarize HistoricalAssignments = count() by Assignor
    | extend HasHistory = 1
);
let RiskyUsers = materialize(
    AADRiskyUsers
    | where TimeGenerated > ago(7d)
    | summarize arg_max(RiskLastUpdatedDateTime, RiskLevel, RiskState) by UserPrincipalName
    | where RiskState in ("atRisk", "confirmedCompromised")
    | extend RiskWeight = case(
        RiskLevel == "high"   and RiskState == "confirmedCompromised", 100,
        RiskLevel == "high",                                            80,
        RiskLevel == "medium" and RiskState == "confirmedCompromised",  70,
        RiskLevel == "medium",                                          50,
        30)
    | project RiskyUPN = UserPrincipalName, RiskLevel, RiskState, RiskWeight
);
let IdentityContext =
    IdentityInfo
    | where TimeGenerated > ago(BaselineWindow)
    | summarize arg_max(TimeGenerated, AccountCreationTime, Department, JobTitle, Manager, IsMFARegistered, IsServiceAccount, IsAccountEnabled) by AccountUPN;
EligibleTier0Assignments(ago(DetectionWindow), now())
| join kind=leftouter PimRequesters on CorrelationId
| extend Assignor = iff((Assignor == "" or Assignor in ("MS-PIM", "MS-PIM-Fairfax")) and isnotempty(RequestedBy), RequestedBy, Assignor)
| where isnotempty(Assignor)
| where not(Assignor matches regex ExcludedActorRegex)
| join kind=leftanti KnownAssignorPairs on Assignor, RoleName
| lookup kind=leftouter PrivilegedAssignors on Assignor
| lookup kind=leftouter Tier0RoleWeight on RoleName
| join kind=leftouter RiskyUsers on $left.Assignor == $right.RiskyUPN
| join kind=leftouter IdentityContext on $left.Assignor == $right.AccountUPN
| extend HasHistory = coalesce(HasHistory, 0)
| extend RiskWeight = coalesce(RiskWeight, 0)
| extend RoleScore  = coalesce(RoleScore, 50)
| extend FirstTimeAssignor = HasHistory == 0
| extend NoveltyWeight = iff(FirstTimeAssignor, 40, 20)
| extend AssignorAgeDays = datetime_diff('day', now(), AccountCreationTime)
| extend AlertScore = RoleScore + NoveltyWeight + RiskWeight
| where AlertScore >= MinScore
| extend AssignorName      = tostring(split(Assignor, "@", 0)[0])
| extend AssignorUPNSuffix = tostring(split(Assignor, "@", 1)[0])
| extend TargetName        = tostring(split(TargetUser, "@", 0)[0])
| extend TargetUPNSuffix   = tostring(split(TargetUser, "@", 1)[0])
| extend Risk_1 = iff(RoleScore >= 90, "CriticalTier0Role", "")
| extend Risk_2 = iff(FirstTimeAssignor, "FirstTimeAssignor", "NewAssignorForRole")
| extend Risk_3 = iff(RiskWeight > 0, "AssignorAtRisk", "")
| extend Risk_4 = iff(AssignorAgeDays < 7, "AssignorRecentlyCreated", "")
| extend Risk_5 = iff(IsMFARegistered == false, "AssignorMFANotRegistered", "")
| 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, " | "), "")
))
| project
    TimeGenerated, Assignor, AssignorName, AssignorUPNSuffix,
    TargetUser, TargetName, TargetUPNSuffix, RoleName, RoleScore,
    FirstTimeAssignor, NoveltyWeight, RiskLevel, RiskState, RiskWeight,
    AlertScore, AssignorAgeDays, Department, JobTitle, Manager,
    IsMFARegistered, IsServiceAccount, RiskIndicators, CorrelationId
| sort by AlertScore desc, TimeGenerated desc

Detection Three: What the Account Did After It Activated

The first two rules ask whether an activation or an assignment is suspicious in itself. This one asks the more useful question. Once a Tier-0 role was live, what did the account do with it? Activating Global Administrator and then doing nothing is a Tuesday. Activating it and, within the hour, creating a user, adding a service principal credential, and weakening a Conditional Access policy is an attack chain you want to see in one incident.

The rule pins follow-on operations to the activation window, so a sensitive action only scores if it happened after the activation, not merely somewhere in the same lookback. It scores from the heaviest follow-on operation, adds weight when the sign-in behind the activation comes from a country the account has never signed in from, and adds more when Identity Protection already rates the account as risky. This is where the join fix from the top of the post earns its place. The geo branch joins on UserId and compares Location as a country code, so it actually returns data.

let DetectionWindow = 1h;
let ActOpWindow = 1h;
let BaselineWindow = 14d;   // scheduled-rule query period maxes at 14 days
let MinScore = 5;
let Tier0Roles = dynamic([
    "Global Administrator", "Privileged Role Administrator",
    "Privileged Authentication Administrator", "Security Administrator",
    "Application Administrator", "Cloud Application Administrator"
]);
let SensitiveOps = dynamic([
    "Add user", "Add member to role",
    "Update conditional access policy", "Delete conditional access policy",
    "Add application", "Add service principal", "Add service principal credentials",
    "Update application - Certificates and secrets management",
    "Add app role assignment to service principal",
    "Set federation settings on domain", "Update domain"
]);
let Tier0Activations =
    AuditLogs
    | where TimeGenerated > ago(DetectionWindow)
    | where Category =~ "RoleManagement"
    | where OperationName == "Add member to role completed (PIM activation)"
    | where ResultType =~ "success" or Result =~ "success"
    | extend Actor   = tostring(InitiatedBy.user.userPrincipalName)
    | extend ActorId = tostring(InitiatedBy.user.id)
    | extend ActorIPInline = tostring(InitiatedBy.user.ipAddress)
    | where isnotempty(Actor)
    | mv-apply TR = TargetResources on (
        where tostring(TR.type) =~ "Role"
        | extend RoleName = tostring(TR.displayName)
        | where isnotempty(RoleName)
    )
    | where RoleName in (Tier0Roles)
    | mv-apply D = AdditionalDetails on (
        summarize ActorIPDetail = take_anyif(tostring(D.value), tostring(D.key) == "ipaddr")
    )
    | extend ActorIP = coalesce(ActorIPDetail, ActorIPInline)
    | project ActivationTime = TimeGenerated, Actor, ActorId, ActorIP, RoleName,
              ActivationCorrelationId = CorrelationId;
let FollowOnOps =
    AuditLogs
    | where TimeGenerated > ago(DetectionWindow)
    | where OperationName != "Add member to role completed (PIM activation)"
    | extend FollowActor = tostring(InitiatedBy.user.userPrincipalName)
    | where isnotempty(FollowActor)
    | extend OpWeight = case(
        OperationName in (SensitiveOps),                          3,
        OperationName has_any ("Delete", "Remove"),               2,
        OperationName has_any ("Update", "Create", "Add", "Set"), 2,
        1)
    | project FollowTime = TimeGenerated, FollowActor, FollowOp = OperationName, OpWeight;
let CountryBaseline = materialize(
    SigninLogs
    | where TimeGenerated between (ago(BaselineWindow) .. ago(1d))
    | where ResultType == "0"
    | where isnotempty(Location)
    | summarize KnownCountries = make_set(Location, 200) by ActorId = UserId
);
let RecentCountry =
    SigninLogs
    | where TimeGenerated > ago(DetectionWindow)
    | where ResultType == "0"
    | where isnotempty(Location)
    | summarize arg_max(TimeGenerated, Location) by ActorId = UserId
    | project ActorId, SignInCountry = Location;
let RiskyUsers = materialize(
    AADRiskyUsers
    | where TimeGenerated > ago(7d)
    | summarize arg_max(RiskLastUpdatedDateTime, RiskLevel, RiskState) by UserPrincipalName
    | where RiskState in ("atRisk", "confirmedCompromised")
    | extend RiskWeight = case(
        RiskState == "confirmedCompromised", 4,
        RiskLevel == "high",                 3,
        2)
    | project RiskyUPN = UserPrincipalName, RiskLevel, RiskState, RiskWeight
);
Tier0Activations
| join kind=inner FollowOnOps on $left.Actor == $right.FollowActor
| where FollowTime between (ActivationTime .. ActivationTime + ActOpWindow)
| summarize
    FollowOnOps   = make_set(FollowOp, 15),
    FollowOnCount = count(),
    MaxOpWeight   = max(OpWeight),
    FirstFollowOn = min(FollowTime)
    by ActivationTime, Actor, ActorId, ActorIP, RoleName, ActivationCorrelationId
| join kind=leftouter RecentCountry on ActorId
| join kind=leftouter CountryBaseline on ActorId
| join kind=leftouter RiskyUsers on $left.Actor == $right.RiskyUPN
| extend RiskWeight = coalesce(RiskWeight, 0)
| extend NewSignInCountry = isnotempty(SignInCountry) and isnotempty(KnownCountries) and not(set_has_element(KnownCountries, SignInCountry))
| extend GeoWeight = iff(NewSignInCountry, 2, 0)
| extend RiskScore = MaxOpWeight + GeoWeight + RiskWeight
| where RiskScore >= MinScore
| extend ActorName      = tostring(split(Actor, "@", 0)[0])
| extend ActorUPNSuffix = tostring(split(Actor, "@", 1)[0])
| extend Risk_1 = iff(MaxOpWeight >= 3, "SensitiveFollowOnOperation", "")
| extend Risk_2 = iff(NewSignInCountry, "NewSignInCountry", "")
| extend Risk_3 = iff(RiskWeight > 0, "ActorAtRisk", "")
| extend Risk_4 = iff(FollowOnCount >= 5, "HighVolumeFollowOn", "")
| 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, " | "), "")
))
| project
    ActivationTime, Actor, ActorName, ActorUPNSuffix, ActorId, ActorIP, RoleName,
    RiskScore, MaxOpWeight, GeoWeight, RiskWeight,
    NewSignInCountry, SignInCountry, KnownCountries,
    FollowOnCount, FirstFollowOn, FollowOnOps,
    RiskLevel, RiskState, RiskIndicators, ActivationCorrelationId
| sort by RiskScore desc, ActivationTime desc

The Blind Spots You Should Know About

First, all three watch role activation and direct assignment. None of them see PIM for Groups. If you grant a privileged role to a role-assignable group and manage eligibility on the group instead of the role, the operation that matters is a group membership change, and it never touches these queries. If you use PIM for Groups, you need a companion detection scoped to those group IDs. That is a post of its own.

Second, the risk weights are only as good as your AADRiskyUsers data. Identity Protection risk needs Entra ID P2, and a P1 tenant or a broken export leaves RiskState and RiskLevel empty. When that happens the risk branch silently contributes zero and Detection One, which inner-joins on risk, returns nothing at all. Confirm the table is populated before you trust the score.

MITRE Mappings

Tactics across the set: Privilege Escalation, Persistence, and Defense Evasion, since the follow-on operations in Detection Three include weakening authentication controls.

T1078.004 Valid Accounts: Cloud Accounts. Detection One, a compromised cloud account elevating into privilege.

T1098.003 Account Manipulation: Additional Cloud Roles. Detection Two, granting eligibility, and the role assignment follow-on in Detection Three.

T1098.001 Account Manipulation: Additional Cloud Credentials. The service principal credential follow-on in Detection Three.

T1136.003 Create Account: Cloud Account. The create-then-elevate chain that the recently-created assignor flag and the Add user follow-on both surface.

T1556.009 Modify Authentication Process: Conditional Access Policies. The Conditional Access follow-on in Detection Three, added to ATT&CK in v15 and exactly the kind of post-activation tampering this rule exists to catch.

T1484.002 Domain Policy Modification: Trust Modification. The federation settings follow-on in Detection Three.

Rule Settings

All three run every 60 minutes. Detection One and Two use a query period of 14 days, because the assignor baseline and the IdentityInfo lookup need the full window, and 14 days is the maximum a scheduled rule allows. Detection Three also uses 14 days for the same reason. Set the detection windows slightly wider than 1 hour if your ingestion lag warrants it. High severity. Alert per result. Group by Account entity with a 2 hour lookback.

Detection One. Map ActorName and ActorUPNSuffix to Account, ActorIP to IP. Custom details: RoleName, RiskState, RiskLevel, AlertScore, RiskIndicators.

Detection Two. Map AssignorName and AssignorUPNSuffix to Account as the initiator, TargetName and TargetUPNSuffix to Account as the target. Custom details: RoleName, FirstTimeAssignor, AlertScore, AssignorAgeDays, RiskIndicators.

Detection Three. Map ActorName and ActorUPNSuffix to Account, ActorIP to IP. Custom details: RoleName, RiskScore, NewSignInCountry, FollowOnCount, RiskIndicators.

Every custom detail key sits under the 20 character limit, and none of the descriptions need more than three placeholders, so they import cleanly as analytic rules.

What You Should Do Next

Review the Tier0Roles list and the weights. These are a conscious decision about what you protect, not a default to accept. If you run a partner or MSSP tenant, Partner Tier1 and Tier2 Support belong in the list, and Tier2 belongs near the top of the weights.

Run all three manually over the last 14 days before you schedule anything. The manual run tells you how many activations and assignments your environment produces in a week, whether the role list is scoped sensibly, and, for Detection Three, whether the geo branch is actually returning countries rather than nulls.

Confirm AADRiskyUsers and IdentityInfo are populated. Both need the right licence, and both fail silent when they are not there. You want to know that before you rely on the score, not after an incident walks past a rule that was quietly scoring everything as zero.

And populate ExcludedActorRegex with your break-glass accounts up front. The entire point of scoring rather than matching is that the analyst opens the highest number first with the context already on screen. That only holds if the score means something, which means the enrichment behind it has to be real.

Follow my repo on GitHub to see more.

Class dismissed.

Consent Preferences