Providers/AzureProbeProvider.ps1

function New-AzureProbeProvider {
    <#
    .SYNOPSIS
        Constructs the Azure (ARM) RBAC probe provider.
    .DESCRIPTION
        Internal factory. Azure is the only supported platform whose authorization
        error reliably names the missing action and scope, so it is the only
        provider whose ProbeLive can derive requirements from a live failure.
        Preflight (ResolveRequirement + TestAccess) uses the offline knowledge
        base and Get-AzRoleAssignment / Get-AzRoleDefinition; live probing
        executes the command and parses the AuthorizationFailed response.
    .OUTPUTS
        PSCustomObject (PSAutoRBAC.Provider)
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Factory; constructs and returns a provider object, makes no state change.')]
    [OutputType([psobject])]
    param()

    $matchScope = {
        param($Assignment, $Scope)
        if (-not $Assignment.Scope) { return $false }
        $target = $Scope.TrimEnd('/')
        $held   = ([string]$Assignment.Scope).TrimEnd('/')
        if ($held -eq '/') { return $true }  # tenant root covers everything
        return $target.StartsWith($held, [System.StringComparison]::OrdinalIgnoreCase)
    }

    [pscustomobject]@{
        PSTypeName        = 'PSAutoRBAC.Provider'
        Name              = 'Azure'
        Aliases           = @('Azure PowerShell', 'Az', 'ARM', 'AzureRM', 'Azure CLI')
        SupportsLiveProbe = $true
        MatchScope        = $matchScope

        ResolveRequirement = {
            param($Command, $Context, $Options)

            Write-PSFMessage -Level Verbose -Message "Azure: resolving requirement for '$Command'." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
            $mapPath = if ($Options -and $Options.ContainsKey('MapPath')) { $Options.MapPath } else { $null }
            $map = if ($mapPath) { Get-RBACKnowledgeBase -Path $mapPath } else { Get-RBACKnowledgeBase -Name 'CommandRoleMap' }

            $platformKey = $map.Keys | Where-Object { $_ -eq 'Azure PowerShell' } | Select-Object -First 1
            $commands = $map[$platformKey]
            $commandKey = $commands.Keys | Where-Object { $_ -eq $Command } | Select-Object -First 1

            $isKnown = $true
            if (-not $commandKey) {
                $isKnown = $false; $commandKey = '*Default'
                Write-PSFMessage -Level Debug -Message "Azure: '$Command' not in knowledge base; using *Default." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
            }
            $entry = $commands[$commandKey]
            Write-PSFMessage -Level Debug -Message "Azure: '$Command' -> role(s) [$(@($entry.Roles) -join ', ')] (known: $isKnown)." -Tag 'PSAutoRBAC', 'Provider', 'Azure'

            [pscustomobject]@{
                PSTypeName  = 'PSAutoRBAC.Requirement'
                Platform    = 'Azure'
                Command     = $Command
                Roles       = @($entry.Roles)
                Permissions = @($entry.Actions)
                ScopeLevel  = $entry.ScopeLevel
                Notes       = $entry.Notes
                IsKnown     = $isKnown
                Source      = 'KnowledgeBase'
            }
        }

        TestAccess = {
            param($CallerId, $RequiredRole, $Scope, $Context, $Options)

            Write-PSFMessage -Level Verbose -Message "Azure: testing '$CallerId' for role(s) [$(@($RequiredRole) -join ', ')] at '$Scope'." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
            $assignments = $null
            if ($Options -and $Options.ContainsKey('RoleAssignment')) {
                $assignments = @($Options.RoleAssignment)
                Write-PSFMessage -Level Debug -Message "Azure: evaluating against $($assignments.Count) supplied assignment(s) (offline)." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
            }
            else {
                if (-not (Get-Command -Name 'Get-AzRoleAssignment' -ErrorAction SilentlyContinue)) {
                    Write-PSFMessage -Level Error -Message 'Azure: Get-AzRoleAssignment unavailable and no -RoleAssignment supplied.' -Tag 'PSAutoRBAC', 'Provider', 'Azure'
                    throw 'Get-AzRoleAssignment is unavailable. Import Az.Resources (and Connect-AzAccount), or pass -RoleAssignment for offline evaluation.'
                }
                $common = @{ ErrorAction = 'Stop' }
                if ($Context -and $Context.AzContext) { $common['DefaultProfile'] = $Context.AzContext }
                try   {
                    Write-PSFMessage -Level Debug -Message "Azure: querying Get-AzRoleAssignment -SignInName '$CallerId'." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
                    $assignments = @(Get-AzRoleAssignment -SignInName $CallerId @common)
                }
                catch {
                    Write-PSFMessage -Level Verbose -Message "Azure: SignInName lookup failed ($($_.Exception.Message)); retrying by ObjectId." -Tag 'PSAutoRBAC', 'Provider', 'Azure'
                    $assignments = @(Get-AzRoleAssignment -ObjectId $CallerId @common)
                }
            }

            $scopeTrim = $Scope.TrimEnd('/')
            foreach ($role in $RequiredRole) {
                $match = $assignments | Where-Object {
                    $held = ([string]$_.Scope).TrimEnd('/')
                    $_.RoleDefinitionName -eq $role -and $held -and
                    ($held -eq '/' -or $scopeTrim.StartsWith($held, [System.StringComparison]::OrdinalIgnoreCase))
                } | Select-Object -First 1

                [pscustomobject]@{
                    PSTypeName = 'PSAutoRBAC.AccessState'
                    Platform   = 'Azure'
                    CallerId   = $CallerId
                    Role       = $role
                    Scope      = $scopeTrim
                    HasAccess  = [bool]$match
                }
            }
        }

        ProbeLive = {
            param($Command, $ArgumentList, $Scope, $Context)

            Write-PSFMessage -Level Significant -Message "Azure: LIVE probing '$Command' at '$Scope' (this executes the command)." -Tag 'PSAutoRBAC', 'Provider', 'Azure', 'LiveProbe'
            $resolved = if ($Command -is [scriptblock]) { $Command }
                        else { Get-Command -Name $Command -ErrorAction SilentlyContinue }
            if (-not $resolved) {
                Write-PSFMessage -Level Error -Message "Azure: cannot live-probe '$Command'; command not found in session." -Tag 'PSAutoRBAC', 'Provider', 'Azure', 'LiveProbe'
                throw "Cannot live-probe '$Command': the command is not available in this session. Import the owning module first."
            }

            $err = $null
            try {
                if ($resolved -is [scriptblock]) {
                    & $resolved 2>&1 | Out-Null
                }
                else {
                    $splat = @{}
                    $params = if ($ArgumentList) { $ArgumentList } else { @() }
                    # Prefer the command's own -WhatIf when it supports ShouldProcess and the
                    # caller did not already pass one, to minimize side effects.
                    if ($resolved.Parameters.ContainsKey('WhatIf') -and -not ($params -contains '-WhatIf')) {
                        $splat['WhatIf'] = $true
                    }
                    & $resolved @params @splat 2>&1 | Out-Null
                }
            }
            catch { $err = $_ }

            if (-not $err) {
                Write-PSFMessage -Level Verbose -Message "Azure: live probe of '$Command' raised no authorization error." -Tag 'PSAutoRBAC', 'Provider', 'Azure', 'LiveProbe'
                return [pscustomobject]@{
                    PSTypeName  = 'PSAutoRBAC.Requirement'
                    Platform    = 'Azure'; Command = $Command; Roles = @(); Permissions = @()
                    ScopeLevel  = 'Unknown'
                    Notes       = 'Live probe completed without an authorization error: the probe identity already has sufficient access, or the command did not reach ARM (e.g. -WhatIf short-circuited before the REST call).'
                    IsKnown     = $true; Source = 'LiveProbe'
                }
            }

            $parsed = ConvertFrom-AzAuthorizationError -InputObject $err
            if (-not $parsed.IsAuthorizationError) {
                Write-PSFMessage -Level Warning -Message "Azure: live probe of '$Command' failed with a non-authorization error; re-throwing." -Tag 'PSAutoRBAC', 'Provider', 'Azure', 'LiveProbe'
                throw $err
            }
            Write-PSFMessage -Level Significant -Message "Azure: derived required action(s) [$($parsed.Actions -join ', ')] from live AuthorizationFailed." -Tag 'PSAutoRBAC', 'Provider', 'Azure', 'LiveProbe'

            $match = ConvertTo-RBACRole -Action $parsed.Actions -Context $Context
            $derivedScope = if ($parsed.Scopes) { $parsed.Scopes[0] } else { $Scope }

            [pscustomobject]@{
                PSTypeName  = 'PSAutoRBAC.Requirement'
                Platform    = 'Azure'
                Command     = $Command
                Roles       = @($match.Roles)
                Permissions = @($parsed.Actions)
                ScopeLevel  = 'Resource'
                Notes       = "Derived from live AuthorizationFailed at scope '$derivedScope' (role source: $($match.Source))."
                IsKnown     = $true
                Source      = 'LiveProbe'
            }
        }

        NewGrantScript = {
            param($CallerId, $Role, $Scope, $Options)
            $scope = $Scope.TrimEnd('/')
            $add = @"
# PSAutoRBAC: grant '$Role' to '$CallerId' at scope '$scope'.
# Run as an identity holding Microsoft.Authorization/roleAssignments/write
# (RBAC Administrator, User Access Administrator, or Owner). Idempotent.
Import-Module Az.Accounts -ErrorAction Stop
Import-Module Az.Resources -ErrorAction Stop
`$upn = '$CallerId'; `$role = '$Role'; `$scope = '$scope'
`$existing = Get-AzRoleAssignment -SignInName `$upn -RoleDefinitionName `$role -Scope `$scope -ErrorAction SilentlyContinue
if (`$existing) { Write-Host "Already assigned '`$role' to '`$upn' at '`$scope'." }
else { New-AzRoleAssignment -SignInName `$upn -RoleDefinitionName `$role -Scope `$scope; Write-Host "Granted '`$role'." }
"@

            $remove = @"
# PSAutoRBAC: revoke '$Role' from '$CallerId' at scope '$scope'.
# Run as an identity holding Microsoft.Authorization/roleAssignments/delete. Idempotent.
Import-Module Az.Accounts -ErrorAction Stop
Import-Module Az.Resources -ErrorAction Stop
`$upn = '$CallerId'; `$role = '$Role'; `$scope = '$scope'
`$existing = Get-AzRoleAssignment -SignInName `$upn -RoleDefinitionName `$role -Scope `$scope -ErrorAction SilentlyContinue
if (`$existing) { Remove-AzRoleAssignment -SignInName `$upn -RoleDefinitionName `$role -Scope `$scope; Write-Host "Revoked '`$role'." }
else { Write-Host "No '`$role' assignment for '`$upn' at '`$scope'." }
"@

            @{ AddScript = $add; RemoveScript = $remove }
        }
    }
}

Register-RBACProvider -Provider (New-AzureProbeProvider)