~/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: 
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.Accounts and Az.Resources modules (the PIM cmdlets Get-AzRoleEligibilitySchedule ship in Az.Resources).
  • Microsoft.Authorization/roleAssignments/read on 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-AzRoleAssignment can resolve display names and object types. Without it, rows come back with ObjectType set to Unknown.
  • An authenticated Az session (Connect-AzAccount), interactively or as a service principal for scheduled runs.

Parameters

NameTypeRequiredDescription
OutputPathStringYesPath to write the CSV report.
IncludeEligibleSwitchNoAlso reports PIM-eligible Owner assignments from Get-AzRoleEligibilitySchedule.

Usage

Sign in and run the audit against every subscription visible to the session:

powershell
Connect-AzAccount

.\Get-AzureOwnerAudit.ps1 -OutputPath "C:\Audits\azure-owners_2025-11-19.csv"
text
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:

powershell
.\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:

powershell
$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

powershell
<#
.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-AzRoleAssignment returns 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. ScopeLevel separates 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 PrincipalObjectId and AssignmentScope before 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 use Get-AzRoleEligibilitySchedule for eligible assignments and Get-AzRoleAssignmentSchedule for active time-bound ones. An eligible Owner has the same access as a permanent one once they activate, so leave -IncludeEligible on 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-AzRoleAssignment returns 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 with Get-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 as ClassicAdminConvert = 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; PrincipalType shows Group. Resolve transitive membership in Entra ID for a named access review. For a single user, Get-AzRoleAssignment -SignInName <upn> -ExpandPrincipalGroups lists roles assigned to them and to groups they belong to.
  • PrincipalType = Unknown means Get-AzRoleAssignment could 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-AzRoleAssignment in the customer tenant. Review those delegations separately.
  • HasCondition marks 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