Public/Grant-IOManagedIdentityPermission.ps1

function Grant-IOManagedIdentityPermission {
    <#
    .SYNOPSIS
        Grants Microsoft Graph (or other API) app role permissions to a managed identity.
    .DESCRIPTION
        This is one of the most common pain points — assigning API permissions to a
        managed identity requires raw Graph API calls. This command simplifies it.
    .EXAMPLE
        Grant-IOManagedIdentityPermission -ManagedIdentityName "my-func-app" -Permission "Mail.Send"
    .EXAMPLE
        Grant-IOManagedIdentityPermission -ManagedIdentityObjectId "aaaa-bbbb..." -Permission "User.Read.All","Group.Read.All"
    .EXAMPLE
        Grant-IOManagedIdentityPermission -ManagedIdentityName "my-vm" -Permission "Sites.Read.All" -ResourceAppId "00000003-0000-0ff1-ce00-000000000000"
        # Grants SharePoint Online permission instead of MS Graph
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess)]
    param(
        [Parameter(ParameterSetName = 'ByName', Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ManagedIdentityName,

        [Parameter(ParameterSetName = 'ById', Mandatory)]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [string]$ManagedIdentityObjectId,

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

        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [string]$ResourceAppId = $script:MsGraphAppId,

        [string]$ToCsv
    )

    $cmdName = $MyInvocation.MyCommand.Name

    # ── Resolve managed identity service principal ─────────────────────────
    if ($PSCmdlet.ParameterSetName -eq 'ByName') {
        Write-IOLog "Looking up managed identity: $ManagedIdentityName" -Level Verbose -Component $cmdName
        # Sanitize input for OData filter — block injection characters
        if ($ManagedIdentityName -match '[;$&\\/<>{}]') {
            throw [System.Management.Automation.ErrorRecord]::new(
                [System.ArgumentException]::new("ManagedIdentityName contains invalid characters."),
                'IO_InvalidInput',
                [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $ManagedIdentityName
            )
        }
        $sanitizedName = $ManagedIdentityName -replace "'", "''"
        $filter = "displayName eq '$sanitizedName'"
        $sps = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals?`$filter=$filter&`$select=id,displayName,appId,servicePrincipalType"

        if (-not $sps -or $sps.Count -eq 0) {
            throw [System.Management.Automation.ErrorRecord]::new(
                [System.Management.Automation.ItemNotFoundException]::new(
                    "Managed identity '$ManagedIdentityName' not found. Verify the name matches the enterprise app display name."
                ),
                'IO_ManagedIdentityNotFound',
                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                $ManagedIdentityName
            )
        }
        if ($sps.Count -gt 1) {
            Write-IOLog "Multiple matches found for '$ManagedIdentityName'. Using first result: $($sps[0].id)" -Level Warning -Component $cmdName
        }
        $miSp = $sps[0]
    }
    else {
        Write-IOLog "Looking up managed identity by ObjectId: $ManagedIdentityObjectId" -Level Verbose -Component $cmdName
        $miSp = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals/$ManagedIdentityObjectId" -SingleResult -NoPagination
        if (-not $miSp -or $miSp.Count -eq 0) {
            throw [System.Management.Automation.ErrorRecord]::new(
                [System.Management.Automation.ItemNotFoundException]::new(
                    "Service principal with ObjectId '$ManagedIdentityObjectId' not found."
                ),
                'IO_ServicePrincipalNotFound',
                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                $ManagedIdentityObjectId
            )
        }
        $miSp = $miSp[0]
    }

    # ── Resolve resource service principal ─────────────────────────────────
    Write-IOLog "Resolving resource app: $ResourceAppId" -Level Verbose -Component $cmdName
    $resSps = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals?`$filter=appId eq '$ResourceAppId'&`$select=id,displayName,appRoles"
    if (-not $resSps -or $resSps.Count -eq 0) {
        throw [System.Management.Automation.ErrorRecord]::new(
            [System.Management.Automation.ItemNotFoundException]::new(
                "Resource application with appId '$ResourceAppId' not found in tenant."
            ),
            'IO_ResourceAppNotFound',
            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
            $ResourceAppId
        )
    }
    $resourceSp = $resSps[0]

    # ── Get existing assignments to avoid duplicates ───────────────────────
    $existingAssignments = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals/$($miSp.id)/appRoleAssignments?`$select=appRoleId,resourceId"
    $existingRoleIds = @($existingAssignments | ForEach-Object { $_.appRoleId })

    # ── Assign each permission ─────────────────────────────────────────────
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($perm in $Permission) {
        $appRole = $resourceSp.appRoles | Where-Object { $_.value -eq $perm -and $_.allowedMemberTypes -contains 'Application' }

        if (-not $appRole) {
            Write-IOLog "Permission '$perm' not found as an Application role on resource '$($resourceSp.displayName)'. Skipped." -Level Warning -Component $cmdName
            $results.Add([PSCustomObject]@{
                ManagedIdentity = $miSp.displayName
                MIObjectId      = $miSp.id
                Permission      = $perm
                ResourceApp     = $resourceSp.displayName
                Status          = 'NOT_FOUND'
                Message         = 'App role not found on resource'
            })
            continue
        }

        if ($appRole.id -in $existingRoleIds) {
            Write-IOLog "Permission '$perm' already assigned to '$($miSp.displayName)'. Skipped." -Level Info -Component $cmdName
            $results.Add([PSCustomObject]@{
                ManagedIdentity = $miSp.displayName
                MIObjectId      = $miSp.id
                Permission      = $perm
                ResourceApp     = $resourceSp.displayName
                Status          = 'ALREADY_ASSIGNED'
                Message         = 'Permission was already granted'
            })
            continue
        }

        if ($PSCmdlet.ShouldProcess("$($miSp.displayName)", "Grant '$perm' from $($resourceSp.displayName)")) {
            try {
                $body = @{
                    principalId = $miSp.id
                    resourceId  = $resourceSp.id
                    appRoleId   = $appRole.id
                }
                Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals/$($miSp.id)/appRoleAssignments" -Method POST -Body $body -NoPagination | Out-Null

                Write-IOLog "Granted '$perm' to '$($miSp.displayName)'." -Level Info -Component $cmdName
                $results.Add([PSCustomObject]@{
                    ManagedIdentity = $miSp.displayName
                    MIObjectId      = $miSp.id
                    Permission      = $perm
                    ResourceApp     = $resourceSp.displayName
                    Status          = 'GRANTED'
                    Message         = 'Successfully assigned'
                })
            }
            catch {
                Write-IOLog "Failed to grant '$perm': $($_.Exception.Message)" -Level Warning -Component $cmdName
                $results.Add([PSCustomObject]@{
                    ManagedIdentity = $miSp.displayName
                    MIObjectId      = $miSp.id
                    Permission      = $perm
                    ResourceApp     = $resourceSp.displayName
                    Status          = 'FAILED'
                    Message         = $_.Exception.Message
                })
            }
        }
    }

    Export-IOResult -Data $results -ToCsv $ToCsv -CommandName $cmdName
}