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. For Azure, B2B guest home email
        resolution is carried through to the tenant object ID used for the
        automatic role assignment.
 
    .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 $testOpts

            $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 = @{ RoleDefinitionName = $state.Role; Scope = $resolvedScope; ErrorAction = 'Stop' }
                        if ($testOpts.ContainsKey('ResolvedObjectId')) {
                            $azp['ObjectId'] = $testOpts.ResolvedObjectId
                        }
                        else {
                            $azp['SignInName'] = $CallerId
                        }
                        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 }
    }
}