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 }
}