Public/Discovery/Find-EntraPermissionHolder.ps1
# Find-EntraPermissionHolder.ps1 # Part of BlackCat Security Module - Entra ID Permission Discovery Functions # Optimized for performance and thorough permission analysis function Test-EntraPermissionMatch { <# .SYNOPSIS Tests if a permission pattern matches a target permission. .DESCRIPTION Helper function that determines whether a permission pattern (which may include wildcards) matches a specific permission target. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$PermissionPattern, [Parameter(Mandatory = $true)] [string]$TargetPermission ) # Fast path: Exact match check if ($PermissionPattern -eq $TargetPermission) { Write-Verbose "Permission match (exact): $PermissionPattern = $TargetPermission" return $true } # Handle global wildcards (applies to all Microsoft Directory permissions) if ($PermissionPattern -eq 'microsoft.directory/*' -or $PermissionPattern -eq '*') { if ($TargetPermission.StartsWith('microsoft.directory/')) { Write-Verbose "Permission match (global wildcard): $PermissionPattern contains $TargetPermission" return $true } } # Handle trailing wildcards (e.g., microsoft.directory/users/*) if ($PermissionPattern.EndsWith('/*')) { $parentPath = $PermissionPattern.TrimEnd('/*') if ($TargetPermission.StartsWith($parentPath + '/')) { Write-Verbose "Permission match (trailing wildcard): $PermissionPattern contains $TargetPermission" return $true } } # Handle embedded wildcards (e.g., microsoft.directory/*/create) if ($PermissionPattern.Contains('/*')) { $patternParts = $PermissionPattern.Split('/') $targetParts = $TargetPermission.Split('/') # Quick length check for optimization if ($patternParts.Count -ne $targetParts.Count) { return $false } # Check each segment with optimized loop $segmentMatches = $true for ($i = 0; $i -lt $patternParts.Count; $i++) { if ($patternParts[$i] -ne '*' -and $patternParts[$i] -ne $targetParts[$i]) { $segmentMatches = $false break } } if ($segmentMatches) { Write-Verbose "Permission match (embedded wildcard): $PermissionPattern matches $TargetPermission" return $true } } # Handle special permission relationships (permission hierarchy) if ($PermissionPattern -eq 'microsoft.directory/applications/allProperties/allTasks') { if ($TargetPermission -eq 'microsoft.directory/applications/allProperties/read' -or $TargetPermission -eq 'microsoft.directory/applications/allProperties/update') { Write-Verbose "Permission match (hierarchy): $PermissionPattern contains $TargetPermission" return $true } } # The reverse relationship if ($TargetPermission -eq 'microsoft.directory/applications/allProperties/allTasks') { if ($PermissionPattern -eq 'microsoft.directory/applications/allProperties/read' -or $PermissionPattern -eq 'microsoft.directory/applications/allProperties/update') { Write-Verbose "Permission match (hierarchy): $TargetPermission contains $PermissionPattern" return $true } } # No match found return $false } function Find-RolesWithPermission { <# .SYNOPSIS Finds roles that have a specific permission. .DESCRIPTION Identifies all Entra ID roles that contain the specified permission, taking into account wildcard patterns and permission hierarchies. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Permission ) Write-Verbose "Getting role definitions from Microsoft Graph..." # Get all role definitions try { $response = Invoke-MsGraph -relativeUrl "roleManagement/directory/roleDefinitions" -NoBatch -ErrorAction Stop $roleDefinitions = $response.value Write-Verbose "Retrieved $($roleDefinitions.Count) role definitions" } catch { Write-Error "Failed to get role definitions: $_" return $null } # Global Administrator role ID (has all permissions) $globalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10' # Check if this permission exists in any role first $permissionExists = $false foreach ($roleDef in $roleDefinitions) { foreach ($rolePermission in $roleDef.rolePermissions) { # Check exact matches first (fastest check) if ($rolePermission.allowedResourceActions -contains $Permission) { $permissionExists = $true break } # Then check patterns foreach ($action in $rolePermission.allowedResourceActions) { if (Test-EntraPermissionMatch -PermissionPattern $action -TargetPermission $Permission) { $permissionExists = $true break } } if ($permissionExists) { break } } if ($permissionExists) { break } } if (-not $permissionExists) { Write-Warning "The permission '$Permission' does not exist in any role definition." return $null } # Find all roles that have this permission $matchingRoles = $roleDefinitions | Where-Object { $roleDefinition = $_ # Special case for Global Administrator which has all valid permissions if (($roleDefinition.id -eq $globalAdminRoleId -or $roleDefinition.templateId -eq $globalAdminRoleId) -and $permissionExists) { Write-Verbose "Global Administrator role automatically matches all valid permissions" return $true } # Check all permissions in this role foreach ($rolePermission in $roleDefinition.rolePermissions) { foreach ($action in $rolePermission.allowedResourceActions) { if (Test-EntraPermissionMatch -PermissionPattern $action -TargetPermission $Permission) { return $true } } } return $false } return $matchingRoles } function Resolve-GroupMembers { <# .SYNOPSIS Resolves members of groups, including nested members. .DESCRIPTION Resolves the members of specified groups, handling nested members when possible. Uses parallel processing for better performance. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$Groups, [Parameter(Mandatory = $false)] [int]$ThrottleLimit = 10 ) $resolvedMembers = [System.Collections.Generic.Dictionary[string, object]]::new() # Filter out non-group principals $groupsToResolve = $Groups | Where-Object { $_.PrincipalType -eq 'group' } if ($groupsToResolve.Count -eq 0) { return $resolvedMembers } Write-Host " 👥 Resolving $($groupsToResolve.Count) groups..." -ForegroundColor Cyan # Process each group with parallel processing (but ensure module functions are available) foreach ($group in $groupsToResolve) { $groupId = $group.PrincipalId try { # Try transitive members first for nested resolution $members = $null try { # Try Microsoft Graph API transitive members endpoint $members = Invoke-MsGraph -relativeUrl "groups/$groupId/transitiveMembers" -NoBatch -ErrorAction Stop $isTransitive = $true } catch { # Fall back to direct members if transitive fails $members = Invoke-MsGraph -relativeUrl "groups/$groupId/members" -NoBatch -ErrorAction Stop $isTransitive = $false } if ($members -and $members.value) { $groupMembers = $members.value | ForEach-Object { $member = $_ $memberType = $member.'@odata.type' -replace "#microsoft.graph.", "" [PSCustomObject]@{ ObjectId = $member.id DisplayName = $member.displayName UserPrincipalName = if ($memberType -eq "user") { $member.userPrincipalName } else { $null } PrincipalType = $memberType AppId = if ($memberType -eq "servicePrincipal") { $member.appId } else { $null } IsNested = $isTransitive } } # Use thread-safe way to update the dictionary [System.Threading.Monitor]::Enter($resolvedMembers) try { $resolvedMembers[$groupId] = $groupMembers } finally { [System.Threading.Monitor]::Exit($resolvedMembers) } } else { # Empty group or error case [System.Threading.Monitor]::Enter($resolvedMembers) try { $resolvedMembers[$groupId] = @() } finally { [System.Threading.Monitor]::Exit($resolvedMembers) } } } catch { Write-Warning "Failed to resolve group $($group.PrincipalName) ($groupId): $_" # Record the error in the dictionary [System.Threading.Monitor]::Enter($resolvedMembers) try { $resolvedMembers[$groupId] = @( [PSCustomObject]@{ ObjectId = $groupId DisplayName = "Error: $($_.Exception.Message)" PrincipalType = "Error" UserPrincipalName = $null AppId = $null } ) } finally { [System.Threading.Monitor]::Exit($resolvedMembers) } } } return $resolvedMembers } function Find-EntraPermissionHolder { <# .SYNOPSIS Find Microsoft Entra ID (Azure AD) principals that have a specific permission. .DESCRIPTION The Find-EntraPermissionHolder function identifies all roles containing a specific permission and then returns all principals (users, groups, or service principals) assigned to those roles. This function leverages the existing Get-EntraRoleMember and Get-EntraIDPermissions functions to efficiently identify who has specific permissions in your Entra ID tenant, which is useful for security audits, compliance checks, and permission discovery during incident response or threat hunting activities. The function supports: - Finding all roles that contain a specific permission - Identifying all principals assigned to those roles - Resolving group memberships to see nested users with the permission - Identifying both active and eligible (PIM) role assignments - Output in various formats for further analysis .PARAMETER Permission The specific permission string to search for (e.g., "microsoft.directory/applications/create"). The function supports exact permission strings as well as wildcard patterns like "microsoft.directory/users/*". .PARAMETER IncludeEligible Include principals with eligible assignments (PIM) in addition to active assignments. Note: Requires RoleManagement.Read.Directory permission. .PARAMETER ResolveGroups When specified, resolves group memberships to include users who have the permission via group membership. This shows the complete permission inheritance chain and is useful for thorough security audits. .PARAMETER IncludeAUScope Include assignments scoped to Administrative Units, not just directory-wide assignments. By default, only directory-wide assignments are included. .PARAMETER OutputPath Path to export the results to a CSV file. The directory will be created if it doesn't exist. Results are still returned to the pipeline even when exporting. .PARAMETER ThrottleLimit Limit the number of concurrent operations for performance tuning. Default is 10 concurrent operations. Increase for faster processing in larger environments, decrease if experiencing throttling or resource constraints. .PARAMETER OutputFormat Format for the output: "Object" (raw PowerShell objects), "JSON" (formatted JSON string), "CSV" (comma-separated values), or "Table" (formatted table view, default). Use "Object" when piping to other commands for further processing. .EXAMPLE Find-EntraPermissionHolder -Permission "microsoft.directory/applications/create" Returns all principals that can create applications in Entra ID. .EXAMPLE Find-EntraPermissionHolder -Permission "microsoft.directory/servicePrincipals/credentials/update" -ResolveGroups Returns all principals that can update service principal credentials, including nested group memberships. .EXAMPLE Find-EntraPermissionHolder -Permission "microsoft.directory/*" -OutputFormat CSV -OutputPath "~/Desktop/all-admins.csv" Finds all principals with any Microsoft Directory permissions and exports results to a CSV file. .EXAMPLE Find-EntraPermissionHolder -Permission "microsoft.directory/users/delete" -IncludeEligible -IncludeAUScope Returns all principals (active and PIM eligible) that can delete users, including those with permissions scoped to specific Administrative Units. .NOTES Author: BlackCat Security Required Permissions: - Directory.Read.All (minimum requirement) - RoleManagement.Read.Directory (for PIM eligible assignments) - GroupMember.Read.All (for resolving group memberships) Performance Notes: - Use the ThrottleLimit parameter to adjust parallel processing performance - For large tenants, resolving groups can take significant time #> [cmdletbinding()] [OutputType([System.Collections.Generic.List[PSCustomObject]])] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $false, Position = 0)] [string]$Permission, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $false)] [Alias('include-eligible')] [switch]$IncludeEligible, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $false)] [Alias('resolve-groups')] [switch]$ResolveGroups, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $false)] [Alias('include-au-scope')] [switch]$IncludeAUScope, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $false)] [Alias('output-path')] [string]$OutputPath, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $false)] [Alias('throttle-limit')] [int]$ThrottleLimit = 10, [Parameter(Mandatory = $false)] [ValidateSet("Object", "JSON", "CSV", "Table")] [Alias("output", "o")] [string]$OutputFormat = "Table" ) begin { Write-Verbose "Starting function $($MyInvocation.MyCommand.Name)" $startTime = Get-Date # Ensure we have a valid Graph connection $MyInvocation.MyCommand.Name | Invoke-BlackCat -ResourceTypeName 'MSGraph' # Create collection for results $permissionHolders = [System.Collections.Generic.List[PSCustomObject]]::new() Write-Host "🔍 Finding Entra ID principals with permission: $Permission" -ForegroundColor Green Write-Host " Started at: $($startTime.ToString('yyyy-MM-dd HH:mm:ss'))" -ForegroundColor DarkGray } process { # Step 1: Find all roles that contain the specified permission $matchingRoles = Find-RolesWithPermission -Permission $Permission if (-not $matchingRoles -or $matchingRoles.Count -eq 0) { Write-Host " â ī¸ No roles found containing the permission: $Permission" -ForegroundColor Yellow return } Write-Host " â Found $($matchingRoles.Count) roles containing the permission: $Permission" -ForegroundColor Green # List the identified roles in verbose mode if ($VerbosePreference -eq 'Continue') { foreach ($role in $matchingRoles) { Write-Host " - $($role.displayName)" -ForegroundColor Cyan } } # Step 2: For each role, get all members using Get-EntraRoleMember foreach ($role in $matchingRoles) { $roleName = $role.displayName $roleId = $role.id Write-Verbose "Getting members for role: $roleName" try { # Get role members directly using Microsoft Graph API to avoid Get-EntraRoleMember restrictions Write-Verbose "Getting role assignments for role: $roleName ($roleId)" # Get all role assignments $roleAssignments = Invoke-MsGraph -relativeUrl "roleManagement/directory/roleAssignments" -NoBatch -ErrorAction Stop $targetAssignments = $roleAssignments.value | Where-Object { $_.roleDefinitionId -eq $roleId } if (-not $targetAssignments -or $targetAssignments.Count -eq 0) { Write-Verbose "No assignments found for role: $roleName" continue } Write-Verbose "Found $($targetAssignments.Count) assignments for role: $roleName" # Get principal details for all assignments $principalIds = $targetAssignments.principalId | Select-Object -Unique $principalDetails = @{} # Get principal details individually (simpler than batch processing) foreach ($principalId in $principalIds) { try { $principalData = Invoke-MsGraph -relativeUrl "directoryObjects/$principalId" -NoBatch -ErrorAction Stop $principalType = $principalData.'@odata.type' -replace "#microsoft.graph.", "" $principalDetails[$principalId] = [PSCustomObject]@{ Type = $principalType Details = $principalData } } catch { Write-Verbose "Could not get details for principal $principalId`: $_" # Create a placeholder for missing principals $principalDetails[$principalId] = [PSCustomObject]@{ Type = "Unknown" Details = [PSCustomObject]@{ displayName = "Unknown Principal" userPrincipalName = $null appId = $null } } } } # Process each assignment foreach ($assignment in $targetAssignments) { $principalId = $assignment.principalId $principalInfo = $principalDetails[$principalId] if (-not $principalInfo) { Write-Verbose "Could not get details for principal: $principalId" continue } # Skip if not including eligible assignments and this is an eligible assignment if (-not $IncludeEligible -and $assignment.assignmentType -eq "Eligible") { Write-Verbose "Skipping eligible assignment for principal: $principalId" continue } # Determine scope - directory-wide or administrative unit $scope = "Directory" $scopeDisplayName = "Directory (Global)" # Only include AU-scoped assignments if requested if ($IncludeAUScope -and $assignment.directoryScopeId -and $assignment.directoryScopeId -ne "/") { $scope = "AdministrativeUnit" $scopeDisplayName = "AU: $($assignment.directoryScopeDisplayName)" } elseif ($assignment.directoryScopeId -and $assignment.directoryScopeId -ne "/" -and -not $IncludeAUScope) { # Skip AU-scoped assignments if not requested Write-Verbose "Skipping AU-scoped assignment for principal: $principalId" continue } # Create member object $member = [PSCustomObject]@{ PrincipalId = $principalId PrincipalType = $principalInfo.Type DisplayName = $principalInfo.Details.displayName UserPrincipalName = if ($principalInfo.Type -eq "user") { $principalInfo.Details.userPrincipalName } else { $null } AppId = if ($principalInfo.Type -eq "servicePrincipal") { $principalInfo.Details.appId } else { $null } AssignmentState = if ($assignment.assignmentType -eq "Eligible") { "Eligible" } else { "Active" } DirectoryScopeId = $assignment.directoryScopeId DirectoryScopeDisplayName = $assignment.directoryScopeDisplayName AssignmentId = $assignment.id } # Add to results collection $permissionHolders.Add([PSCustomObject]@{ Permission = $Permission RoleDisplayName = $roleName RoleId = $roleId RoleDescription = $role.description AssignmentType = $member.AssignmentState Scope = $scope ScopeDisplayName = $scopeDisplayName PrincipalId = $member.PrincipalId PrincipalName = $member.DisplayName PrincipalType = $member.PrincipalType UserPrincipalName = $member.UserPrincipalName AppId = $member.AppId ResolvedMembers = @() AssignmentId = $member.AssignmentId }) } } catch { Write-Warning "Error getting members for role '$roleName': $_" } } # Step 3: Resolve group memberships if requested if ($ResolveGroups -and $permissionHolders.Count -gt 0) { $groupPrincipals = $permissionHolders | Where-Object { $_.PrincipalType -eq 'group' } if ($groupPrincipals.Count -gt 0) { # Resolve all group members $resolvedGroups = Resolve-GroupMembers -Groups $groupPrincipals -ThrottleLimit $ThrottleLimit # Update the original objects with resolved members foreach ($holder in $permissionHolders) { if ($holder.PrincipalType -eq 'group' -and $resolvedGroups.ContainsKey($holder.PrincipalId)) { $holder.ResolvedMembers = $resolvedGroups[$holder.PrincipalId] $holder | Add-Member -NotePropertyName "ResolvedMemberCount" -NotePropertyValue $holder.ResolvedMembers.Count -Force } } # Summarize results $resolvedUsers = ($resolvedGroups.Values | ForEach-Object { $_ } | Where-Object { $_.PrincipalType -eq "user" }).Count $resolvedSPs = ($resolvedGroups.Values | ForEach-Object { $_ } | Where-Object { $_.PrincipalType -eq "servicePrincipal" }).Count $nestedGroups = ($resolvedGroups.Values | ForEach-Object { $_ } | Where-Object { $_.PrincipalType -eq "group" -or $_.PrincipalType -eq "NestedGroup" -or $_.PrincipalType -eq "DirectGroup" }).Count Write-Host " â Resolved $resolvedUsers users and $resolvedSPs service principals from $($groupPrincipals.Count) groups" -ForegroundColor Green if ($nestedGroups -gt 0) { Write-Host " âšī¸ Found $nestedGroups nested groups in the permission chain" -ForegroundColor Cyan } } } } end { # Calculate execution time $executionTime = (Get-Date) - $startTime $formattedTime = "{0:mm\:ss\.fff}" -f $executionTime # Summarize findings $groupedResults = $permissionHolders | Group-Object -Property PrincipalType Write-Host "`n📊 Permission Analysis Complete" -ForegroundColor Green Write-Host " Permission searched: $Permission" Write-Host " Execution time: $formattedTime" -ForegroundColor DarkGray foreach ($group in $groupedResults) { $pluralSuffix = if ($group.Count -ne 1) { "s" } else { "" } $color = switch ($group.Name) { "user" { "Green" } "group" { "Yellow" } "servicePrincipal" { "Cyan" } default { "White" } } Write-Host " â Found $($group.Count) $($group.Name)$pluralSuffix with the permission" -ForegroundColor $color } # Handle direct export to file if requested if ($OutputPath) { Write-Host " 💾 Exporting results to $OutputPath" -ForegroundColor Cyan try { # Create directory if it doesn't exist $directory = Split-Path -Parent $OutputPath if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } # Export to CSV $permissionHolders | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Host " Export completed successfully: $OutputPath" -ForegroundColor Green } catch { Write-Warning "Error exporting to $OutputPath`: $_" } } # Format and return output based on requested format switch ($OutputFormat) { "Table" { # Create a more compact, user-friendly view for table display $userFocusedOutput = $permissionHolders | ForEach-Object { [PSCustomObject]@{ "PrincipalName" = $_.PrincipalName "PrincipalType" = $_.PrincipalType "Role" = $_.RoleDisplayName "AssignmentType" = $_.AssignmentType "UserPrincipalName" = $_.UserPrincipalName "Scope" = $_.ScopeDisplayName } } return Format-BlackCatOutput -Data $userFocusedOutput -OutputFormat $OutputFormat -FunctionName $MyInvocation.MyCommand.Name -FilePrefix "EntraPermHolders-$($Permission.Split('/')[-1])" } "Object" { return $permissionHolders } default { # For JSON, CSV formats, return the complete data structure return Format-BlackCatOutput -Data $permissionHolders -OutputFormat $OutputFormat -FunctionName $MyInvocation.MyCommand.Name -FilePrefix "EntraPermHolders-$($Permission.Split('/')[-1])" } } } } |