Public/Convert-ApplicationAccessPolicyToRBAC.ps1

<#
.SYNOPSIS
Migrates legacy Exchange Online Application Access Policies to RBAC for Applications.

.DESCRIPTION
Convert-ApplicationAccessPolicyToRBAC reads existing Application Access Policy entries
(via Get-ApplicationAccessPolicy) and recreates the equivalent access as RBAC for
Applications assignments by delegating to New-RBACforAppEntry.

For each RestrictAccess policy it resolves the Entra service principal, derives the
Exchange Online application roles from the app's granted Microsoft Graph application
permissions (filtered to the set that Application Access Policies supported and mapped to
their App RBAC role names via the private Get-LegacyScopeRoleMap), copies the members of
the original policy scope group, and calls New-RBACforAppEntry to create a new scoped
Unified Group and the matching role assignments. DenyAccess policies have no additive RBAC
equivalent and are skipped with a warning.

The function supports -WhatIf and -Confirm through SupportsShouldProcess; -WhatIf
propagates into the delegated New-RBACforAppEntry call.

.PARAMETER AppId
Application (client) id. When supplied, only Application Access Policies for that app are
converted. When omitted, all Application Access Policies are processed.

.PARAMETER Policy
Application Access Policy object(s) to convert, as emitted by Get-ApplicationAccessPolicy.
Accepts pipeline input so 'Get-ApplicationAccessPolicy | Convert-ApplicationAccessPolicyToRBAC'
works.

.PARAMETER Role
Exchange Online application roles to assign instead of the auto-derived set. Short names
such as Mail.Send are normalized to Application Mail.Send. When supplied, role derivation
from the app's Graph permissions is skipped.

.PARAMETER ManagedBy
Recipient that will be assigned as the new Unified Group owner. Passed through to
New-RBACforAppEntry.

.PARAMETER GroupPrefix
Prefix used when building the new Unified Group name. Passed through to New-RBACforAppEntry.

.EXAMPLE
Convert-ApplicationAccessPolicyToRBAC -WhatIf -Verbose

Shows the planned conversion for every Application Access Policy without making changes.

.EXAMPLE
Get-ApplicationAccessPolicy | Convert-ApplicationAccessPolicyToRBAC

Converts every Application Access Policy piped in from Get-ApplicationAccessPolicy.

.EXAMPLE
Convert-ApplicationAccessPolicyToRBAC -AppId '11111111-2222-3333-4444-555555555555' -Role 'Mail.Send'

Converts the policies for one app, assigning the explicit Application Mail.Send role
instead of deriving roles from the app's Graph permission grants.

.OUTPUTS
PSCustomObject

Returns one summary object per processed policy with the resolved identity, access right,
scope group, derived roles, skipped permissions, copied members, the New-RBACforAppEntry
result, warnings, and errors.

.NOTES
Requires connected Microsoft Graph (Get-MgServicePrincipal,
Get-MgServicePrincipalAppRoleAssignment) and Exchange Online
(Get-ApplicationAccessPolicy, plus the cmdlets used by New-RBACforAppEntry) sessions.
#>

function Convert-ApplicationAccessPolicyToRBAC {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'All')]
    param(
        [Parameter(ParameterSetName = 'ByAppId')]
        [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')]
        [Alias('ClientId','ApplicationId')]
        [string] $AppId,

        [Parameter(ValueFromPipeline, ParameterSetName = 'ByPolicy')]
        [ValidateNotNull()]
        [object[]] $Policy,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]] $Role,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ManagedBy = 'GraphAPI-Dummy-owner',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $GroupPrefix = 'Um365RAo1'
    )

    begin {
        $legacyRoleMap = Get-LegacyScopeRoleMap
        # Cache resource service principals (e.g. Microsoft Graph) by ResourceId so the
        # AppRoleId -> permission value lookup only fetches each resource SP once.
        $resourceSpCache = @{}

        # Collect piped policies so non-pipeline filtering still works in end{}.
        $pipedPolicies = [System.Collections.Generic.List[object]]::new()
    }

    process {
        if ($Policy) {
            foreach ($p in $Policy) { $pipedPolicies.Add($p) }
        }
    }

    end {
        # --- Source the policies to convert
        $policies =
            if ($pipedPolicies.Count -gt 0) {
                $pipedPolicies
            }
            else {
                try {
                    if ($PSBoundParameters.ContainsKey('AppId')) {
                        @(Get-ApplicationAccessPolicy -AppId $AppId -ErrorAction Stop)
                    }
                    else {
                        @(Get-ApplicationAccessPolicy -ErrorAction Stop)
                    }
                }
                catch {
                    Write-Error -Message ("Failed to read Application Access Policies: {0}" -f $_.Exception.Message)
                    return
                }
            }

        if (-not $policies -or @($policies).Count -eq 0) {
            Write-Verbose -Message 'No Application Access Policies found to convert.'
            return
        }

        foreach ($pol in $policies) {
            $polAppId = $pol.AppId
            $result = [ordered]@{
                AppId              = $polAppId
                ResolvedDisplay    = $null
                AccessRight        = $pol.AccessRight
                ScopeGroup         = $pol.ScopeName
                DerivedRoles       = @()
                SkippedPermissions = @()
                MembersCopied      = @()
                RBACResult         = $null
                Warnings           = @()
                Errors             = @()
            }

            try {
                # --- Only RestrictAccess policies map to additive RBAC grants
                if ($pol.AccessRight -and $pol.AccessRight -ne 'RestrictAccess') {
                    $skipMsg = "Policy AccessRight '$($pol.AccessRight)' for AppId '$polAppId' has no additive RBAC equivalent and was skipped."
                    $result.Warnings += $skipMsg
                    Write-Warning -Message $skipMsg
                    [pscustomobject]$result
                    continue
                }

                # --- Resolve the service principal from the policy AppId
                $filter = "appId eq `'$polAppId`'"
                $matchesRes = @(Get-MgServicePrincipal -Filter $filter -ErrorAction Stop)
                if ($matchesRes.Count -eq 0) { throw "No service principal found for AppId '$polAppId'." }
                if ($matchesRes.Count -gt 1) { throw "Unexpected: multiple service principals for AppId '$polAppId'." }
                $sp = $matchesRes[0]
                $result.ResolvedDisplay = $sp.DisplayName

                # --- Determine roles: explicit -Role override, else derive from grants
                $rolesNormalized = @()
                if ($PSBoundParameters.ContainsKey('Role')) {
                    $rolesNormalized = @($Role | ForEach-Object { Get-NormalizeRole $_ } | Select-Object -Unique)
                }
                else {
                    $grants = @(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -ErrorAction Stop)
                    foreach ($grant in $grants) {
                        $permValue = Resolve-AppRolePermissionValue -Grant $grant -Cache $resourceSpCache
                        if (-not $permValue) {
                            $result.SkippedPermissions += "AppRoleId $($grant.AppRoleId) (could not resolve permission name)"
                            continue
                        }
                        if ($legacyRoleMap.Contains($permValue)) {
                            $rolesNormalized += $legacyRoleMap[$permValue]
                        }
                        else {
                            $result.SkippedPermissions += $permValue
                        }
                    }
                    $rolesNormalized = @($rolesNormalized | Select-Object -Unique)
                }

                $result.DerivedRoles = @($rolesNormalized)

                if (@($rolesNormalized).Count -eq 0) {
                    $noRoleMsg = "No convertible Application Access Policy permissions found for AppId '$polAppId'; nothing to assign."
                    $result.Warnings += $noRoleMsg
                    Write-Warning -Message $noRoleMsg
                    [pscustomobject]$result
                    continue
                }

                # --- Collect members of the original scope group
                $members = @()
                if ($pol.ScopeName) {
                    try {
                        $groupMembers = @(Get-DistributionGroupMember -Identity $pol.ScopeName -ErrorAction Stop)
                        $members = @($groupMembers | ForEach-Object { [string]$_.PrimarySmtpAddress } | Where-Object { $_ })
                    }
                    catch {
                        $memberWarn = "Could not read members of scope group '$($pol.ScopeName)': $($_.Exception.Message)"
                        $result.Warnings += $memberWarn
                        Write-Warning -Message $memberWarn
                    }
                }
                $result.MembersCopied = @($members)

                # --- Delegate to New-RBACforAppEntry (-WhatIf propagates)
                if ($PSCmdlet.ShouldProcess(
                        "AppId $polAppId ($($sp.DisplayName))",
                        "Convert Application Access Policy to RBAC roles: $($rolesNormalized -join ', ')")) {

                    $rbacParams = @{
                        AppId       = $polAppId
                        Role        = $rolesNormalized
                        ManagedBy   = $ManagedBy
                        GroupPrefix = $GroupPrefix
                        ErrorAction = 'Stop'
                    }
                    if ($members.Count -gt 0) { $rbacParams['Members'] = $members }

                    $result.RBACResult = New-RBACforAppEntry @rbacParams
                    foreach ($w in @($result.RBACResult.Warnings)) { $result.Warnings += $w }
                    foreach ($e in @($result.RBACResult.Errors))   { $result.Errors   += $e }
                }

                [pscustomobject]$result
            }
            catch {
                $result.Errors += $_.Exception.Message
                [pscustomobject]$result
            }
        }
    }
}