Private/ConvertTo-RBACRole.ps1
|
function ConvertTo-RBACRole { <# .SYNOPSIS Maps one or more Azure control-plane actions to candidate built-in roles. .DESCRIPTION Internal. An AuthorizationFailed error (or a requirement lookup) yields an *action* such as 'Microsoft.Storage/storageAccounts/write'; callers grant *roles*, not actions. This helper reverse-maps actions to candidate role(s), in priority order: 1. The curated Data/RoleActionMap.psd1 table (action glob -> roles). Tried first because a raw "fewest actions wins" search over all built-in roles surfaces technically-correct but useless niche roles (e.g. a Defender single-action role for a storage write); the curated table returns the sensible, human-vetted least-privilege role. 2. Live role definitions via Get-AzRoleDefinition (authoritative) for the long tail of actions not covered by the curated table - ranked with a heavy wildcard penalty so broad roles (Owner/Contributor) rank below specific ones. 3. A conservative fallback ('Contributor' for write/delete/action, 'Reader' for read) so the caller is never left without a candidate. Wildcards in role definitions (e.g. 'Microsoft.Storage/*') are honoured when matching an action. .PARAMETER Action One or more action strings to resolve. .PARAMETER Context Optional PSAutoRBAC.Context used to scope live Get-AzRoleDefinition calls. .PARAMETER MaxCandidates Maximum candidate roles to return per action set (default 3). .OUTPUTS PSCustomObject: Roles [string[]], Source [string], Actions [string[]]. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)] [string[]]$Action, [Parameter()] [psobject]$Context, [Parameter()] [int]$MaxCandidates = 3 ) $actions = @($Action | Where-Object { $_ } | Select-Object -Unique) if ($actions.Count -eq 0) { Write-PSFMessage -Level Debug -Message 'No actions supplied to map; returning empty role set.' -Tag 'PSAutoRBAC', 'RoleMap' return [pscustomobject]@{ PSTypeName = 'PSAutoRBAC.RoleMatch'; Roles = @(); Source = 'None'; Actions = @() } } Write-PSFMessage -Level Verbose -Message "Mapping $($actions.Count) action(s) to role(s): $($actions -join ', ')." -Tag 'PSAutoRBAC', 'RoleMap' # Treat a definition's action pattern (possibly wildcarded) as matching a needed action. $matchAction = { param($pattern, $needed) if ([string]::IsNullOrEmpty($pattern)) { return $false } if ($pattern -eq '*') { return $true } $regex = '^' + [regex]::Escape($pattern).Replace('\*', '.*') + '$' return [bool]([regex]::IsMatch($needed, $regex, [Text.RegularExpressions.RegexOptions]::IgnoreCase)) } # 1) Curated offline RoleActionMap.psd1 (action glob -> sensible role(s)). try { $map = Get-RBACKnowledgeBase -Name 'RoleActionMap' $offline = @() foreach ($a in $actions) { foreach ($pattern in $map.Keys) { if (& $matchAction $pattern $a) { $offline += $map[$pattern] } } } $roles = @($offline | Where-Object { $_ } | Select-Object -Unique -First $MaxCandidates) if ($roles.Count -gt 0) { Write-PSFMessage -Level Verbose -Message "Mapped via curated map to: $($roles -join ', ')." -Tag 'PSAutoRBAC', 'RoleMap' return [pscustomobject]@{ PSTypeName = 'PSAutoRBAC.RoleMatch'; Roles = $roles; Source = 'CuratedMap'; Actions = $actions } } Write-PSFMessage -Level Debug -Message 'No curated-map match; trying live role definitions.' -Tag 'PSAutoRBAC', 'RoleMap' } catch { Write-PSFMessage -Level Warning -Message "Curated role map unavailable: $($_.Exception.Message)" -Tag 'PSAutoRBAC', 'RoleMap' } # 2) Live role definitions (authoritative long-tail, least-privilege ranked). if (Get-Command -Name 'Get-AzRoleDefinition' -ErrorAction SilentlyContinue) { try { $params = @{ ErrorAction = 'Stop' } if ($Context -and $Context.AzContext) { $params['DefaultProfile'] = $Context.AzContext } $defs = Get-AzRoleDefinition @params $hits = foreach ($def in $defs) { # Support both the flattened shape (def.Actions, deprecated in # Az.Resources 10) and the new per-permission shape (def.Permissions[].Actions). $prop = { param($o, $n) if ($o.PSObject.Properties.Name -contains $n) { $o.$n } } $defActions = @(& $prop $def 'Actions') $defNot = @(& $prop $def 'NotActions') $perms = & $prop $def 'Permissions' if ($perms) { $defActions += @($perms | ForEach-Object { & $prop $_ 'Actions' }) $defNot += @($perms | ForEach-Object { & $prop $_ 'NotActions' }) } $defActions = @($defActions | Where-Object { $_ } | Select-Object -Unique) $defNot = @($defNot | Where-Object { $_ }) $notMatched = $actions | Where-Object { $a = $_ -not (@($defActions | Where-Object { & $matchAction $_ $a }) | Where-Object { -not (@($defNot) | Where-Object { & $matchAction $_ $a }) }) } if (-not $notMatched) { # Least-privilege ranking: penalize wildcards heavily so broad # roles ('*' = Owner/Contributor) rank below specific ones. $wildcards = @($defActions | Where-Object { $_ -match '\*' }).Count [pscustomobject]@{ Name = $def.Name; Weight = ($wildcards * 1000) + $defActions.Count } } } $roles = @($hits | Sort-Object Weight | Select-Object -ExpandProperty Name -Unique -First $MaxCandidates) if ($roles.Count -gt 0) { Write-PSFMessage -Level Verbose -Message "Mapped via $(@($defs).Count) live role definition(s) to: $($roles -join ', ')." -Tag 'PSAutoRBAC', 'RoleMap' return [pscustomobject]@{ PSTypeName = 'PSAutoRBAC.RoleMatch'; Roles = $roles; Source = 'RoleDefinition'; Actions = $actions } } } catch { Write-PSFMessage -Level Warning -Message "Live role-definition mapping unavailable ($($_.Exception.Message)); using conservative fallback." -Tag 'PSAutoRBAC', 'RoleMap' } } # 3) Conservative fallback. $needsWrite = @($actions | Where-Object { $_ -match '/(write|delete|action)$' -or $_ -match '/\*$' -or $_ -eq '*' }).Count -gt 0 $fallback = if ($needsWrite) { 'Contributor' } else { 'Reader' } Write-PSFMessage -Level Verbose -Message "No role-definition match; conservative fallback role: $fallback." -Tag 'PSAutoRBAC', 'RoleMap' [pscustomobject]@{ PSTypeName = 'PSAutoRBAC.RoleMatch'; Roles = @($fallback); Source = 'Fallback'; Actions = $actions } } |