Fixing the "Suspicious granting of permissions to an account" Analytic Rule
Alright class.
Today's patient is "Suspicious granting of permissions to an account", the content hub rule that promises an alert "when a previously unseen source IP address is used" to grant access on Azure resources. The premise sounds sensible. The implementation is blind to the one event the rule exists for.
A Threshold That Protects the Attacker
let alertOperationThreshold = 5;
...
| where AssignmentCountbyCaller >= alertOperationThreshold
The rule only fires when a caller and IP pair produces at least five qualifying role assignments in the current day. Walk through the attack it was written for: stolen credentials assign Owner on your production subscription to an account the attacker controls. One write operation. One is less than five. The rule stays silent. A volume threshold on a crime that only needs to happen once is not a detection, it is a courtesy.
The arithmetic is off as well: every assignment writes a Start row carrying the request body and a Success row carrying only a status code, and with no status filter the two sides of the join count different things.
A Baseline Built From the Attacker's Own Noise
| where TimeGenerated between (ago(starttime) .. ago(endtime))
| summarize count() by CallerIpAddress, Caller, bin(TimeGenerated, 1d)
| where count_ >= alertOperationThreshold
The description says previously unseen IP. The code says something narrower: a pair only enters the baseline after five assignments in a single day. An admin assigning three or four roles daily from the same office IP never crosses that bar, so the day a project pushes them to six, their two week old address is reported as previously unseen.
The mirror image is worse. The baseline is role assignment activity itself, telemetry the attacker controls, and yesterday belongs to it. Monday, the attacker performs five throwaway Reader assignments on a sandbox resource group from their IP. Tuesday, that pair is baselined and the Owner grant sails through unexamined. The rule teaches the attacker how to disappear, and the tuition costs five junk writes.
A Detection That Depends on a CSV in GitHub
let AzureBuiltInRole = externaldata(Role:string,RoleDescription:string,ID:string)
[@"https://raw.githubusercontent.com/Azure/Azure-Sentinel/master/Sample%20Data/Feeds/AzureBuiltInRole.csv"] ...
| join kind = inner (AzureBuiltInRole) on $left.RoleAssignedID == $right.ID;
Every parsed assignment is inner joined against a CSV of built in roles in a GitHub repository. Custom roles are not in that file, so the join discards them, and a thoughtful attacker does not assign Owner; they create a custom role with Microsoft.Authorization/roleAssignments/write buried in the actions. This rule structurally cannot see it. The day that CSV moves in a repository you do not control, the join returns zero rows and the rule goes quiet without a single error. Survivable by itself, but plan for it: test the feed now and then, or keep the list in a Watchlist you own. The rebuild uses an inline datatable instead.
One Grant Is the Attack
Role assignment is not a volume crime. The question worth an analyst's minute is whether this single grant matters: which role, at which scope, landing on whom, issued by whom, and from where. One Owner beats fifty Readers.
The rebuild scores every completed assignment on those five axes. Owner, User Access Administrator and Role Based Access Control Administrator form the control plane tier because anyone holding them can mint further access; Contributor and the sensitive data plane roles form the high impact tier. Root and management group scope score heavily, subscription moderately, resource group not at all. Self grants, guest grantees and fresh grantees add weight. The new source IP, the original premise, survives as one point of seasoning: a /24 prefix, human callers only, baselined against thirteen days of full ARM history.
Known False Positives
PIM. Where Privileged Identity Management manages Azure resources, every activation materialises as an assignment write by the PIM service, and those grants already carry an approval trail. Observe which identity performs those writes and put it in TrustedCallers. Observe it, do not guess it.
Infrastructure pipelines. Terraform and Bicep assign roles constantly, usually at resource group scope, from agents whose addresses rotate hourly. Resource group scope carries no weight, and network novelty only applies to callers that look like humans, because a fresh IP on a Microsoft hosted agent is Tuesday. Contributor at subscription scope scores four and stays under the gate; a pipeline that legitimately grants control plane roles becomes a TrustedCallers entry after review.
Onboarding. A fresh grantee alone scores two. It takes a privileged role or broad scope before the rule cares.
B2B heavy estates. A guest carries three points because an external identity receiving resource access deserves a look; if your business hands Reader to partners weekly, tune W_GuestGrantee down.
The Rebuild
| where RiskScore >= FireThreshold
What fires alone: any assignment at tenant root or management group scope, five points. What fires in pairs: a control plane role plus any second signal, so a control plane grant at subscription scope (six), to a guest (seven), to a fresh account (six) or to the caller (seven). These combinations are rare, a handful a month in a steady tenant: volume an analyst can afford to take seriously.
Degradation: without UEBA there is no IdentityInfo, so guest and fresh grantee fall silent while role, scope, self grant and network keep working. A missing lookup never becomes a false alarm.
The RiskIndicators Field
Control plane role: Owner | Assignment at subscription scope | Caller granted the role to their own account
The analyst reads the story in one field. The combination to chase: a self grant next to a control plane role. Either an administrator doing something they know they should not, or your attacker mid escalation. Both deserve the interruption.
The Blind Spots You Should Know About
- Owner at resource group scope to an existing member from a known network scores four and stays silent. Deliberate; dev heavy estates do this daily. Lower FireThreshold to 4 where the pattern is rare.
- With the PIM provisioner in TrustedCallers, an attacker holding a standing eligible assignment activates invisibly; eligibility creation is a different operation. The PIM audit trail owns that path.
- Adding a user to a group that already holds a role never touches AzureActivity. The directory lessons name the same gap; role assignable groups need a post of their own.
- Key Vault access policies and other data plane grants are not RBAC writes, and ABAC conditions are not evaluated; a heavily conditioned Owner scores like an unconditioned one.
- Subscriptions that do not export activity logs are invisible. Policy assigned diagnostic settings are the fix, not this rule.
MITRE Mappings for the Updated Rule
- T1098 Account Manipulation: granting a role to a principal is the textbook cloud manipulation primitive, for Persistence and Privilege Escalation.
- T1098.003 Additional Cloud Roles: describes the exact event this rule scores.
- T1548.005 Temporary Elevated Cloud Access: covers the self grant path, the same shape that follows a Global Administrator elevate access call.
Rule Settings
- Frequency: 1 day
- Query period: 14 days, equal to the largest lookup (the caller network baseline)
- Severity: Medium
- Event grouping: alert per result (one row per assignment)
- Incident grouping: by Account entity, 6 hour window
- Entity mappings: caller Account (Name, UPNSuffix), grantee Account (AadUserId), caller IP (Address)
- Custom details: RoleName, RoleId, Scope, ScopeTier, GranteeUpn, GranteeType, GranteeUserType, GranteeCreated, RiskScore, RiskIndicators
KQL
// =====================================================================
// Azure RBAC Role Assignment - Composite Risk
// =====================================================================
// Description : Scores every completed Azure RBAC role assignment on
// which role, at which scope, to whom, by whom and from
// where; weighted signals replace the original volume
// threshold so one high value grant alerts alone, while
// pipeline, PIM and onboarding noise scores below the gate.
// Type : Detection
//
// Tables : AzureActivity, IdentityInfo
// Connectors : Azure Activity (policy assigned diagnostic settings
// recommended); UEBA (Behavior Analytics) for IdentityInfo
// License : Microsoft Sentinel; AzureActivity ingestion is free;
// UEBA required for IdentityInfo (guest and fresh grantee
// signals degrade to silence without it)
//
// Tuning : - Set the rule query period to P14D; the 14d caller
// network baseline only resolves if the rule itself
// looks back that far
// - TrustedCallers - vetted assigners; observe 14 days of
// output, then add the PIM provisioning identity and
// pipeline identities you can vouch for
// - FireThreshold - 5 by default; lower to 4 where resource
// group level control plane grants are rare
// - RoleTiers - extend with role ids that matter locally
// - W_GuestGrantee / FreshAccountDays - tune for B2B heavy
// tenants and onboarding cadence
//
// Known FPs : - PIM activations - assignment writes by the PIM
// provisioning identity; exclude via TrustedCallers
// - IaC pipelines - resource group scope carries no scope
// weight and IP novelty applies to human callers only
// - Onboarding - a fresh grantee alone scores 2 and cannot
// fire without a privileged role or broad scope on top
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-10
// =====================================================================
let DetectionWindow = 1d; // rule runs daily; only events inside the last run interval are scored
let BaselineWindow = 14d; // depth of caller network history; must equal the rule query period
let FreshAccountDays = 7d; // grantee accounts younger than this count as fresh
let IdentityLookback = 14d; // how far back IdentityInfo snapshots are read
let FireThreshold = 5; // minimum RiskScore for a row to alert
let TrustedCallers = dynamic([]); // UPNs or app ids of vetted assigners; add the PIM provisioning identity here if PIM manages Azure roles
// Scoring weights - serious signals clear the threshold alone or in pairs, soft signals only stack
let W_RootScope = 5; // assignment at tenant root or management group scope
let W_SubScope = 2; // assignment at subscription root scope
let W_ControlPlaneRole = 4; // role able to mint further role assignments
let W_HighImpactRole = 2; // role with broad write or sensitive data plane reach
let W_UnlistedRole = 1; // role outside the tier lists, counted at broad scope only
let W_SelfGrant = 3; // caller granted the role to their own object id
let W_GuestGrantee = 3; // role landed on a B2B guest account
let W_FreshGrantee = 2; // role landed on an account created within FreshAccountDays
let W_NewCallerIp = 1; // human caller operating ARM from a network with no prior history
// Role tier lists - extend with the role ids that matter in your estate
let RoleTiers = datatable(RoleId: string, RoleName: string, RoleTier: string) [
"8e3af657-a8ff-443c-a75c-2fe8c4bcb635", "Owner", "ControlPlane",
"18d7d88d-d35e-4fb5-a5c3-7773c20a72d9", "User Access Administrator", "ControlPlane",
"f58310d9-a9f6-439a-9e8d-f62e7b41a168", "Role Based Access Control Administrator", "ControlPlane",
"b24988ac-6180-42a0-ab88-20f7382dd24c", "Contributor", "HighImpact",
"00482a5a-887f-4fb3-b363-3b7fe8e74483", "Key Vault Administrator", "HighImpact",
"fb1c8493-542b-48eb-b624-b4c8fea62acd", "Security Admin", "HighImpact",
"b7e6dc6d-f1e8-4753-8033-0f276bb0955b", "Storage Blob Data Owner", "HighImpact"
];
// Helper - reduce an IPv4 address to its /24 prefix so ISP churn inside one network does not read as novelty
let IpPrefix = (ip: string) {
iff(ip contains ".", strcat_array(array_slice(split(ip, "."), 0, 2), "."), ip)
};
// Lookup - every network each caller used in the 13 days before the detection window, so events cannot baseline themselves
let CallerNetworkHistory = AzureActivity
| where TimeGenerated between (ago(BaselineWindow) .. ago(DetectionWindow))
| where isnotempty(Caller) and isnotempty(CallerIpAddress)
| project Caller, CallerIpAddress
| extend KnownPrefix = IpPrefix(CallerIpAddress)
| summarize KnownPrefixes = make_set(KnownPrefix, 500) by Caller;
// Lookup - latest identity snapshot per object id, used to enrich the grantee
let IdentitySnapshot = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| extend AccountObjectId = tolower(AccountObjectId)
| summarize arg_max(TimeGenerated, AccountUPN, AccountDisplayName, UserType, AccountCreationTime) by AccountObjectId
| project AccountObjectId, AccountUPN, AccountDisplayName, UserType, AccountCreationTime;
// Main pipeline - one row per completed role assignment, scored on positive evidence only
AzureActivity
| where TimeGenerated > ago(DetectionWindow)
| where OperationNameValue =~ "microsoft.authorization/roleassignments/write"
| where Caller !in~ (TrustedCallers)
| extend AssignmentPath = tolower(iff(isnotempty(_ResourceId), _ResourceId, tostring(parse_json(Properties).entity)))
| summarize
StartTime = min(TimeGenerated),
EndTime = max(TimeGenerated),
Statuses = make_set(ActivityStatusValue, 4),
Props = take_anyif(Properties, Properties has "requestbody"),
CallerClaims = take_anyif(Claims, Claims has "objectidentifier"),
CallerIpAddress = take_any(CallerIpAddress)
by AssignmentPath, Caller
| where Statuses has_any ("Success", "Succeeded")
| where isnotempty(Props)
| extend Body = parse_json(tostring(parse_json(Props).requestbody))
| extend PrincipalId = tolower(tostring(coalesce(Body.Properties.PrincipalId, Body.properties.principalId)))
| extend PrincipalType = tostring(coalesce(Body.Properties.PrincipalType, Body.properties.principalType))
| extend RoleDefinitionPath = tostring(coalesce(Body.Properties.RoleDefinitionId, Body.properties.roleDefinitionId))
| extend Scope = tolower(tostring(coalesce(Body.Properties.Scope, Body.properties.scope, split(AssignmentPath, "/providers/microsoft.authorization/roleassignments/")[0])))
| extend RoleId = tolower(tostring(split(RoleDefinitionPath, "/")[-1]))
| where isnotempty(PrincipalId) and isnotempty(RoleId)
| extend CallerObjectId = tolower(tostring(parse_json(CallerClaims)["http://schemas.microsoft.com/identity/claims/objectidentifier"]))
| extend CallerIpPrefix = IpPrefix(CallerIpAddress)
| extend ScopeTier = case(
Scope == "/" or Scope startswith "/providers/microsoft.management/managementgroups", "TenantRootOrMG",
Scope matches regex @"^/subscriptions/[0-9a-f-]+$", "Subscription",
Scope has_cs "resourcegroups" and not(Scope has_cs "providers"), "ResourceGroup",
"Resource")
| lookup kind=leftouter (RoleTiers) on RoleId
| join kind=leftouter (IdentitySnapshot) on $left.PrincipalId == $right.AccountObjectId
| join kind=leftouter (CallerNetworkHistory) on Caller
| extend S_RootScope = iff(ScopeTier == "TenantRootOrMG", W_RootScope, 0)
| extend S_SubScope = iff(ScopeTier == "Subscription", W_SubScope, 0)
| extend S_ControlPlane = iff(RoleTier == "ControlPlane", W_ControlPlaneRole, 0)
| extend S_HighImpact = iff(RoleTier == "HighImpact", W_HighImpactRole, 0)
| extend S_Unlisted = iff(isempty(RoleTier) and ScopeTier in ("TenantRootOrMG", "Subscription"), W_UnlistedRole, 0)
| extend S_SelfGrant = iff(isnotempty(CallerObjectId) and CallerObjectId == PrincipalId, W_SelfGrant, 0)
| extend S_Guest = iff(UserType =~ "Guest", W_GuestGrantee, 0)
| extend S_Fresh = iff(isnotempty(AccountCreationTime) and AccountCreationTime > ago(FreshAccountDays), W_FreshGrantee, 0)
| extend S_NewIp = iff(Caller contains "@" and isnotempty(KnownPrefixes) and not(set_has_element(KnownPrefixes, CallerIpPrefix)), W_NewCallerIp, 0)
| extend RiskScore = S_RootScope + S_SubScope + S_ControlPlane + S_HighImpact + S_Unlisted + S_SelfGrant + S_Guest + S_Fresh + S_NewIp
| where RiskScore >= FireThreshold
| extend Risk_1 = iff(S_RootScope > 0, "Assignment at tenant root or management group scope", "")
| extend Risk_2 = iff(S_SubScope > 0, "Assignment at subscription scope", "")
| extend Risk_3 = iff(S_ControlPlane > 0, strcat("Control plane role: ", RoleName), "")
| extend Risk_4 = iff(S_HighImpact > 0, strcat("High impact role: ", RoleName), "")
| extend Risk_5 = iff(S_Unlisted > 0, "Unclassified role at broad scope", "")
| extend Risk_6 = iff(S_SelfGrant > 0, "Caller granted the role to their own account", "")
| extend Risk_7 = iff(S_Guest > 0, "Grantee is a guest account", "")
| extend Risk_8 = iff(S_Fresh > 0, strcat("Grantee account created ", format_datetime(AccountCreationTime, "yyyy-MM-dd")), "")
| extend Risk_9 = iff(S_NewIp > 0, "Caller network not seen in the baseline window", "")
| 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, " | "), ""),
iff(isnotempty(Risk_9), strcat(Risk_9, " | "), "")))
| extend GranteeDisplay = coalesce(AccountDisplayName, AccountUPN, PrincipalId)
| extend Name = tostring(split(Caller, "@", 0)[0])
| extend UPNSuffix = tostring(split(Caller, "@", 1)[0])
| project StartTime, EndTime, Caller, CallerIpAddress, Name, UPNSuffix,
PrincipalId,
PrincipalType = iff(isnotempty(PrincipalType), PrincipalType, iff(isnotempty(AccountUPN), "User", "Unknown")),
GranteeUpn = AccountUPN, GranteeDisplay,
GranteeUserType = UserType, GranteeCreated = AccountCreationTime,
RoleName = coalesce(RoleName, strcat("Unlisted role ", RoleId)), RoleId,
Scope, ScopeTier, RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime 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
- Run the pipeline over fourteen days with the score gate removed and summarise by Caller; that surfaces the PIM provisioner and every pipeline identity, and decides whether the rule is usable at all.
- Extend the RoleTiers datatable with your crown jewel roles; AKS admin and container registry push are common candidates.
- Set the guest weight against your collaboration reality, then leave it for two weeks and judge the output, not the theory.
- Confirm every subscription exports activity logs to the workspace, because the sharpest rule cannot score events that never arrive.
Class dismissed.