Audit Azure VM Storage and Ownership Fast with this KQL Query

Ever

Ever been asked to pull a report of specific Azure VMs, their OS versions, who owns them, and exactly how much storage they are chewing up? Clicking through the Azure Portal to piece this together is a massive time sink.

This is where Azure Resource Graph (ARG) shines. I use the KQL query below whenever I need to quickly audit environments for cost allocation, cleanup projects, or basic inventory tracking.

Why this query is useful:

  • Calculates real storage: It grabs the OS disk and iterates through all attached data disks to give you a true TotalCombinedStorageGB metric. It also safely handles edge cases, like VMs with zero data disks, without dropping them from the report.
  • Makes it readable: It joins backend tables to swap the ugly Subscription ID for the human-readable Subscription Name.
  • Filters like a pro: It extracts the Owner tag and the OSVersion, allowing you to easily filter for specific teams or operating systems using fuzzy matches and lists.

How to use it:

  1. Open Resource Graph Explorer in the Azure Portal.
  2. Paste the KQL query below.
  3. Update the YOUR FILTERS section with your target OS and Owner names.
  4. Hit Run and export your clean table directly to a CSV!
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend VMName = tostring(name)
| extend Region = tostring(location)
| extend ResourceGroup = tostring(resourceGroup)
| extend OSType = tostring(properties.storageProfile.osDisk.osType)
| extend OSVersion = strcat(tostring(properties.storageProfile.imageReference.offer), " ", tostring(properties.storageProfile.imageReference.sku))
| extend Owner = tostring(tags['Owner'])
// --- YOUR FILTERS ---
| where OSVersion contains "<ENTER_OS_VERSION_HERE>"
// The in~ operator checks against a list of values
| where Owner in~ ("<OWNER_1>", "<OWNER_2>", "<OWNER_3>")
// --------------------
| extend OSDiskSizeGB = toint(properties.storageProfile.osDisk.diskSizeGB)
| extend OSDiskSizeGB = iff(isnull(OSDiskSizeGB), 0, OSDiskSizeGB)
| extend DataDisks = properties.storageProfile.dataDisks
| extend DataDisks = iff(isnull(DataDisks) or array_length(DataDisks) == 0, dynamic([{"diskSizeGB": 0}]), DataDisks)
| mv-expand DataDisks
| extend DataDiskSizeGB = toint(DataDisks.diskSizeGB)
| extend DataDiskSizeGB = iff(isnull(DataDiskSizeGB), 0, DataDiskSizeGB)
| summarize TotalDataDiskSizeGB = sum(DataDiskSizeGB) by id, VMName, ResourceGroup, subscriptionId, Region, OSType, OSVersion, Owner, OSDiskSizeGB
| extend TotalCombinedStorageGB = OSDiskSizeGB + TotalDataDiskSizeGB
| join kind=leftouter (
    ResourceContainers
    | where type =~ 'microsoft.resources/subscriptions'
    | project subscriptionId, SubscriptionName = tostring(name)
) on subscriptionId
| project
    VMName,
    ResourceGroup,
    SubscriptionName,
    Region,
    OSType,
    OSVersion,
    Owner,
    OSDiskSizeGB,
    TotalDataDiskSizeGB,
    TotalCombinedStorageGB
| order by VMName asc

Query Azure MySQL Audit Logs

The common practice for user management in Azure AD is to create Azure AD Security Groups for users and assign permissions to groups rather than individual users. For example, the common practice for users who need access to Azure MySQL instance would to be add them to an Azure AD Group (say MySqlDb_Users) and then setup this Azure AD Group for access on Azure MySQL Instance.

Pre-requisite

  • Ensure that Audit Logs are enabled on all the production instances for Azure MySql as documented here — https://docs.microsoft.com/en-us/azure/mysql/howto-configure-audit-logs-portal
  • Make sure to enable Auditing on Server Parameters section (at least Connection audit log event type).
  • The logs can be saved to Storage Account, Event Hubs or Azure Log Analytics Workspace. We used Log Analytics Workspace for the runbook because it provides rich querying capabilities.

The following Log Analytics Query and the corresponding screenshot shows that individual user activity is tracked in the Audit Logs in spite of them using the Azure AD Group as username to connect Azure MySQL — external_user_s field logs the exact individual user, connection_id_d and thread_id_d carry over the Connection/ThreadId which can be used to tie the user across all log records.

AzureDiagnostics
| where Category == "MySqlAuditLogs"
| order by TimeGenerated desc
| project TimeGenerated, event_class_s, event_subclass_s, user_s, external_user_s, connection_id_d, thread_id_d, event_time_t, sql_text_s, db_s, table_name_s, error_code_d