Providers/GraphProbeProvider.ps1

function Get-GraphCommandPermission {
    <#
    .SYNOPSIS
        Resolves the least-privilege Graph permissions for a cmdlet via the catalog.
    .DESCRIPTION
        Internal. Wraps Find-MgGraphCommand (the documented mechanism for finding
        a cmdlet's required permissions). Returns a hashtable
        @{ Permissions=[string[]]; Source=string } or $null when the catalog is
        unavailable or the command is unknown.
    .OUTPUTS
        System.Collections.Hashtable or $null
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Command)

    if (-not (Get-Command -Name 'Find-MgGraphCommand' -ErrorAction SilentlyContinue)) {
        Write-PSFMessage -Level Debug -Message "Graph: Find-MgGraphCommand unavailable; will use knowledge-base fallback for '$Command'." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
        return $null
    }
    try {
        $cmd = Find-MgGraphCommand -Command $Command -ErrorAction Stop | Select-Object -First 1
        if (-not $cmd) { return $null }
        $perms = @($cmd.Permissions | Where-Object { $_ } | Select-Object -ExpandProperty Name -Unique)
        Write-PSFMessage -Level Debug -Message "Graph: catalog resolved '$Command' to permission(s) [$($perms -join ', ')]." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
        return @{ Permissions = $perms; Source = 'GraphCommandCatalog' }
    }
    catch {
        Write-PSFMessage -Level Warning -Message "Graph: Find-MgGraphCommand failed for '$Command': $($_.Exception.Message)" -Tag 'PSAutoRBAC', 'Provider', 'Graph'
        return $null
    }
}

function New-GraphProbeProvider {
    <#
    .SYNOPSIS
        Constructs the Microsoft Graph RBAC probe provider.
    .DESCRIPTION
        Internal factory. Graph's authorization error
        ('Authorization_RequestDenied. Insufficient privileges to complete the
        operation.') does NOT name the missing permission, so error-parsing is
        useless here. Instead this provider resolves requirements authoritatively
        with Find-MgGraphCommand (Microsoft's documented mechanism for "what does
        this cmdlet need"), falling back to the offline knowledge base. Access is
        evaluated against the connected identity's consented scopes / app roles
        (Get-MgContext) and, for directory-role requirements, directory role
        membership.
    .OUTPUTS
        PSCustomObject (PSAutoRBAC.Provider)
    #>

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

    [pscustomobject]@{
        PSTypeName        = 'PSAutoRBAC.Provider'
        Name              = 'Microsoft Graph'
        Aliases           = @('Graph', 'Entra', 'MgGraph', 'AzureAD', 'Microsoft Entra')
        SupportsLiveProbe = $false
        MatchScope        = { param($Assignment, $Scope) $true }  # Graph permissions are tenant-wide

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

            Write-PSFMessage -Level Verbose -Message "Graph: resolving requirement for '$Command'." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
            $catalog = Get-GraphCommandPermission -Command $Command

            $mapPath = if ($Options -and $Options.ContainsKey('MapPath')) { $Options.MapPath } else { $null }
            $map = if ($mapPath) { Get-RBACKnowledgeBase -Path $mapPath } else { Get-RBACKnowledgeBase -Name 'CommandRoleMap' }
            $commands = $map['Microsoft Graph']
            $commandKey = $commands.Keys | Where-Object { $_ -eq $Command } | Select-Object -First 1
            $isKnown = [bool]$commandKey
            if (-not $commandKey) { $commandKey = '*Default' }
            $entry = $commands[$commandKey]

            $permissions = if ($catalog -and $catalog.Permissions.Count) { $catalog.Permissions } else { @($entry.Actions) }
            $source = if ($catalog -and $catalog.Permissions.Count) { $catalog.Source } else { 'KnowledgeBase' }
            if ($catalog -and $catalog.Permissions.Count) { $isKnown = $true }
            Write-PSFMessage -Level Debug -Message "Graph: '$Command' -> role(s) [$(@($entry.Roles) -join ', ')], permission(s) [$(@($permissions) -join ', ')] (source: $source)." -Tag 'PSAutoRBAC', 'Provider', 'Graph'

            [pscustomobject]@{
                PSTypeName  = 'PSAutoRBAC.Requirement'
                Platform    = 'Microsoft Graph'
                Command     = $Command
                Roles       = @($entry.Roles)           # least-privilege directory role(s)
                Permissions = $permissions              # Graph API scopes / app roles
                ScopeLevel  = 'Tenant'
                Notes       = $entry.Notes
                IsKnown     = $isKnown
                Source      = $source
            }
        }

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

            Write-PSFMessage -Level Verbose -Message "Graph: testing '$CallerId' for [$(@($RequiredRole) -join ', ')] (tenant-wide)." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
            # Offline / explicit: caller passes the granted scopes or directory roles.
            $granted = $null
            if ($Options -and $Options.ContainsKey('RoleAssignment')) {
                $granted = @($Options.RoleAssignment | ForEach-Object {
                    if ($_ -is [string]) { $_ } elseif ($_.RoleDefinitionName) { $_.RoleDefinitionName } else { "$_" }
                })
                Write-PSFMessage -Level Debug -Message "Graph: evaluating against supplied scopes/roles [$($granted -join ', ')]." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
            }
            elseif (Get-Command -Name 'Get-MgContext' -ErrorAction SilentlyContinue) {
                $ctx = Get-MgContext -ErrorAction SilentlyContinue
                if ($ctx) {
                    $granted = @($ctx.Scopes)
                    Write-PSFMessage -Level Debug -Message "Graph: evaluating against connected-identity scopes [$($granted -join ', ')]." -Tag 'PSAutoRBAC', 'Provider', 'Graph'
                }
            }
            if ($null -eq $granted) {
                Write-PSFMessage -Level Warning -Message 'Graph: no connected context and no -RoleAssignment; access reported as not held.' -Tag 'PSAutoRBAC', 'Provider', 'Graph'
            }

            foreach ($role in $RequiredRole) {
                $has = $false
                if ($null -ne $granted) {
                    $has = [bool](@($granted) -contains $role)
                }
                [pscustomobject]@{
                    PSTypeName = 'PSAutoRBAC.AccessState'
                    Platform   = 'Microsoft Graph'
                    CallerId   = $CallerId
                    Role       = $role
                    Scope      = 'Tenant'
                    HasAccess  = $has
                }
            }
        }

        ProbeLive = {
            param($Command, $ArgumentList, $Scope, $Context)
            # Graph errors are non-diagnostic; we never derive a requirement from a
            # failure. Resolve authoritatively from the command catalog instead.
            $catalog = Get-GraphCommandPermission -Command $Command
            $perms = if ($catalog) { $catalog.Permissions } else { @() }
            [pscustomobject]@{
                PSTypeName  = 'PSAutoRBAC.Requirement'
                Platform    = 'Microsoft Graph'; Command = $Command
                Roles       = @(); Permissions = $perms; ScopeLevel = 'Tenant'
                Notes       = 'Graph authorization errors do not name the missing permission; requirement resolved from Find-MgGraphCommand rather than a live failure.'
                IsKnown     = [bool]$catalog; Source = 'GraphCommandCatalog'
            }
        }

        NewGrantScript = {
            param($CallerId, $Role, $Scope, $Options)
            $add = @"
# PSAutoRBAC: assign Entra directory role '$Role' to '$CallerId'.
# Run as Privileged Role Administrator (or Global Administrator). Idempotent.
Import-Module Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop
Connect-MgGraph -Scopes 'RoleManagement.ReadWrite.Directory' -NoWelcome
`$user = Get-MgUser -UserId '$CallerId' -ErrorAction Stop
`$def = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq '$Role'" | Select-Object -First 1
if (-not `$def) { throw "Directory role '$Role' not found." }
`$held = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '`$(`$user.Id)' and roleDefinitionId eq '`$(`$def.Id)'" -ErrorAction SilentlyContinue
if (`$held) { Write-Host "Already assigned '$Role' to '$CallerId'." }
else {
    New-MgRoleManagementDirectoryRoleAssignment -PrincipalId `$user.Id -RoleDefinitionId `$def.Id -DirectoryScopeId '/'
    Write-Host "Assigned '$Role' to '$CallerId'."
}
"@

            $remove = @"
# PSAutoRBAC: remove Entra directory role '$Role' from '$CallerId'.
# Run as Privileged Role Administrator (or Global Administrator). Idempotent.
Import-Module Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop
Connect-MgGraph -Scopes 'RoleManagement.ReadWrite.Directory' -NoWelcome
`$user = Get-MgUser -UserId '$CallerId' -ErrorAction Stop
`$def = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq '$Role'" | Select-Object -First 1
if (-not `$def) { throw "Directory role '$Role' not found." }
`$held = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '`$(`$user.Id)' and roleDefinitionId eq '`$(`$def.Id)'" -ErrorAction SilentlyContinue
if (`$held) { Remove-MgRoleManagementDirectoryRoleAssignment -UnifiedRoleAssignmentId `$held.Id; Write-Host "Removed '$Role'." }
else { Write-Host "No '$Role' assignment for '$CallerId'." }
"@

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

Register-RBACProvider -Provider (New-GraphProbeProvider)