Public/Discovery/Find-AzurePermissionHolder.ps1
function Find-AzurePermissionHolder { <# .SYNOPSIS Finds Azure users or service principals that have a specific permission. .DESCRIPTION The Find-AzurePermissionHolder function identifies all Azure users, groups, or service principals that have been assigned roles containing a specified permission. It searches across all subscriptions accessible to the authenticated user, unless limited by parameters. The function handles wildcards and permission hierarchies to ensure comprehensive results. .PARAMETER Permission The permission to search for within Azure roles (e.g. 'Microsoft.KeyVault/vaults/accessPolicies/write'). The function will find all principals with roles containing this permission. You can provide multiple permission patterns as an array. .PARAMETER IncludeGroups Includes group details in the results. By default, only users and service principals are returned. .PARAMETER SubscriptionId Limits the search to a specific subscription. If omitted, searches all accessible subscriptions. .PARAMETER PrincipalType Filters results by principal type. Valid options are: User, Group, ServicePrincipal. .PARAMETER OutputFormat Specifies the output format for results. Valid values are: - Object: Returns PowerShell objects (default) - JSON: Returns results in JSON format and saves to file - CSV: Returns results in CSV format and saves to file - Table: Returns results in formatted table Aliases: output, o .PARAMETER SkipCache Skips using the cache and forces a fresh API call. .OUTPUTS Returns a collection of custom objects with the following properties: - PrincipalId: The Azure AD Object ID of the principal - PrincipalType: The type of principal (User, Group, ServicePrincipal) - RoleName: The display name of the RBAC role - RoleDefinitionId: The ID of the role definition - Scope: The resource scope of the role assignment - MatchedPermissions: List of permissions granted by the role that matched the search criteria .EXAMPLE Find-AzurePermissionHolder -Permission "Microsoft.KeyVault/vaults/accessPolicies/write" Finds all users and service principals that have been assigned roles with key vault access policy write permissions. .EXAMPLE Find-AzurePermissionHolder -Permission "Microsoft.Compute/virtualMachines/start/action" -PrincipalType User -OutputFormat JSON Finds all users that can start virtual machines and outputs the results in JSON format. .EXAMPLE Find-AzurePermissionHolder -Permission @("Microsoft.Authorization/roleAssignments/write", "Microsoft.Authorization/roleDefinitions/write") -OutputFormat Table Finds all principals that can modify role assignments or role definitions, and outputs as a table. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] [string[]]$Permission, [Parameter(Mandatory = $false)] [switch]$IncludeGroups, [Parameter(Mandatory = $false)] [ValidatePattern('^[0-9a-fA-F-]{36}$', ErrorMessage = "It does not match expected pattern '{1}'")] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [ValidateSet('User', 'Group', 'ServicePrincipal')] [string]$PrincipalType, [Parameter(Mandatory = $false)] [ValidateSet("Object", "JSON", "CSV", "Table")] [Alias("output", "o")] [string]$OutputFormat = "Table", [Parameter(Mandatory = $false)] [switch]$SkipCache ) Write-Verbose "Starting function $($MyInvocation.MyCommand.Name)" # Use BlackCat authentication $MyInvocation.MyCommand.Name | Invoke-BlackCat $startTime = Get-Date # Initialize variables $results = @() $roleDefinitionsCache = @{} $principalCache = @{} Write-Host "🔍 Starting Azure Permission Holder Analysis..." -ForegroundColor Green Write-Host " 🎯 Searching for permission: $($Permission -join ', ')" -ForegroundColor Cyan # Create cache key for results $cacheKeyParams = @{ Permission = ($Permission -join '|') IncludeGroups = $IncludeGroups.IsPresent SubscriptionId = $SubscriptionId PrincipalType = $PrincipalType } $cacheKey = ConvertTo-CacheKey -BaseIdentifier "Find-AzurePermissionHolder" -Parameters $cacheKeyParams # Try to get cached results first if (-not $SkipCache) { $cachedResults = Get-BlackCatCache -Key $cacheKey -CacheType 'MSGraph' if ($null -ne $cachedResults) { Write-Host "📋 Retrieved permission holders from cache" -ForegroundColor Green # Return cached results in requested format using BlackCat's standard output formatter $formatParam = @{ Data = $cachedResults OutputFormat = $OutputFormat FunctionName = $MyInvocation.MyCommand.Name FilePrefix = "AzPermHolders-$($Permission[0].Split('/')[-1])" } return Format-BlackCatOutput @formatParam } } # Determine scope for role definitions query $scope = if ($SubscriptionId) { "/subscriptions/$SubscriptionId" } else { "/" # Tenant root scope } try { # Get role definitions via REST API $roleDefsUri = "https://management.azure.com$scope/providers/Microsoft.Authorization/roleDefinitions?api-version=2022-04-01" Write-Verbose "Fetching role definitions from: $roleDefsUri" $roleDefinitionsResponse = Invoke-RestMethod -Uri $roleDefsUri -Headers $script:authHeader -Method 'GET' -ErrorAction Stop $roleDefinitions = $roleDefinitionsResponse.value Write-Host " ✅ Retrieved $($roleDefinitions.Count) role definitions" -ForegroundColor Green } catch { Write-Error "Failed to retrieve role definitions: $($_.Exception.Message)" return } $matchingRoles = @{} foreach ($roleDef in $roleDefinitions) { $roleId = $roleDef.name $roleName = $roleDef.properties.roleName $permissions = $roleDef.properties.permissions $roleActions = @() # Extract all actions from the role definition foreach ($permissionSet in $permissions) { if ($permissionSet.actions) { $roleActions += $permissionSet.actions } } # Check if any of the role's actions match our target permission(s) $matchingPermissions = @() foreach ($permissionPattern in $Permission) { foreach ($action in $roleActions) { if (Test-PermissionMatch -Pattern $action -Target $permissionPattern) { # Check if this permission is blocked by notActions $blocked = $false foreach ($permissionSet in $permissions) { if ($permissionSet.notActions) { foreach ($notAction in $permissionSet.notActions) { if (Test-PermissionMatch -Pattern $notAction -Target $permissionPattern) { $blocked = $true break } } } } if (-not $blocked) { $matchingPermissions += $action break } } } } if ($matchingPermissions.Count -gt 0) { $matchingRoles[$roleId] = @{ RoleName = $roleName RoleId = $roleId MatchingPermissions = $matchingPermissions } Write-Verbose "Found matching role: $roleName with permissions: $($matchingPermissions -join ', ')" } # Store all role definitions in cache for later lookup $roleDefinitionsCache[$roleId] = @{ RoleName = $roleName Actions = $roleActions } } Write-Host " ✅ Found $($matchingRoles.Count) roles with matching permissions" -ForegroundColor Green if ($matchingRoles.Count -eq 0) { Write-Host "No role definitions found with the specified permission(s). Check permission syntax." -ForegroundColor Yellow return } # Get all subscriptions if not specified $subscriptions = @() if ($SubscriptionId) { $subscriptions += $SubscriptionId } else { try { $subscriptionsUri = "https://management.azure.com/subscriptions?api-version=2020-01-01" $subscriptionsResponse = Invoke-RestMethod -Uri $subscriptionsUri -Headers $script:authHeader -Method 'GET' -ErrorAction Stop $subscriptions += $subscriptionsResponse.value.subscriptionId Write-Host " 📊 Found $($subscriptions.Count) accessible subscriptions" -ForegroundColor Cyan } catch { Write-Error "Failed to retrieve subscriptions: $($_.Exception.Message)" return } } $matchingRoleIds = [string[]]$matchingRoles.Keys $roleAssignments = @() # Process each subscription to find role assignments foreach ($subId in $subscriptions) { try { Write-Verbose "Retrieving role assignments for subscription: $subId" # Get role assignments for this subscription $roleAssignmentsUri = "https://management.azure.com/subscriptions/$subId/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" $roleAssignmentsResponse = Invoke-RestMethod -Uri $roleAssignmentsUri -Headers $script:authHeader -Method 'GET' -ErrorAction Stop $assignments = $roleAssignmentsResponse.value # Filter assignments to only those with matching role definitions $matchingAssignments = $assignments | Where-Object { $roleDefId = ($_.properties.roleDefinitionId -split '/')[-1] $matchingRoleIds -contains $roleDefId } if ($PrincipalType) { $matchingAssignments = $matchingAssignments | Where-Object { $_.properties.principalType -eq $PrincipalType } } $roleAssignments += $matchingAssignments Write-Verbose "Found $($matchingAssignments.Count) matching role assignments in subscription $subId" } catch { Write-Warning "Error retrieving role assignments for subscription $subId`: $($_.Exception.Message)" } } Write-Host " ✅ Found $($roleAssignments.Count) role assignments for matching roles" -ForegroundColor Green if ($roleAssignments.Count -eq 0) { Write-Host "No matching role assignments found." -ForegroundColor Yellow return } # Group principals by type $principalGroups = $roleAssignments | Group-Object { $_.properties.principalType } foreach ($group in $principalGroups) { $principalType = $group.Name $principalIds = $group.Group.properties.principalId | Select-Object -Unique # Skip groups if not requested if ($principalType -eq 'Group' -and -not $IncludeGroups) { continue } } foreach ($assignment in $roleAssignments) { $roleDefId = ($assignment.properties.roleDefinitionId -split '/')[-1] $principalId = $assignment.properties.principalId $principalType = $assignment.properties.principalType # Skip groups if not requested if ($principalType -eq 'Group' -and -not $IncludeGroups) { continue } # Principal details no longer needed in simplified result object # $principalDetail = $principalCache[$principalId] $roleInfo = $matchingRoles[$roleDefId] $result = [PSCustomObject]@{ PrincipalId = $principalId PrincipalType = $principalType RoleName = $roleInfo.RoleName RoleDefinitionId = $roleDefId Scope = $assignment.properties.scope MatchedPermissions = ($roleInfo.MatchingPermissions | Select-Object -Unique) -join ', ' } $results += $result } # Cache the results for future use try { Set-BlackCatCache -Key $cacheKey -Data $results -CacheType 'MSGraph' Write-Verbose "Saved results to cache with key: $cacheKey" } catch { Write-Verbose "Failed to save results to cache: $($_.Exception.Message)" } # Display summary $duration = [Math]::Round(((Get-Date) - $startTime).TotalSeconds, 1) Write-Host "`n📊 Permission Holder Discovery Summary:" -ForegroundColor Magenta Write-Host " Found $($results.Count) permission holders for '$($Permission -join ', ')'" -ForegroundColor Green # Group by principal type for summary $principalTypeSummary = $results | Group-Object PrincipalType foreach ($group in $principalTypeSummary) { Write-Host " $($group.Name): $($group.Count)" -ForegroundColor Cyan } Write-Host " Duration: $($duration) seconds" -ForegroundColor White Write-Host "✅ Permission holder analysis completed successfully!" -ForegroundColor Green # Return results in the requested format using BlackCat's standard output formatter $formatParam = @{ Data = $results OutputFormat = $OutputFormat FunctionName = $MyInvocation.MyCommand.Name FilePrefix = "AzPermHolders-$($Permission[0].Split('/')[-1])" } return Format-BlackCatOutput @formatParam } function Test-PermissionMatch { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$Pattern, [Parameter(Mandatory = $true)] [string]$Target ) if ($Pattern -eq $Target) { Write-Verbose "Permission match (exact): $Pattern = $Target" return $true } if ($Pattern -eq "*") { Write-Verbose "Permission match (global wildcard): $Pattern contains $Target" return $true } if ($Pattern.Contains("*")) { $regexPattern = '^' + [regex]::Escape($Pattern).Replace('\*', '.*') + '$' if ($Target -match $regexPattern) { Write-Verbose "Permission match (wildcard): $Target matches pattern $Pattern" return $true } } if ($Target.StartsWith("$Pattern/")) { Write-Verbose "Permission match (prefix): $Pattern is a prefix of $Target" return $true } if ($Pattern.StartsWith("$Target/")) { Write-Verbose "Permission match (prefix): $Target is a prefix of $Pattern" return $true } $actionMapping = @{ 'write' = @('read') 'delete' = @('read') 'action' = @('read') 'all' = @('read', 'write', 'delete', 'action') } if ($Pattern -match '^(.+)/([^/]+)$' -and $Target -match '^(.+)/([^/]+)$') { $patternBase = $matches[1] $patternAction = $matches[2].ToLower() # Reset $matches to avoid conflicts $null = $Target -match '^(.+)/([^/]+)$' $targetBase = $matches[1] $targetAction = $matches[2].ToLower() if ($patternBase -eq $targetBase -and $actionMapping.ContainsKey($patternAction) -and $actionMapping[$patternAction] -contains $targetAction) { Write-Verbose "Permission match (action hierarchy): $Pattern implies $Target" return $true } } return $false } |