Private/Invoke-RBACAzureGuardProbe.ps1

function Invoke-RBACAzureGuardProbe {
    <#
    .SYNOPSIS
        Enriches advisory Azure guardrails with live detection.
    .DESCRIPTION
        Internal. Given the offline advisory guards for the Azure platform, this
        helper promotes the ones that can be checked against ARM - resource locks,
        deny assignments, policy assignments, and PIM-eligible resource roles -
        from 'Potential' to 'Blocking' or 'Clear', attaching the concrete evidence
        (lock name/level, deny assignment name, policy assignment display name,
        eligible role name). Every probe is best-effort and StrictMode-safe: a
        missing Az cmdlet or a query error leaves the guard at its advisory
        'Potential' state rather than throwing, so the caller always gets a result.
 
        Only the guards present in the supplied set are examined (so a -GuardType
        filter upstream is honoured), and each guard object is mutated in place and
        returned.
    .PARAMETER Guard
        The advisory guards (PSAutoRBAC.Guard) for the Azure platform.
    .PARAMETER CallerId
        The identity whose eligible roles are inspected.
    .PARAMETER Scope
        The ARM scope to evaluate locks / deny assignments / policy at.
    .PARAMETER Context
        Optional PSAutoRBAC.Context for DefaultProfile.
    .OUTPUTS
        PSCustomObject (PSAutoRBAC.Guard)
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [psobject[]]$Guard,
        [Parameter()] [string]$CallerId,
        [Parameter()] [string]$Scope,
        [Parameter()] [psobject]$Context
    )

    # StrictMode-safe property reader.
    $get = { param($o, $n) if ($o -and ($o.PSObject.Properties.Name -contains $n)) { $o.$n } }

    $byType = @{}
    foreach ($g in $Guard) { $byType[$g.GuardType] = $g }

    $common = @{ ErrorAction = 'SilentlyContinue' }
    if ($Context -and ($Context.PSObject.Properties.Name -contains 'AzContext') -and $Context.AzContext) {
        $common['DefaultProfile'] = $Context.AzContext
    }

    # 1) Resource locks - ReadOnly blocks write, CanNotDelete blocks delete.
    if ($byType.ContainsKey('ResourceLock') -and (Get-Command -Name 'Get-AzResourceLock' -ErrorAction SilentlyContinue)) {
        $g = $byType['ResourceLock']
        try {
            $locks = @(Get-AzResourceLock -Scope $Scope @common)
            if ($locks.Count -gt 0) {
                $g.Status = 'Blocking'; $g.Source = 'Live'
                $g.Evidence = @($locks | ForEach-Object {
                    $props = & $get $_ 'Properties'
                    $level = & $get $props 'level'
                    $name  = & $get $_ 'Name'
                    "lock '$name' (level: $level)"
                })
                Write-PSFMessage -Level Significant -Message "Guard(Azure): $($locks.Count) management lock(s) at '$Scope' will override RBAC." -Tag 'PSAutoRBAC', 'Guard', 'Azure'
            }
            else { $g.Status = 'Clear'; $g.Source = 'Live' }
        }
        catch { Write-PSFMessage -Level Verbose -Message "Guard(Azure): lock probe failed ($($_.Exception.Message)); leaving advisory." -Tag 'PSAutoRBAC', 'Guard', 'Azure' }
    }

    # 2) Deny assignments - take precedence over role assignments.
    if ($byType.ContainsKey('DenyAssignment') -and (Get-Command -Name 'Get-AzDenyAssignment' -ErrorAction SilentlyContinue)) {
        $g = $byType['DenyAssignment']
        try {
            $denies = @(Get-AzDenyAssignment -Scope $Scope @common)
            if ($denies.Count -gt 0) {
                $g.Status = 'Blocking'; $g.Source = 'Live'
                $g.Evidence = @($denies | ForEach-Object {
                    $name = & $get $_ 'DenyAssignmentName'; if (-not $name) { $name = & $get $_ 'Id' }
                    "deny assignment '$name'"
                })
                Write-PSFMessage -Level Significant -Message "Guard(Azure): $($denies.Count) deny assignment(s) at '$Scope' can override role assignments." -Tag 'PSAutoRBAC', 'Guard', 'Azure'
            }
            else { $g.Status = 'Clear'; $g.Source = 'Live' }
        }
        catch { Write-PSFMessage -Level Verbose -Message "Guard(Azure): deny-assignment probe failed ($($_.Exception.Message)); leaving advisory." -Tag 'PSAutoRBAC', 'Guard', 'Azure' }
    }

    # 3) Azure Policy assignments in scope - deny/DINE effects can reject or mutate.
    # Assignment presence alone does not prove a deny effect, so this stays
    # 'Potential' with the assignment names as evidence (Clear when none).
    if ($byType.ContainsKey('AzurePolicyDeny') -and (Get-Command -Name 'Get-AzPolicyAssignment' -ErrorAction SilentlyContinue)) {
        $g = $byType['AzurePolicyDeny']
        try {
            $assignments = @(Get-AzPolicyAssignment -Scope $Scope @common)
            if ($assignments.Count -gt 0) {
                $g.Source = 'Live'
                $g.Evidence = @($assignments | ForEach-Object {
                    $props = & $get $_ 'Properties'
                    $dn = & $get $props 'DisplayName'; if (-not $dn) { $dn = & $get $_ 'Name' }
                    "policy assignment '$dn'"
                })
                Write-PSFMessage -Level Verbose -Message "Guard(Azure): $($assignments.Count) policy assignment(s) apply at '$Scope'; verify for deny/deployIfNotExists effects." -Tag 'PSAutoRBAC', 'Guard', 'Azure'
            }
            else { $g.Status = 'Clear'; $g.Source = 'Live' }
        }
        catch { Write-PSFMessage -Level Verbose -Message "Guard(Azure): policy probe failed ($($_.Exception.Message)); leaving advisory." -Tag 'PSAutoRBAC', 'Guard', 'Azure' }
    }

    # 4) PIM - resource role assigned as eligible (needs activation to be effective).
    if ($byType.ContainsKey('PrivilegedIdentityManagement') -and $CallerId -and
        (Get-Command -Name 'Get-AzRoleEligibilitySchedule' -ErrorAction SilentlyContinue)) {
        $g = $byType['PrivilegedIdentityManagement']
        try {
            $eligible = @(Get-AzRoleEligibilitySchedule -Scope $Scope @common |
                Where-Object {
                    $p = & $get $_ 'PrincipalId'; $u = & $get $_ 'PrincipalDisplayName'; $e = & $get $_ 'PrincipalEmail'
                    ($p -eq $CallerId) -or ($u -eq $CallerId) -or ($e -eq $CallerId)
                })
            if ($eligible.Count -gt 0) {
                $g.Status = 'Potential'; $g.Source = 'Live'
                $g.Evidence = @($eligible | ForEach-Object {
                    $r = & $get $_ 'RoleDefinitionDisplayName'; if (-not $r) { $r = & $get $_ 'Name' }
                    "eligible role '$r' (activate before use)"
                })
                Write-PSFMessage -Level Significant -Message "Guard(Azure): '$CallerId' has $($eligible.Count) PIM-eligible role(s) at '$Scope' requiring activation." -Tag 'PSAutoRBAC', 'Guard', 'Azure'
            }
            else { $g.Status = 'Clear'; $g.Source = 'Live' }
        }
        catch { Write-PSFMessage -Level Verbose -Message "Guard(Azure): PIM probe failed ($($_.Exception.Message)); leaving advisory." -Tag 'PSAutoRBAC', 'Guard', 'Azure' }
    }

    $Guard
}