Providers/Entra/Private/Split-EntraOwnedObject.ps1

function Split-EntraOwnedObject {
    <#
    .SYNOPSIS
        Separates the applications or service principals an identity owns from those it does not

    .DESCRIPTION
        Application.ReadWrite.OwnedBy lets an identity delete only the applications and
        service principals it owns. The grant is the permission the bootstrapped service app
        holds, deliberately, so a teardown running as that app can meet seeded applications it
        did not create - by a human, or by a service app since replaced - and cannot delete.
        Asking the person to confirm each of those and then failing each with a 403 is what
        this exists to prevent: the owners are read first, and the objects the identity does
        not own are set aside with a reason before anything is prompted for.

        An owners read that fails leaves the object in the owned list. Refusing to try on no
        evidence is worse than one failed delete that reports itself.

    .PARAMETER Object
        The applications or service principals to split, as Get-EntraSeededObject returned them.

    .PARAMETER Type
        Applications or ServicePrincipals; decides the owners path.

    .PARAMETER IdentityObjectId
        The object id of the connected identity, from Get-EntraTeardownCapability.

    .PARAMETER Connection
        The connection to read through. Defaults to the active one.

    .OUTPUTS
        PSCustomObject with Owned and Unowned, each an array of the input objects.

    .EXAMPLE
        PS> $split = Split-EntraOwnedObject -Object $applications -Type Applications -IdentityObjectId $capability.IdentityObjectId

        DESCRIPTION: Splits the seeded applications by whether the service app owns them
        OUTPUT: Owned and Unowned arrays
        USE CASE: The applications layer of Remove-EntraEnvironment under an OwnedBy grant

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [AllowEmptyCollection()]
        [object[]]$Object = @(),

        [Parameter(Mandatory = $true)]
        [ValidateSet('Applications', 'ServicePrincipals')]
        [string]$Type,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$IdentityObjectId,

        [Parameter()]
        [hashtable]$Connection
    )

    if (-not $Connection) { $Connection = Get-EntraConnection }
    $path = if ($Type -eq 'Applications') { '/applications' } else { '/servicePrincipals' }

    $owned = [System.Collections.Generic.List[object]]::new()
    $unowned = [System.Collections.Generic.List[object]]::new()

    foreach ($item in $Object) {
        try {
            $owners = @(Invoke-EntraRequest -Method GET -Path "$path/$($item.id)/owners" -Connection $Connection -Paginate -Query @{ '$select' = 'id' })
            if (@($owners | ForEach-Object { [string]$_.id }) -contains $IdentityObjectId) { $owned.Add($item) }
            else { $unowned.Add($item) }
        }
        catch {
            Write-Verbose "Could not read the owners of '$($item.displayName)'; attempting it anyway: $($_.Exception.Message)"
            $owned.Add($item)
        }
    }

    return [PSCustomObject]@{
        Owned   = $owned.ToArray()
        Unowned = $unowned.ToArray()
    }
}