~/2025/11/19/powershell-azure-audit-who-has-owner-role-across-all-subscriptions.md
PowerShell: Azure – Audit Who Has Owner Role Across All Subscriptions
--- author: Tom Lasswell date: read: 4 min in: [ps, scripts] tags: [powershell, azure] ---
$ grep -n '^#' post.md
Owner is the role everyone means to review quarterly and almost nobody does, because doing it by hand means clicking into every subscription's Access Control blade one at a time. Assignments also pile up from management-group inheritance, so a principal can be an Owner on a subscription without any assignment visible on that subscription at all, and PIM-eligible Owners don't show up in the usual list until they activate. This script enumerates every subscription the running credential can see, pulls every Owner assignment that affects it (inherited from above, on the subscription, or on a resource group or resource inside it), optionally adds PIM-eligible Owners, and flattens the result into one CSV so you can actually review it. It pairs with the access model in Azure: Landing Zone Design for a Mid-Size Company, Three Years In, where Owner is supposed to live only at a handful of scopes.
Requirements
- PowerShell 7.x with the
Az.AccountsandAz.Resourcesmodules (the PIM cmdletsGet-AzRoleEligibilityScheduleship inAz.Resources). Microsoft.Authorization/roleAssignments/readon every subscription you want audited. Reader on the Tenant Root Group (the root management group) covers every management group and subscription in the tenant in one assignment. Note that the Entra ID Global Reader role does not grant this: it is a directory role, not an Azure RBAC role. A Global Administrator who lacks Azure access can temporarily elevate access to get User Access Administrator at root, grant Reader, and remove the elevation.- Directory read access in Microsoft Graph so
Get-AzRoleAssignmentcan resolve display names and object types. Without it, rows come back withObjectTypeset toUnknown. - An authenticated Az session (
Connect-AzAccount), interactively or as a service principal for scheduled runs.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
OutputPath | String | Yes | Path to write the CSV report. |
IncludeEligible | Switch | No | Also reports PIM-eligible Owner assignments from Get-AzRoleEligibilitySchedule. |
Usage
Sign in and run the audit against every subscription visible to the session:
Connect-AzAccount
.\Get-AzureOwnerAudit.ps1 -OutputPath "C:\Audits\azure-owners_2025-11-19.csv"
Auditing 14 subscription(s)...
Production - East : 5 Owner row(s) (2 inherited, 2 subscription, 1 below)
Production - West : 4 Owner row(s) (2 inherited, 1 subscription, 1 below)
Sandbox - Dev Team : 9 Owner row(s) (2 inherited, 6 subscription, 1 below)
...
61 row(s), 23 unique principal(s) across 14 of 14 subscription(s). Report written to C:\Audits\azure-owners_2025-11-19.csv
Add PIM-eligible Owners, which the plain role assignment list never shows:
.\Get-AzureOwnerAudit.ps1 -OutputPath "C:\Audits\azure-owners_2025-11-19.csv" -IncludeEligible
Then triage the CSV. Start with rows nobody can explain: unresolved principals (usually deleted accounts), direct user assignments at subscription scope, and service principals holding Owner:
$rows = Import-Csv -Path "C:\Audits\azure-owners_2025-11-19.csv"
$rows | Where-Object { $_.PrincipalType -eq "Unknown" } | Format-Table SubscriptionName, PrincipalObjectId, AssignmentScope
$rows | Where-Object { $_.ScopeLevel -eq "Subscription" -and $_.PrincipalType -eq "User" -and $_.AssignmentState -eq "Active" } | Format-Table SubscriptionName, PrincipalName
$rows | Where-Object { $_.PrincipalType -eq "ServicePrincipal" } | Sort-Object PrincipalName -Unique | Format-Table PrincipalName, AssignmentScope
Script
<#
.SYNOPSIS
Reports every Owner role assignment that affects each Azure subscription visible to the current session.
.DESCRIPTION
Enumerates subscriptions with Get-AzSubscription and, for each one that is Enabled, Warned or
PastDue (Disabled and Expired ones are counted and skipped), calls
Get-AzRoleAssignment -RoleDefinitionName Owner in that subscription's context. Without a scope,
Get-AzRoleAssignment returns assignments inherited from the root and management groups, assignments
on the subscription, and assignments on resource groups and resources inside it; each row is labelled
with ScopeLevel (Inherited, Subscription, ResourceGroup, Resource) so they can be told apart. Rows
whose description matches the automatic classic administrator conversion are flagged. With
-IncludeEligible, PIM-eligible Owner assignments are added from Get-AzRoleEligibilitySchedule, which
Get-AzRoleAssignment does not return. Writes one CSV.
.PARAMETER OutputPath
Path to write the CSV report.
.PARAMETER IncludeEligible
Also reports PIM-eligible Owner assignments from Get-AzRoleEligibilitySchedule.
.EXAMPLE
.\Get-AzureOwnerAudit.ps1 -OutputPath "C:\Audits\azure-owners_2025-11-19.csv" -IncludeEligible
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-11-19)
Requires: Az.Accounts, Az.Resources, roleAssignments/read on every audited subscription
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[Parameter(Mandatory = $false)]
[switch]$IncludeEligible
)
$ErrorActionPreference = 'Stop'
# Built-in Owner role definition ID (same in every tenant).
$ownerRoleId = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635"
$classicConversionText = "The Classic Admin role was converted to an Azure Owner role"
function Get-ScopeLevel {
param([string]$Scope, [string]$SubscriptionScope)
if ($Scope -eq "/" -or $Scope -like "/providers/Microsoft.Management/managementGroups/*") {
return "Inherited"
}
if ($Scope -eq $SubscriptionScope) {
return "Subscription"
}
if ($Scope -match "^/subscriptions/[^/]+/resourceGroups/[^/]+$") {
return "ResourceGroup"
}
return "Resource"
}
$allSubscriptions = @(Get-AzSubscription)
$subscriptions = @($allSubscriptions | Where-Object { $_.State -in @("Enabled", "Warned", "PastDue") })
$inactiveCount = $allSubscriptions.Count - $subscriptions.Count
if ($inactiveCount -gt 0) {
Write-Warning "Skipping $inactiveCount Disabled or Expired subscription(s)."
}
Write-Host "Auditing $($subscriptions.Count) subscription(s)..."
$results = [System.Collections.Generic.List[object]]::new()
$auditedCount = 0
foreach ($subscription in $subscriptions) {
$subscriptionScope = "/subscriptions/$($subscription.Id)"
try {
Set-AzContext -SubscriptionId $subscription.Id | Out-Null
$ownerAssignments = @(Get-AzRoleAssignment -RoleDefinitionName "Owner" |
Where-Object { $_.RoleDefinitionId -like "*$ownerRoleId" })
} catch {
Write-Warning "Skipping '$($subscription.Name)': $($_.Exception.Message)"
continue
}
$auditedCount++
foreach ($assignment in $ownerAssignments) {
$results.Add([PSCustomObject]@{
SubscriptionName = $subscription.Name
SubscriptionId = $subscription.Id
PrincipalName = $assignment.DisplayName
SignInName = $assignment.SignInName
PrincipalType = $assignment.ObjectType
PrincipalObjectId = $assignment.ObjectId
AssignmentState = "Active"
AssignmentScope = $assignment.Scope
ScopeLevel = Get-ScopeLevel -Scope $assignment.Scope -SubscriptionScope $subscriptionScope
EndDateTime = $null
HasCondition = -not [string]::IsNullOrEmpty($assignment.Condition)
ClassicAdminConvert = "$($assignment.Description)" -like "$classicConversionText*"
})
}
if ($IncludeEligible) {
try {
$eligible = @(Get-AzRoleEligibilitySchedule -Scope $subscriptionScope |
Where-Object { $_.RoleDefinitionId -like "*$ownerRoleId" })
} catch {
Write-Warning "Could not read PIM eligibility for '$($subscription.Name)': $($_.Exception.Message)"
$eligible = @()
}
foreach ($schedule in $eligible) {
$results.Add([PSCustomObject]@{
SubscriptionName = $subscription.Name
SubscriptionId = $subscription.Id
PrincipalName = $schedule.PrincipalDisplayName
SignInName = $schedule.PrincipalEmail
PrincipalType = $schedule.PrincipalType
PrincipalObjectId = $schedule.PrincipalId
AssignmentState = "Eligible"
AssignmentScope = $schedule.Scope
ScopeLevel = Get-ScopeLevel -Scope $schedule.Scope -SubscriptionScope $subscriptionScope
EndDateTime = $schedule.EndDateTime
HasCondition = -not [string]::IsNullOrEmpty($schedule.Condition)
ClassicAdminConvert = $false
})
}
}
$subscriptionRows = @($results | Where-Object { $_.SubscriptionId -eq $subscription.Id })
$inheritedCount = @($subscriptionRows | Where-Object { $_.ScopeLevel -eq "Inherited" }).Count
$directCount = @($subscriptionRows | Where-Object { $_.ScopeLevel -eq "Subscription" }).Count
$belowCount = $subscriptionRows.Count - $inheritedCount - $directCount
Write-Host "$($subscription.Name) : $($subscriptionRows.Count) Owner row(s) ($inheritedCount inherited, $directCount subscription, $belowCount below)"
}
$results | Export-Csv -Path $OutputPath -NoTypeInformation
$uniquePrincipals = @($results | Select-Object -ExpandProperty PrincipalObjectId -Unique).Count
Write-Host "$($results.Count) row(s), $uniquePrincipals unique principal(s) across $auditedCount of $($subscriptions.Count) subscription(s). Report written to $OutputPath"
Notes
- Why the scope column matters. Called without
-Scope,Get-AzRoleAssignmentreturns every assignment in the subscription, including ones inherited from the root and management groups and ones made on resource groups and resources below it. An earlier draft of this script labelled everything that was not on the subscription itself as "inherited", which lumped resource-group Owners in with management-group Owners.ScopeLevelseparates them. An Owner on a single resource group is a much smaller problem than an Owner on the management group above twelve subscriptions. - Inherited assignments appear once per subscription they reach, so a management-group Owner shows up on every row for every subscription under that group. That is deliberate (it is effective Owner on each), but deduplicate by
PrincipalObjectIdandAssignmentScopebefore counting how many Owner grants actually exist. - PIM-eligible assignments are invisible to
Get-AzRoleAssignment. It lists active assignments only; Microsoft's PIM integration guidance says to useGet-AzRoleEligibilitySchedulefor eligible assignments andGet-AzRoleAssignmentSchedulefor active time-bound ones. An eligible Owner has the same access as a permanent one once they activate, so leave-IncludeEligibleon for access reviews. Eligible assignments can't be created for service principals or managed identities, because they can't perform the activation step. - Time-bound active Owners look permanent in this report. While an active time-bound assignment is in effect,
Get-AzRoleAssignmentreturns it like any other active assignment, but the objects it returns (PSRoleAssignment) carry no start or end date, so the CSV can't tell a 30-day grant from a permanent one. If end dates matter to the review, list them withGet-AzRoleAssignmentSchedule -Scope /subscriptions/<subscriptionId> | Where-Object { $_.EndDateTime -ne $null }, the query Microsoft's PIM integration guidance uses for active time-bound assignments. - Classic administrators are gone. Microsoft retired the Co-Administrator and Service Administrator roles on August 31, 2024, began automatically converting remaining ones to Owner at subscription scope in December 2025, and removed the Classic Administrators blade in May 2026. There is no longer anything useful behind
Get-AzRoleAssignment -IncludeClassicAdministrators. The converted assignments carry the description "The Classic Admin role was converted to an Azure Owner role on behalf of the user due to Classic Admin retirement", which the script surfaces asClassicAdminConvert = True. Those rows deserve the first look: they are usually Owner grants nobody consciously made. - A group listed as an Owner is not expanded to its members;
PrincipalTypeshowsGroup. Resolve transitive membership in Entra ID for a named access review. For a single user,Get-AzRoleAssignment -SignInName <upn> -ExpandPrincipalGroupslists roles assigned to them and to groups they belong to. PrincipalType = UnknownmeansGet-AzRoleAssignmentcould not resolve the object in Microsoft Graph: either the principal was deleted and left an orphaned assignment behind, or the running account lacks directory read permission. Check the second case before deleting anything.- The script sees only what the credential can read. A subscription it can't list is simply missing from the report rather than reported as an error, so check the subscription count on the first line against what you expect. One it can list but not read is skipped with a warning, and the final line then shows fewer audited than attempted (
12 of 14). - Assignments made by a service provider through Azure Lighthouse are not returned by
Get-AzRoleAssignmentin the customer tenant. Review those delegations separately. HasConditionmarks assignments that carry a condition. Microsoft's guidance for replacing converted classic administrators is to prefer a job-function role with fewer permissions, a narrower scope, or an Owner assignment with a condition; the column shows which Owner grants have already been constrained that way.
Source
- Get-AzRoleAssignment
- List Azure role assignments using Azure PowerShell
- Eligible and time-bound role assignments in Azure RBAC
- Get-AzRoleAssignmentSchedule
- Get-AzRoleEligibilitySchedule
- Azure classic subscription administrators (retirement)
- Azure built-in roles: Privileged (Owner role definition)
- Elevate access to manage all Azure subscriptions and management groups