Public/Set-RBACAccess.ps1

function Set-RBACAccess {
    <#
    .SYNOPSIS
        Discovers, evaluates, and (optionally) grants the least-privilege RBAC a
        caller needs to run a command.
 
    .DESCRIPTION
        End-to-end orchestration over the probe providers:
 
          1. Resolve the requirement (preflight, or -LiveProbe for Azure).
          2. Test whether -CallerId already holds each required role at the scope.
          3. Generate idempotent grant / revoke snippets.
          4. With -WhatIf, only report. Otherwise grant missing roles, honouring
             -Confirm. Automatic granting is implemented for Azure
             (New-AzRoleAssignment); for Graph / Fabric / Purview the AddScript is
             returned for review and the result is marked Applied = $false, because
             those grants are intentionally not executed automatically.
 
        Designed for Zero Trust provisioning: a framework identity holding only
        the relevant *roleAssignments write* permission grants the caller exactly
        the roles an operation needs - and the RemoveScript revokes them after.
 
    .PARAMETER Platform
        Platform name or alias.
 
    .PARAMETER Command
        The command whose requirement is evaluated.
 
    .PARAMETER CallerId
        The identity to evaluate and grant.
 
    .PARAMETER Scope / -SubscriptionId / -ResourceGroupName / -ManagementGroupId / -ResourceId
        Scope, or the parts to build an ARM scope from.
 
    .PARAMETER ArgumentList
        Arguments for -LiveProbe (and recorded on output).
 
    .PARAMETER LiveProbe
        Derive the requirement from a live Azure failure (Azure only).
 
    .PARAMETER IncludeGuard
        Also evaluate the guardrails (gates / blocks) that could prohibit the action
        or the role grant, attaching them to each result's Guards property.
 
    .PARAMETER GuardLive
        Detect guardrails against the live control plane (read-only), implying
        -IncludeGuard. A live 'Blocking' guard that affects the role assignment
        (a deny assignment, a ReadOnly lock, a policy deny on roleAssignments)
        causes Set-RBACAccess to skip the automatic grant and report Blocked = $true,
        so the framework never attempts a grant a guardrail would reject.
 
    .PARAMETER RoleAssignment
        Pre-fetched assignments/roles for offline evaluation.
 
    .PARAMETER Options
        Provider hashtable (e.g. @{ WorkspaceId='...' }).
 
    .PARAMETER MapPath
        Alternate knowledge-base path (testing).
 
    .PARAMETER TenantId / -RunAs*
        Probe-identity context (see Get-RBACRequirement).
 
    .EXAMPLE
        Set-RBACAccess -Platform Azure -SubscriptionId SUB1 -CallerId a@b.com `
            -Command New-AzResourceGroup -WhatIf
 
    .OUTPUTS
        PSCustomObject (PSAutoRBAC.AccessResult) per required role.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Platform,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object]$Command,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CallerId,

        [Parameter()]
        [string]$Scope,

        [Parameter()]
        [string]$SubscriptionId,

        [Parameter()]
        [string]$ResourceGroupName,

        [Parameter()]
        [string]$ManagementGroupId,

        [Parameter()]
        [string]$ResourceId,

        [Parameter()]
        [Alias('Args')]
        [object[]]$ArgumentList,

        [Parameter()]
        [switch]$LiveProbe,

        [Parameter()]
        [switch]$IncludeGuard,

        [Parameter()]
        [switch]$GuardLive,

        [Parameter()]
        [object[]]$RoleAssignment,

        [Parameter()]
        [hashtable]$Options,

        [Parameter()]
        [string]$MapPath,

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [pscredential]$RunAsCredential,

        [Parameter()]
        [pscredential]$RunAsServicePrincipal,

        [Parameter()]
        [string]$RunAsTenantId,

        [Parameter()]
        [switch]$RunAsManagedIdentity,

        [Parameter()]
        [string]$RunAsManagedIdentityClientId
    )

    $provider = Get-RBACProviderInternal -Platform $Platform
    $context  = Initialize-RBACContext -BoundParameters $PSBoundParameters
    $commandName = if ($Command -is [scriptblock]) { '<scriptblock>' } else { [string]$Command }
    Write-PSFMessage -Level Verbose -Message "Set-RBACAccess: platform '$($provider.Name)', command '$commandName', caller '$CallerId'." -Tag 'PSAutoRBAC', 'Public', 'Access'

    try {
        $resolvedScope = Resolve-RBACScope -Scope $Scope -SubscriptionId $SubscriptionId `
            -ResourceGroupName $ResourceGroupName -ManagementGroupId $ManagementGroupId `
            -ResourceId $ResourceId -AllowTenantRoot

        $opts = @{}
        if ($Options) { $opts = $Options.Clone() }
        if ($MapPath) { $opts['MapPath'] = $MapPath }

        if ($LiveProbe -and $provider.SupportsLiveProbe -and
            $PSCmdlet.ShouldProcess("$commandName (as probe identity)", 'Execute command to derive RBAC requirement (LiveProbe)')) {
            $requirement = & $provider.ProbeLive $Command $ArgumentList $resolvedScope $context
        }
        else {
            $requirement = & $provider.ResolveRequirement $commandName $context $opts
        }

        $roles = @($requirement.Roles | Where-Object { $_ })
        $testOpts = $opts.Clone()
        if ($PSBoundParameters.ContainsKey('RoleAssignment')) { $testOpts['RoleAssignment'] = $RoleAssignment }
        $states = if ($roles.Count) { @(& $provider.TestAccess $CallerId $roles $resolvedScope $context $testOpts) } else { @() }

        # Guardrails - a live 'Blocking' guard affecting the role assignment stops
        # the automatic grant, so the framework never attempts what would be rejected.
        $guards = @()
        $blockingGrantGuards = @()
        if ($IncludeGuard -or $GuardLive) {
            $guardOpts = $opts.Clone()
            if ($GuardLive) { $guardOpts['Live'] = $true }
            $guards = @(Invoke-RBACProviderGuard -Provider $provider -Command $commandName -CallerId $CallerId -Scope $resolvedScope -Context $context -Options $guardOpts)
            $blockingGrantGuards = @($guards | Where-Object { $_.Status -eq 'Blocking' -and ($_.Affects -eq 'RoleAssignment' -or $_.Affects -eq 'Both') })
            if ($blockingGrantGuards.Count -gt 0) {
                Write-PSFMessage -Level Warning -Message "Set-RBACAccess: $($blockingGrantGuards.Count) blocking guardrail(s) [$(@($blockingGrantGuards.GuardType) -join ', ')] at '$resolvedScope' would reject a role assignment; grants will be skipped." -Tag 'PSAutoRBAC', 'Public', 'Access', 'Guard'
            }
        }

        foreach ($state in $states) {
            $scripts = & $provider.NewGrantScript $CallerId $state.Role $resolvedScope $opts

            $applied = $false
            $blocked = $blockingGrantGuards.Count -gt 0
            if ($state.HasAccess -ne $true) {
                Write-PSFMessage -Level Verbose -Message "Set-RBACAccess: '$CallerId' is missing '$($state.Role)' at '$resolvedScope'." -Tag 'PSAutoRBAC', 'Public', 'Access'
                if ($blocked) {
                    Write-PSFMessage -Level Warning -Message "Set-RBACAccess: not granting '$($state.Role)' - a blocking guardrail would reject the assignment. Resolve the guardrail (see the Guards property) first." -Tag 'PSAutoRBAC', 'Public', 'Access', 'Guard'
                }
                elseif ($PSCmdlet.ShouldProcess("$CallerId at $resolvedScope", "Grant '$($state.Role)' on $($provider.Name)")) {
                    if ($provider.Name -eq 'Azure' -and (Get-Command New-AzRoleAssignment -ErrorAction SilentlyContinue)) {
                        $azp = @{ SignInName = $CallerId; RoleDefinitionName = $state.Role; Scope = $resolvedScope; ErrorAction = 'Stop' }
                        if ($context.AzContext) { $azp['DefaultProfile'] = $context.AzContext }
                        New-AzRoleAssignment @azp | Out-Null
                        $applied = $true
                        Write-PSFMessage -Level Significant -Message "Set-RBACAccess: granted '$($state.Role)' to '$CallerId' at '$resolvedScope'." -Tag 'PSAutoRBAC', 'Public', 'Access'
                    }
                    else {
                        Write-PSFMessage -Level Warning -Message "Automatic grant is not performed for '$($provider.Name)'. Run the AddScript on the result from an identity with the necessary management permission." -Tag 'PSAutoRBAC', 'Public', 'Access'
                    }
                }
            }
            else {
                Write-PSFMessage -Level Debug -Message "Set-RBACAccess: '$CallerId' already holds '$($state.Role)'; nothing to do." -Tag 'PSAutoRBAC', 'Public', 'Access'
            }

            [pscustomobject]@{
                PSTypeName   = 'PSAutoRBAC.AccessResult'
                Platform     = $provider.Name
                Command      = $commandName
                CallerId     = $CallerId
                Scope        = $resolvedScope
                Role         = $state.Role
                HasAccess    = $state.HasAccess
                Applied      = $applied
                Blocked      = $blocked
                IsKnown      = $requirement.IsKnown
                Source       = $requirement.Source
                Permissions  = $requirement.Permissions
                Notes        = $requirement.Notes
                Guards       = $guards
                AddScript    = $scripts.AddScript
                RemoveScript = $scripts.RemoveScript
            }
        }
    }
    finally {
        if ($context.IsRunAs) { & $context.Disconnect }
    }
}