KQLMicrosoft SentinelDetection Engineering
Would You Know If Someone Cancelled Your Azure Subscription?
Alright class.
Yet another lesson in this series, and this one breaks the format. There is no Microsoft rule to fix today, because Microsoft does not ship one. Search the content hub for anything covering subscription cancellation and you come back empty. That is remarkable, because cancelling a subscription is one click that switches off every resource inside it. Firewalls, domain controllers, log collectors, and, if your architecture is unlucky, the Sentinel workspace that was supposed to raise the alarm.
So the question for today is simple. Would you know if someone cancelled one of your Azure subscriptions? In most environments I have seen, the honest answer is no. You would find out when something downstream stops responding, or when the portal greets you with a disabled subscription banner the next morning.
What cancellation actually does
A subscription does not go from alive to deleted in one step. Cancellation moves it to a disabled state. Virtual machines are deallocated, temporary IP addresses are freed, storage drops to read only, and billing stops. Microsoft then holds the data for 30 to 90 days before permanent deletion, and a pay-as-you-go subscription can be reactivated from the portal during that window.
That retention window is why people treat cancellation as a soft action. From a business continuity view it is recoverable. From a security view it is an outage of everything in the subscription, triggered by a single privileged identity, and the recovery path can take hours once billing support gets involved. An attacker does not need to delete your data to hurt you. Stopping it is enough, and cancellation stops all of it at once.
Only a subscription Owner can do this. That narrows the suspect pool to a handful of identities, which is exactly the property a high fidelity detection wants.
Where the evidence lands
The cancellation writes an Administrative event into the Activity log, which arrives in the AzureActivity table if the subscription (or better, the management group) exports its Activity log to your workspace. The operation to watch is this one:
| where OperationNameValue =~ "Microsoft.Subscription/cancel/action"
The event carries the caller UPN, the caller IP, the subscription ID, and a hierarchy property that tells you which management group the subscription sat under. Failed attempts land too, usually because the portal refused the cancellation while non-cancellable resources still existed. Do not dismiss the failures either. Someone tried to switch off a subscription and Azure stopped them. I want to know who.

The race you might lose
Now the uncomfortable part, and the reason this post exists. If the subscription being cancelled is the one hosting your Sentinel workspace, the detection has to win a race. The event must be exported to the workspace, ingested, picked up by the next scheduled run, and turned into an incident before the platform disables the resources in that subscription. Activity log export takes minutes on a good day, and the subscription state flips within roughly ten minutes of the click.
I have not found documentation that settles whether a scheduled rule still runs inside a freshly cancelled subscription, so I decided to test it out on one of the tenants I am using; the results are actually reassuring.
At 20:26, I cancelled the subscription

Logs came into the Azure Activity table roughly 1 minute later (this is actually a surprise to me)
At 20:36, an incident came in ready to investigate and look at. Subscription was still ready to use at that time but it could be only due to a fact that I had Sentinel open and ready to use.

The architectural fix for all of this is boring and correct: put your security tooling in its own subscription, export Activity logs at management group scope into that workspace, and the rule survives whichever customer or workload subscription dies.
False positives, designed for up front
Two benign processes produce this telemetry. The first is planned decommissioning. A cloud administrator retires a sandbox or a project subscription, usually announced in a change record nobody on the SOC has read. The rule handles this with the TrustedCancellers list: a successful cancellation by a listed account scores 4 and stays silent, unless amplifiers stack on top. I would leave that list empty in most tenants. Cancellations are rare enough that one incident per decommissioning, closed as expected activity, is a fair price for never missing the real one.
The second is cleanup friction. Administrators tidying a tenant hit the non-cancellable resources error and generate failed attempts. Those score 6 on their own and stay below the threshold. They only fire when something else is wrong with the picture, such as the caller being a guest or the target hosting your monitoring stack.
The Rebuild
The gate is the standard series pattern:
| where RiskScore >= FireThreshold
A successful cancellation scores 10 and fires alone, every time, because the event is rare and always deserves eyes. A trusted account scores 4. A failed attempt scores 6 and needs company. On top of the base sit four amplifiers: the target subscription hosts your security tooling (+5, driven by the MonitoringSubscriptions list), the caller was granted Owner on that subscription inside the last 14 days (+4, pulled from role assignment events in AzureActivity), the caller is a guest (+3), and the caller IP sits outside your declared ranges (+2, only when you populate TrustedIpRanges).
Degradation is graceful. Without UEBA there is no IdentityInfo, so the guest and fresh Owner signals go silent and the caller enrichment columns come back empty, but the base signal still fires. No signal in this rule turns a missing lookup into a false alarm. Saying that the UEBA check won't work against 14 days when the rule is scheduled to run every 5-15 minutes, so it may not work, I am adding it as my personal best practice.
The RiskIndicators Field
A worked example of what the analyst sees:
Subscription cancelled [+10] | Subscription hosts security tooling [+5] | Caller granted Owner on 2026-07-14 [+4] | Caller IP outside trusted ranges [+2]
That combination is the one to chase immediately. An identity that became Owner this week and then cancelled the subscription holding your monitoring stack is not doing housekeeping. Treat it as hands-on-keyboard until proven otherwise.
The Blind Spots You Should Know About
The rule only sees subscriptions whose Activity logs reach the workspace. A subscription nobody connected is invisible, which is an argument for the management group diagnostic setting rather than per-subscription wiring. The fresh Owner signal only covers grants made at subscription scope; an attacker inheriting Owner from a management group assignment slips past that amplifier, though the base signal still fires. Cancellations driven from the billing side, such as a CSP partner cancelling an Azure plan entitlement from Partner Center, may surface differently or not at all in AzureActivity, and I have not tested that path. And the race condition above stands until proven otherwise: if your only workspace lives in the cancelled subscription, assume the alert may never leave the building.
MITRE Mappings for the Updated Rule
Impact, T1489 (Service Stop): cancellation stops every service in the subscription, which is the direct effect an attacker buys with the click. Defense Evasion, T1562.008 (Impair Defenses: Disable Cloud Logs): cancelling a subscription that hosts log collection or Sentinel silences your telemetry pipeline, which is why the MonitoringSubscriptions amplifier exists.
Rule Settings
Frequency: 5-15 minutes, because speed is the entire point of this rule. Query period: 1 days (as that's the maximum we can get when choosing low frequency) Severity: High. Event grouping: alert per result. Incident grouping: enabled, by Account entity, 6 hour window. Entity mappings: Account (Name, UPNSuffix), IP (CallerIpAddress), Azure resource (subscription resource ID). Custom details: SubscriptionId, ManagementGroup, CancelStatus, CallerDepartment, CallerJobTitle, CallerUserType, RiskScore, RiskIndicators.
KQL
// =====================================================================
// Azure Subscription Cancellation or Cancellation Attempt
// =====================================================================
// Description : Detects successful and failed Azure subscription
// cancellations from Activity log data, scored with weighted
// risk signals (monitoring subscription target, fresh Owner
// grant, guest caller, unknown IP); planned decommissioning
// is suppressed via a trusted canceller list scoring below
// the fire threshold.
// Type : Detection
//
// Tables : AzureActivity, IdentityInfo
// Connectors : Azure Activity (per subscription or management group
// diagnostic settings); UEBA for IdentityInfo
// License : Any Sentinel-enabled workspace; Entra ID P2 with UEBA
// adds guest detection and caller enrichment
//
// Tuning : - Set the rule query period to P14D; the 14d Owner grant
// and identity lookups only resolve if the rule itself
// looks back that far
// - DetectionWindow - events scored per run; keep wider
// than the frequency to absorb Activity log latency
// - MonitoringSubscriptions - subscription IDs hosting
// Sentinel or log collection; powers the +5 amplifier
// - TrustedCancellers - decommissioning accounts whose
// successful cancellations score below threshold
// - TrustedIpRanges - corporate egress CIDRs; empty
// disables the IP amplifier entirely
//
// Known FPs : - Planned decommissioning by cloud administrators; list
// the account in TrustedCancellers to score it at 4
// - Failed attempts from tenant cleanup hitting the
// non-cancellable resources error; score 6 alone and
// stay silent without an amplifier
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-20
// =====================================================================
// Tuning variables
let DetectionWindow = 15m; // window scored per run; wider than the 5 minute frequency to absorb Activity log latency
let OwnerGrantLookback = 1d; // history window for fresh Owner grants to the caller
let IdentityLookback = 1d; // IdentityInfo snapshot window
let MonitoringSubscriptions = dynamic([]); // subscription IDs hosting Sentinel, Log Analytics or other security tooling
let TrustedCancellers = dynamic([]); // accounts permitted to cancel subscriptions under a documented decommissioning process
let TrustedIpRanges = dynamic([]); // corporate egress ranges in CIDR notation; leave empty to disable the IP check
// Scoring: weighted signals feed an additive RiskScore gated by FireThreshold
let FireThreshold = 10;
let W_CancelSuccess = 10; // successful cancellation, clears the threshold alone
let W_CancelTrusted = 4; // successful cancellation by a trusted decommissioning account
let W_CancelFailed = 6; // blocked attempt, needs at least one amplifier
let W_MonitoringSub = 5; // the target subscription hosts your security tooling
let W_FreshOwner = 4; // caller received Owner on the target subscription inside the lookback
let W_GuestCaller = 3; // caller is a guest account
let W_UnknownIp = 2; // caller IP sits outside your declared ranges
let OwnerRoleId = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635"; // built-in Owner role definition ID
// Latest identity snapshot per account for caller enrichment; absent UEBA degrades to silence
let Identity = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| extend JoinUpn = tolower(AccountUPN)
| summarize arg_max(TimeGenerated, AccountObjectId, AccountDisplayName, Department, JobTitle, UserType) by JoinUpn;
// Owner role grants inside the lookback, resolved to principal and subscription
let FreshOwnerGrants = AzureActivity
| where TimeGenerated > ago(OwnerGrantLookback)
| where OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write"
| where ActivityStatusValue in ("Success", "Succeeded")
| where Properties has OwnerRoleId
| extend RequestBody = todynamic(tostring(parse_json(Properties).requestbody))
| extend GrantedPrincipalId = tolower(tostring(RequestBody.Properties.PrincipalId))
| where isnotempty(GrantedPrincipalId)
| summarize GrantTime = max(TimeGenerated) by GrantSubscriptionId = SubscriptionId, GrantedPrincipalId;
// Main pipeline: cancellation actions in the current detection window
AzureActivity
| where TimeGenerated > ago(DetectionWindow)
| where CategoryValue == "Administrative"
| where OperationNameValue in~ ("Microsoft.Subscription/cancel/action", "Microsoft.Subscription/subscriptions/cancel/action")
| where ActivityStatusValue in ("Success", "Failure", "Failed")
| extend Props = parse_json(Properties)
| extend ManagementGroup = tostring(split(tostring(Props.hierarchy), "/")[-2])
| extend JoinUpn = tolower(Caller)
| join kind=leftouter Identity on JoinUpn
| extend CallerObjectId = tolower(AccountObjectId)
| join kind=leftouter FreshOwnerGrants on $left.CallerObjectId == $right.GrantedPrincipalId, $left.SubscriptionId == $right.GrantSubscriptionId
| extend IsSuccess = ActivityStatusValue == "Success"
| extend IsTrusted = Caller in~ (TrustedCancellers)
| extend Risk_1 = case(
IsSuccess and IsTrusted, strcat("Cancelled by trusted decommissioning account [+", W_CancelTrusted, "]"),
IsSuccess, strcat("Subscription cancelled [+", W_CancelSuccess, "]"),
strcat("Cancellation attempt failed [+", W_CancelFailed, "]"))
| extend Risk_2 = iff(SubscriptionId in~ (MonitoringSubscriptions), strcat("Subscription hosts security tooling [+", W_MonitoringSub, "]"), "")
| extend Risk_3 = iff(isnotempty(GrantTime), strcat("Caller granted Owner on ", format_datetime(GrantTime, "yyyy-MM-dd"), " [+", W_FreshOwner, "]"), "")
| extend Risk_4 = iff(UserType == "Guest" or Caller has "#EXT#", strcat("Caller is a guest account [+", W_GuestCaller, "]"), "")
| extend Risk_5 = iff(isnotempty(CallerIpAddress) and array_length(TrustedIpRanges) > 0 and ipv4_is_in_any_range(CallerIpAddress, TrustedIpRanges) == false, strcat("Caller IP outside trusted ranges [+", W_UnknownIp, "]"), "")
| extend RiskScore =
case(IsSuccess and IsTrusted, W_CancelTrusted, IsSuccess, W_CancelSuccess, W_CancelFailed)
+ iff(isnotempty(Risk_2), W_MonitoringSub, 0)
+ iff(isnotempty(Risk_3), W_FreshOwner, 0)
+ iff(isnotempty(Risk_4), W_GuestCaller, 0)
+ iff(isnotempty(Risk_5), W_UnknownIp, 0)
| where RiskScore >= FireThreshold
| 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, " | "), "")))
| extend CancelStatus = iff(IsSuccess, "Cancelled", "Attempt failed")
| extend Name = tostring(split(Caller, "@", 0)[0])
| extend UPNSuffix = iff(Caller has "#EXT#", extract(@"_([^_]+)#EXT#", 1, Caller), tostring(split(Caller, "@", 1)[0]))
| extend SubscriptionResourceId = strcat("/subscriptions/", SubscriptionId)
| project TimeGenerated, Caller, Name, UPNSuffix, CallerIpAddress, SubscriptionId, SubscriptionResourceId, ManagementGroup, CancelStatus, AccountDisplayName, Department, JobTitle, UserType, RiskScore, RiskIndicators
| sort by RiskScore desc, 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
Check where your Activity logs actually go, because that decides whether this rule can work at all; a management group diagnostic setting into a workspace outside your workload subscriptions is the version that survives the race. Populate MonitoringSubscriptions with the subscription IDs holding Sentinel, Log Analytics and your collectors, since that list powers the highest value amplifier. Run the query over 90 days of history in Logs before deploying, so you see how often your own administrators trip the failed attempt path. And if you run a single subscription tenant, it will still work, but have a think about using better architecture and scope down your IAM.
Class dismissed.