Modules/Private/90-Permissions.ps1
|
function Reset-RangerSkippedResources { <# .SYNOPSIS v1.6.0 (#206): initialise / clear the skipped-resources tracker. #> $script:RangerSkippedResources = [System.Collections.Generic.List[object]]::new() } function Add-RangerSkippedResource { <# .SYNOPSIS v1.6.0 (#206): record a skipped subscription / resource group / resource. #> param( [Parameter(Mandatory = $true)][string]$Scope, [Parameter(Mandatory = $true)][string]$Target, [Parameter(Mandatory = $true)][string]$Category, [string]$Reason ) if (-not $script:RangerSkippedResources) { Reset-RangerSkippedResources } [void]$script:RangerSkippedResources.Add([pscustomobject]@{ scope = $Scope target = $Target category = $Category reason = $Reason timestamp = (Get-Date).ToUniversalTime().ToString('o') }) } function Get-RangerSkippedResources { <# .SYNOPSIS v1.6.0 (#206): return and clear the skipped-resources tracker. #> if (-not $script:RangerSkippedResources) { return @() } $snapshot = @($script:RangerSkippedResources) $script:RangerSkippedResources = [System.Collections.Generic.List[object]]::new() return $snapshot } function Get-RangerArmErrorCategory { <# .SYNOPSIS v1.6.0 (#206): classify an ARM / Az exception into a stable category so callers can decide between skip / retry / abort. .OUTPUTS Hashtable with: Category : 'Authorization' | 'NetworkUnreachable' | 'NotFound' | 'Throttled' | 'Other' Action : 'Skip' | 'Retry' | 'Warn' Detail : short human-friendly description #> param( [Parameter(Mandatory = $true)] $ErrorRecord ) $message = '' try { if ($ErrorRecord.Exception) { $message = [string]$ErrorRecord.Exception.Message } if (-not $message -and $ErrorRecord.ErrorDetails) { $message = [string]$ErrorRecord.ErrorDetails.Message } } catch { } if ($message -match '(?i)AuthorizationFailed|403|does not have authorization') { return @{ Category = 'Authorization'; Action = 'Skip'; Detail = 'Caller is not authorised on the target.' } } if ($message -match '(?i)getaddrinfo failed|no such host|name or service not known|could not be resolved|No connection could be made|NetworkIssue|A connection attempt failed') { return @{ Category = 'NetworkUnreachable'; Action = 'Skip'; Detail = 'ARM endpoint unreachable from this host.' } } if ($message -match '(?i)ResourceGroupNotFound|ResourceNotFound|SubscriptionNotFound|NotFound|404') { return @{ Category = 'NotFound'; Action = 'Skip'; Detail = 'Target resource / resource group / subscription not found.' } } if ($message -match '(?i)TooManyRequests|throttled|429|SubscriptionRequestsThrottled') { return @{ Category = 'Throttled'; Action = 'Retry'; Detail = 'ARM throttling — retry with backoff.' } } return @{ Category = 'Other'; Action = 'Warn'; Detail = $message } } function Invoke-RangerPermissionAudit { <# .SYNOPSIS Core implementation of Test-RangerPermissions (v1.6.0 #202). .DESCRIPTION Runs a structured pre-run audit against the resolved config. Checks: - Az.Accounts context is present - Subscription Reader role on target subscription - HCI cluster read access (Microsoft.AzureStackHCI/clusters/read) - Arc Connected Machine read (Microsoft.HybridCompute/machines/read) - Key Vault secret read when keyvault:// refs are present in config - Required resource providers registered (AzureStackHCI, HybridCompute) Returns a [pscustomobject] with OverallReadiness, Checks, and Recommendations. Safe to call with no Azure session — returns Insufficient with guidance. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) $checks = New-Object System.Collections.Generic.List[pscustomobject] $recommendations = New-Object System.Collections.Generic.List[string] function Add-RangerPermCheck { param([string]$Name, [string]$Status, [string]$Message, [string]$Remediation) $checks.Add([pscustomobject]@{ Name = $Name Status = $Status Message = $Message Remediation = $Remediation }) if ($Status -ne 'Pass' -and -not [string]::IsNullOrWhiteSpace($Remediation)) { [void]$recommendations.Add($Remediation) } } $subscriptionId = $Config.targets.azure.subscriptionId $resourceGroup = $Config.targets.azure.resourceGroup $clusterName = $Config.environment.clusterName $caller = $null # Check 1 — Azure context if (-not (Get-Command -Name 'Get-AzContext' -ErrorAction SilentlyContinue)) { Add-RangerPermCheck -Name 'Azure context' -Status 'Fail' ` -Message 'Az.Accounts module is not installed or not importable.' ` -Remediation 'Install-Module Az.Accounts -Scope CurrentUser -Force' } else { $ctx = Get-AzContext -ErrorAction SilentlyContinue if (-not $ctx -or -not $ctx.Account) { Add-RangerPermCheck -Name 'Azure context' -Status 'Fail' ` -Message 'No active Azure authentication context.' ` -Remediation 'Run Connect-AzAccount, or set credentials.azure.method in config (service-principal, managed-identity, device-code).' } else { $caller = $ctx.Account.Id Add-RangerPermCheck -Name 'Azure context' -Status 'Pass' -Message "Signed in as $caller ($($ctx.Account.Type))" -Remediation $null } } # If no context, the rest of the ARM checks will fail. Skip gracefully. $hasContext = [bool](Get-AzContext -ErrorAction SilentlyContinue) if (-not $hasContext -or [string]::IsNullOrWhiteSpace($subscriptionId) -or $subscriptionId -eq '00000000-0000-0000-0000-000000000000') { Add-RangerPermCheck -Name 'Target subscription configured' -Status 'Fail' ` -Message 'targets.azure.subscriptionId is not set or still a placeholder.' ` -Remediation 'Set targets.azure.subscriptionId in config or pass -SubscriptionId.' } else { Add-RangerPermCheck -Name 'Target subscription configured' -Status 'Pass' ` -Message "Subscription $subscriptionId" -Remediation $null # Check 2 — Subscription Reader (at minimum): attempt a cheap Get-AzResourceGroup try { $rgProbe = if (-not [string]::IsNullOrWhiteSpace($resourceGroup)) { Get-AzResourceGroup -Name $resourceGroup -ErrorAction Stop } else { # List up to one RG to prove Reader works at subscription scope Get-AzResourceGroup -ErrorAction Stop | Select-Object -First 1 } if ($rgProbe) { Add-RangerPermCheck -Name 'Subscription Reader' -Status 'Pass' ` -Message 'Can list/read resource groups in target subscription.' -Remediation $null } else { Add-RangerPermCheck -Name 'Subscription Reader' -Status 'Warn' ` -Message 'No resource groups returned — subscription may be empty or Reader scope is narrower than sub-wide.' ` -Remediation 'Verify Reader role on the subscription or at least the target resource group.' } } catch { Add-RangerPermCheck -Name 'Subscription Reader' -Status 'Fail' ` -Message "Get-AzResourceGroup failed: $($_.Exception.Message)" ` -Remediation 'Grant Reader role on the subscription (or at least the target resource group).' } # Check 3 — HCI cluster read try { $hciArgs = @{ ResourceType = 'microsoft.azurestackhci/clusters'; ErrorAction = 'Stop' } if (-not [string]::IsNullOrWhiteSpace($resourceGroup)) { $hciArgs['ResourceGroupName'] = $resourceGroup } elseif (-not [string]::IsNullOrWhiteSpace($clusterName)) { $hciArgs['Name'] = $clusterName } $hci = @(Get-AzResource @hciArgs) if ($hci.Count -gt 0) { Add-RangerPermCheck -Name 'HCI cluster read' -Status 'Pass' ` -Message "Discovered $($hci.Count) microsoft.azurestackhci/clusters resource(s)." -Remediation $null } else { Add-RangerPermCheck -Name 'HCI cluster read' -Status 'Warn' ` -Message 'Query succeeded but returned no clusters in the configured scope.' ` -Remediation 'Verify clusterName / resourceGroup; or wait for Arc sync if the cluster was registered recently.' } } catch { Add-RangerPermCheck -Name 'HCI cluster read' -Status 'Fail' ` -Message "Get-AzResource for microsoft.azurestackhci/clusters failed: $($_.Exception.Message)" ` -Remediation 'Grant Azure Stack HCI Reader or Reader on the cluster resource / resource group.' } # Check 4 — Arc Connected Machine read try { $arcArgs = @{ ResourceType = 'Microsoft.HybridCompute/machines'; ErrorAction = 'Stop' } if (-not [string]::IsNullOrWhiteSpace($resourceGroup)) { $arcArgs['ResourceGroupName'] = $resourceGroup } $arc = @(Get-AzResource @arcArgs | Select-Object -First 5) Add-RangerPermCheck -Name 'Arc machine read' -Status 'Pass' ` -Message "Can read Arc-connected machines (sample: $($arc.Count))." -Remediation $null } catch { Add-RangerPermCheck -Name 'Arc machine read' -Status 'Fail' ` -Message "Get-AzResource for Microsoft.HybridCompute/machines failed: $($_.Exception.Message)" ` -Remediation 'Grant Azure Connected Machine Resource Reader on the subscription or target resource group.' } # v2.1.0 (#235) — Per-resource-type ARM probes for the v2.0.0 collector surfaces. # On a scoped Reader role the cluster + Arc checks above can pass while these # ARM types still 403. Cheaper to surface the gap up-front than to discover it # mid-run and populate manifest.run.skippedResources. $v2ArmSurfaces = @( @{ Id = 'logicalNetworks'; Type = 'Microsoft.AzureStackHCI/logicalNetworks'; Label = 'Logical networks (#216)' } @{ Id = 'storageContainers'; Type = 'Microsoft.AzureStackHCI/storageContainers'; Label = 'Storage paths (#217)' } @{ Id = 'customLocations'; Type = 'Microsoft.ExtendedLocation/customLocations'; Label = 'Custom locations (#218)' } @{ Id = 'resourceBridges'; Type = 'Microsoft.ResourceConnector/appliances'; Label = 'Arc Resource Bridge (#219)' } @{ Id = 'arcGateways'; Type = 'Microsoft.HybridCompute/gateways'; Label = 'Arc Gateway (#220)' } @{ Id = 'marketplaceImages'; Type = 'Microsoft.AzureStackHCI/marketplaceGalleryImages';Label = 'Marketplace images (#221)' } @{ Id = 'galleryImages'; Type = 'Microsoft.AzureStackHCI/galleryImages'; Label = 'Custom gallery images (#221)' } ) $armSurfaceResults = New-Object System.Collections.Generic.List[pscustomobject] $armDeniedLabels = New-Object System.Collections.Generic.List[string] foreach ($surface in $v2ArmSurfaces) { try { $surfaceArgs = @{ ResourceType = $surface.Type; ErrorAction = 'Stop' } if (-not [string]::IsNullOrWhiteSpace($resourceGroup)) { $surfaceArgs['ResourceGroupName'] = $resourceGroup } $null = @(Get-AzResource @surfaceArgs | Select-Object -First 1) $armSurfaceResults.Add([pscustomobject]@{ Id = $surface.Id Type = $surface.Type Status = 'Pass' Label = $surface.Label }) } catch { $category = Get-RangerArmErrorCategory -ErrorRecord $_ $isAuth = $category.Category -eq 'Authorization' $armSurfaceResults.Add([pscustomobject]@{ Id = $surface.Id Type = $surface.Type Status = if ($isAuth) { 'Denied' } else { 'Unknown' } Label = $surface.Label Message = $_.Exception.Message }) if ($isAuth) { [void]$armDeniedLabels.Add($surface.Label) } } } if ($armDeniedLabels.Count -eq 0) { Add-RangerPermCheck -Name 'v2.0.0 ARM surfaces' -Status 'Pass' ` -Message "Read access confirmed on all $($v2ArmSurfaces.Count) v2.0.0 resource types." -Remediation $null } elseif ($armDeniedLabels.Count -eq $v2ArmSurfaces.Count) { Add-RangerPermCheck -Name 'v2.0.0 ARM surfaces' -Status 'Fail' ` -Message "All $($v2ArmSurfaces.Count) v2.0.0 resource types returned 403 on read — caller has no Arc-data-plane read." ` -Remediation 'Grant Reader (or Azure Stack HCI Reader + Azure Connected Machine Resource Reader) at subscription scope so Arc data-plane types are readable.' } else { Add-RangerPermCheck -Name 'v2.0.0 ARM surfaces' -Status 'Warn' ` -Message "$($armDeniedLabels.Count) of $($v2ArmSurfaces.Count) v2.0.0 resource types denied read: $($armDeniedLabels -join '; ')" ` -Remediation 'Grant Reader on the denied resource types so the corresponding collectors do not silently skip mid-run.' } $script:RangerLastArmSurfaceChecks = @($armSurfaceResults) # v2.1.0 (#233) — Azure Advisor read probe. Advisor is advisory (WAF assessment # degrades gracefully), so deny is treated as Warn not Fail. if (-not (Get-Command -Name 'Get-AzAdvisorRecommendation' -ErrorAction SilentlyContinue)) { Add-RangerPermCheck -Name 'Azure Advisor read' -Status 'Skip' ` -Message 'Az.Advisor module not installed; WAF Assessment will omit Advisor recommendations.' ` -Remediation 'Install-Module Az.Advisor -Scope CurrentUser (optional; only needed for Advisor-backed WAF findings).' } else { try { $null = @(Get-AzAdvisorRecommendation -ErrorAction Stop | Select-Object -First 1) Add-RangerPermCheck -Name 'Azure Advisor read' -Status 'Pass' ` -Message 'Get-AzAdvisorRecommendation succeeded.' -Remediation $null } catch { $msg = [string]$_.Exception.Message if ($msg -match '(?i)not registered') { Add-RangerPermCheck -Name 'Azure Advisor read' -Status 'Warn' ` -Message 'Microsoft.Advisor resource provider is not registered for this subscription.' ` -Remediation 'Register-AzResourceProvider -ProviderNamespace Microsoft.Advisor (requires Contributor on subscription).' } elseif ((Get-RangerArmErrorCategory -ErrorRecord $_).Category -eq 'Authorization') { Add-RangerPermCheck -Name 'Azure Advisor read' -Status 'Warn' ` -Message 'Caller lacks Microsoft.Advisor/recommendations/read on the subscription — WAF Assessment Advisor section will be empty.' ` -Remediation 'Grant Reader (or higher) at subscription scope so Get-AzAdvisorRecommendation returns data.' } else { Add-RangerPermCheck -Name 'Azure Advisor read' -Status 'Warn' ` -Message "Get-AzAdvisorRecommendation failed: $msg" ` -Remediation 'Verify Advisor is enabled on the subscription; re-run with -SkipPreCheck if the error is transient.' } } } # Check 5 — Required resource provider registrations foreach ($providerId in @('Microsoft.AzureStackHCI', 'Microsoft.HybridCompute')) { try { $rp = Get-AzResourceProvider -ProviderNamespace $providerId -ErrorAction Stop | Select-Object -First 1 if ($rp -and $rp.RegistrationState -eq 'Registered') { Add-RangerPermCheck -Name "Provider: $providerId" -Status 'Pass' ` -Message 'Registered in target subscription.' -Remediation $null } else { $state = if ($rp) { [string]$rp.RegistrationState } else { 'Unknown' } Add-RangerPermCheck -Name "Provider: $providerId" -Status 'Warn' ` -Message "Registration state: $state" ` -Remediation "Register-AzResourceProvider -ProviderNamespace $providerId" } } catch { Add-RangerPermCheck -Name "Provider: $providerId" -Status 'Warn' ` -Message "Could not determine registration state: $($_.Exception.Message)" ` -Remediation "Register-AzResourceProvider -ProviderNamespace $providerId (requires Contributor on subscription)." } } } # Check 6 — Key Vault access (only when keyvault:// refs exist) $kvRefs = New-Object System.Collections.Generic.List[string] foreach ($credName in @('cluster', 'domain', 'bmc', 'firewall', 'switch')) { $block = $Config.credentials[$credName] if ($block -and $block.passwordRef -is [string] -and $block.passwordRef.StartsWith('keyvault://')) { [void]$kvRefs.Add([string]$block.passwordRef) } } if ($Config.credentials.azure -and $Config.credentials.azure.clientSecretRef -is [string] -and $Config.credentials.azure.clientSecretRef.StartsWith('keyvault://')) { [void]$kvRefs.Add([string]$Config.credentials.azure.clientSecretRef) } if ($kvRefs.Count -eq 0) { Add-RangerPermCheck -Name 'Key Vault access' -Status 'Skip' ` -Message 'No keyvault:// references in config.' -Remediation $null } elseif (-not $hasContext) { Add-RangerPermCheck -Name 'Key Vault access' -Status 'Warn' ` -Message 'Cannot verify — Azure context missing.' ` -Remediation 'Sign in to Azure; Key Vault uses the same identity.' } else { $kvOk = 0; $kvFail = @() foreach ($uri in $kvRefs) { $parsed = try { ConvertFrom-RangerKeyVaultUri -Uri $uri } catch { $null } if (-not $parsed) { continue } try { $null = Get-AzKeyVaultSecret -VaultName $parsed.VaultName -Name $parsed.SecretName -ErrorAction Stop $kvOk++ } catch { $kvFail += "${uri}: $($_.Exception.Message)" } } if ($kvFail.Count -eq 0) { Add-RangerPermCheck -Name 'Key Vault access' -Status 'Pass' ` -Message "$kvOk of $($kvRefs.Count) Key Vault secret(s) readable." -Remediation $null } else { Add-RangerPermCheck -Name 'Key Vault access' -Status 'Fail' ` -Message "$($kvFail.Count) of $($kvRefs.Count) Key Vault secrets unreadable. First failure: $($kvFail[0])" ` -Remediation 'Grant the caller Key Vault Secrets User on each referenced vault and confirm network access (VPN/private endpoint).' } } # Aggregate readiness $failCount = @($checks | Where-Object { $_.Status -eq 'Fail' }).Count $warnCount = @($checks | Where-Object { $_.Status -eq 'Warn' }).Count $overall = if ($failCount -gt 0) { 'Insufficient' } elseif ($warnCount -gt 0) { 'Partial' } else { 'Full' } [pscustomobject]@{ OverallReadiness = $overall CallerAccount = $caller Checks = @($checks) Recommendations = @($recommendations) GeneratedAt = (Get-Date).ToUniversalTime().ToString('o') } } function Format-RangerPermissionAuditConsole { param([Parameter(Mandatory = $true)]$Result) Write-Host '' Write-Host '── Ranger Pre-Run Permission Audit ──────' -ForegroundColor Cyan if ($Result.CallerAccount) { Write-Host " Caller: $($Result.CallerAccount)" -ForegroundColor Gray } Write-Host '' foreach ($c in $Result.Checks) { $pad = $c.Name.PadRight(32) switch ($c.Status) { 'Pass' { Write-Host " [ OK ] $pad $($c.Message)" -ForegroundColor Green } 'Warn' { Write-Host " [ WARN ] $pad $($c.Message)" -ForegroundColor Yellow } 'Fail' { Write-Host " [ FAIL ] $pad $($c.Message)" -ForegroundColor Red } 'Skip' { Write-Host " [ SKIP ] $pad $($c.Message)" -ForegroundColor DarkGray } default { Write-Host " [ ???? ] $pad $($c.Message)" } } } Write-Host '' $color = switch ($Result.OverallReadiness) { 'Full' { 'Green' } 'Partial' { 'Yellow' } 'Insufficient' { 'Red' } default { 'Gray' } } Write-Host " Overall readiness: $($Result.OverallReadiness)" -ForegroundColor $color if ($Result.Recommendations.Count -gt 0) { Write-Host '' Write-Host ' Remediation steps:' -ForegroundColor Yellow foreach ($r in $Result.Recommendations) { Write-Host " - $r" -ForegroundColor Gray } } Write-Host '' } function Format-RangerPermissionAuditMarkdown { param([Parameter(Mandatory = $true)]$Result) $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# Ranger Permission Audit') $lines.Add('') $lines.Add("- **Overall readiness:** $($Result.OverallReadiness)") if ($Result.CallerAccount) { $lines.Add("- **Caller:** $($Result.CallerAccount)") } $lines.Add("- **Generated:** $($Result.GeneratedAt)") $lines.Add('') $lines.Add('| Check | Status | Detail |') $lines.Add('| --- | --- | --- |') foreach ($c in $Result.Checks) { $lines.Add("| $($c.Name) | $($c.Status) | $($c.Message) |") } if ($Result.Recommendations.Count -gt 0) { $lines.Add('') $lines.Add('## Remediation') foreach ($r in $Result.Recommendations) { $lines.Add("- $r") } } return ($lines -join [Environment]::NewLine) } |