Export-Entra.ps1

<#
 .Synopsis
  Exports Entra's configuration and settings for a tenant
 .Description
  This cmdlet reads the configuration information from the target Entra tenant and produces the output files in a target directory
 
 .PARAMETER OutputDirectory
    Specifies the directory path where the output files will be generated.
 
.PARAMETER Type
    Specifies the type of objects to export. Default to Config which exports the key configuration settings of the tenant.
 
.PARAMETER All
    If specified performs a full export of all objects and configuration in the tenant.
 
.EXAMPLE
   .\Export-Entra -Path 'c:\temp\contoso'
 
   Runs a default export and includes the key tenant configuration settings. Does not include large data collections such as users, static groups, applications, service principals, etc.
 
   .EXAMPLE
   .\Export-Entra -Path 'c:\temp\contoso' -All
 
   Runs a full export of all objects and configuration settings.
 
.EXAMPLE
   .\Export-Entra -Path 'c:\temp\contoso' -All -CloudUsersAndGroupsOnly
 
   Runs a full export but excludes on-prem synced users and groups.
 
.EXAMPLE
   .\Export-Entra -Path 'c:\temp\contoso' -Type ConditionalAccess, AppProxy
 
   Runs an export that includes just the Conditional Access and Application Proxy settings.
 
.EXAMPLE
   .\Export-Entra -Path 'c:\temp\contoso' -Type B2C
 
   Runs an export of all B2C settings.
#>


Function Export-Entra {
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [String]$Path,

        [Parameter(Mandatory = $false)]
        [ValidateSet('All', 'Config', 'AccessReviews', 'ConditionalAccess', 'Users', 'Groups', 'Applications', 'ServicePrincipals','B2C','B2B','PIM','PIMAzure','PIMAAD', 'AppProxy', 'Organization', 'Domains', 'EntitlementManagement', 'Policies', 'AdministrativeUnits', 'SKUs', 'Identity', 'Roles', 'Governance', 'Devices')]
        [String[]]$Type = 'Config',

        [Parameter(Mandatory = $false)]
        [object]$ExportSchema,

        # Performs a full export if true
        [Parameter(Mandatory = $false)]
        [switch]
        $All,

        # Excludes onPrem synced users and groups from export
        [Parameter(Mandatory = $false)]
        [switch]
        $CloudUsersAndGroupsOnly
    )

    if ($null -eq (Get-MgContext)) {
        Write-Error "No active connection. Run Connect-EntraExporter or Connect-MgGraph to sign in and then retry."
        exit
    }
    if($All) {$Type = @('All')}
    $global:Type = $Type #Used in places like Groups where Config flag will limit the resultset to just dynamic groups.

    if (!$ExportSchema) {
        $ExportSchema = Get-EEDefaultSchema
    }

    # aditional filters
    foreach ($entry in $ExportSchema) {
        $graphUri = Get-ObjectProperty $entry "GraphUri"
        # filter out synced users or groups
        if ($CloudUsersAndGroupsOnly -and ($graphUri -in "users","groups")) {
            if([string]::IsNullOrEmpty($entry.Filter)){
                $entry.Filter = "onPremisesSyncEnabled ne true"
            }
            else {
                $entry.Filter = $entry.Filter + " and (onPremisesSyncEnabled ne true)"
            }
        }
        # get all PIM elements
        if ($All -and ($graphUri -in "privilegedAccess/aadroles/resources","privilegedAccess/azureResources/resources")) {
            $entry.Filter = $null
        }
    }

    #region helper functions
    function _randomizeRequestId {
        <#
        Adds a random number to the request ID to avoid duplicates in batch requests.
 
        Request ID in batch requests must be unique. I am using 'Path' property from $ExportSchema as the request ID.
        However, there can be multiple $ExportSchema items with the same path (e.g. 'Groups' in this case) which would lead to duplicated request IDs in the batch request and failure of the whole batch.
        To avoid this, I am appending a random number to the request ID.
        #>


        [CmdletBinding()]
        param (
            [Parameter(Mandatory = $true)]
            [string]$requestId
        )

        # add a random number to avoid duplicated ids in batch requests
        $requestId + "%%%" + (Get-Random) + "%%%"
    }

    function _normalizeRequestId {
        <#
        Removes the randomization string (added to the request ID to avoid duplicates in batch requests).
        #>


        [CmdletBinding()]
        param (
            [Parameter(Mandatory = $true)]
            [string]$requestId
        )

        # remove the random string added to avoid duplicated ids in batch requests
        $requestId -replace "\%\%\%\d+\%\%\%", ""
    }
    #endregion helper functions

    #region get parent results first
    $batchRequestStableApi = [System.Collections.Generic.List[Object]]::new()
    $batchRequestBetaApi = [System.Collections.Generic.List[Object]]::new()
    $parentWithChildren = [System.Collections.Generic.List[Object]]::new()
    $results = [System.Collections.Generic.List[Object]]::new()

    $requestedExportSchema = $ExportSchema | ? { Compare-Object $_.Tag $Type -ExcludeDifferent -IncludeEqual }

    foreach ($item in $requestedExportSchema) {
        $outputFileName = Join-Path -Path $Path -ChildPath $item.Path

        Write-Warning "Processing parent '$($item.GraphUri)' ($($item.Path))"

        # $spacer = ''

        # Write-Host "$spacer $($item.Path)"

        $command = Get-ObjectProperty $item 'Command'
        $graphUri = Get-ObjectProperty $item 'GraphUri'
        $apiVersion = Get-ObjectProperty $item 'ApiVersion'
        $ignoreError = Get-ObjectProperty $item 'IgnoreError'
        $children = Get-ObjectProperty $item 'Children'
        if (!$apiVersion) { $apiVersion = 'v1.0' }
        if($command) {
            #FIXME pri exportu pocitam ze ma RequestId
            $results.Add((Invoke-Expression -Command $command))
        }
        else {
            $uri = New-FinalUri -RelativeUri $graphUri -Select (Get-ObjectProperty $item 'Select') -QueryParameters (Get-ObjectProperty $item 'QueryParameters')
            # batch request id cannot contain '\' character
            $id = $outputFileName -replace '\\', '/'

            # to avoid duplicated ids in batch request if there are multiple $ExportSchema items with the same path ('Groups' in this case)
            $id = _randomizeRequestId $id

            Write-Verbose "Adding request '$uri' with id '$id' to the batch"

            $request = New-GraphBatchRequest -Url $uri -Id $id -header @{ ConsistencyLevel = 'eventual' }

            if ($apiVersion -eq 'beta') {
                $batchRequestBetaApi.Add($request)
            }
            else {
                $batchRequestStableApi.Add($request)
            }
        }

        if ($children) {
            $parentWithChildren.Add($item)
        }
    }

    # get the results
    if ($batchRequestStableApi) {
        Write-Verbose "Processing $($batchRequestStableApi.count) v1.0 API requests"
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestStableApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }

    if ($batchRequestBetaApi) {
        Write-Verbose "Processing $($batchRequestBetaApi.count) beta API requests"
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestBetaApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }
    #endregion get parent results first

    #region get children results
    $batchRequestStableApi = [System.Collections.Generic.List[Object]]::new()
    $batchRequestBetaApi = [System.Collections.Generic.List[Object]]::new()
    $childrenWithChildren = [System.Collections.Generic.List[Object]]::new()

    if ($parentWithChildren){
        foreach ($parent in $parentWithChildren) {
            $parentPath = Join-Path -Path $Path -ChildPath $parent.Path
            Write-Verbose "Looking for results for parent '$parentPath'"
            $parentResult = $results | Where-Object { (_normalizeRequestId $_.RequestId) -eq ($parentPath -replace "\\", "/") }

            if (!$parentResult) {
                Write-Verbose "'$parentPath' doesn't contain any data, skipping children retrieval"
                continue
            }

            $parents = $parentResult.Id

            Write-Warning "Processing children results for parent '$parentPath' ($($parents.count))"

            foreach ($item in $parent.Children) {
                Write-Verbose "Processing child '$($item.GraphUri)' ($($item.Path))"

                # $spacer = ''

                # Write-Host "$spacer $($item.Path)"

                $command = Get-ObjectProperty $item 'Command'
                $graphUri = Get-ObjectProperty $item 'GraphUri'
                $apiVersion = Get-ObjectProperty $item 'ApiVersion'
                $ignoreError = Get-ObjectProperty $item 'IgnoreError'
                $children = Get-ObjectProperty $item 'Children'
                if (!$apiVersion) { $apiVersion = 'v1.0' }
                if ($command) {
                    #FIXME pri exportu pocitam ze ma RequestId
                    $parents | % {
                        $command += " -Parents $_"
                        $result = Invoke-Expression -Command $command
                        if ($result) {
                            $results.Add($result)
                        }
                    }
                }
                else {
                    # New-GraphBatchRequest uses <placeholder> instead of {id}
                    $graphUri = $graphUri -replace '{id}', '<placeholder>'

                    $uri = New-FinalUri -RelativeUri $graphUri -Select (Get-ObjectProperty $item 'Select') -QueryParameters (Get-ObjectProperty $item 'QueryParameters')
                    # New-GraphBatchRequest needs to have uri where < and > aren't URL encoded
                    $uri = $uri -replace "%3C", "<" -replace "%3E", ">"

                    $parents | % {
                        if ($item.Path -match "\.json$") {
                            $outputFileName = Join-Path -Path $parentPath -ChildPath $item.Path
                        } else {
                            $outputFileName = Join-Path -Path $parentPath -ChildPath $_
                            $outputFileName = Join-Path -Path $outputFileName -ChildPath $item.Path
                        }
                        # batch request id cannot contain '\' character
                        $id = $outputFileName -replace '\\', '/'

                        # to avoid duplicated ids in batch request if there are multiple $ExportSchema items with the same path ('Groups' in this case)
                        $id = _randomizeRequestId $id

                        Write-Verbose "Adding request '$uri' with id '$id' to the batch"

                        $request = New-GraphBatchRequest -Url $uri -Id $id -placeholder $_ -header @{ ConsistencyLevel = 'eventual' }

                        if ($apiVersion -eq 'beta') {
                            $batchRequestBetaApi.Add($request)
                        }
                        else {
                            $batchRequestStableApi.Add($request)
                        }
                    }
                }

                if ($children) {
                    $childrenWithChildren.Add($item)
                }
            }
        }
    }

    # get the results
    if ($batchRequestStableApi) {
        Write-Verbose "Processing $($batchRequestStableApi.count) v1.0 API requests"
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestStableApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }

    if ($batchRequestBetaApi) {
        Write-Verbose "Processing $($batchRequestBetaApi.count) beta API requests"
        # FIXME poresit chyby..ne vsechno chceme jen tak ignorovat
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestBetaApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }
    #endregion get children results

    #region get children of children results
    $batchRequestStableApi = [System.Collections.Generic.List[Object]]::new()
    $batchRequestBetaApi = [System.Collections.Generic.List[Object]]::new()

    if ($childrenWithChildren){
        foreach ($parent in $childrenWithChildren) {
            $parentPath = $parent.Path
            $parentResult = $results | Where-Object { (_normalizeRequestId $_.RequestId) -like ("$parentPath (*)" -replace "\\", "/") }

            if (!$parentResult) {
                Write-Verbose "'$parentPath' doesn't contain any data, skipping children retrieval"
                continue
            }

            $parents = $parentResult.Id | select -Unique

            Write-Warning "Processing children results for parent '$parentPath' ($($parents.count))"

            foreach ($item in $parent.Children) {
                Write-Verbose "Processing child '$($item.GraphUri)' ($($item.Path))"

                # $spacer = ''

                # Write-Host "$spacer $($item.Path)"

                $command = Get-ObjectProperty $item 'Command'
                $graphUri = Get-ObjectProperty $item 'GraphUri'
                $apiVersion = Get-ObjectProperty $item 'ApiVersion'
                $ignoreError = Get-ObjectProperty $item 'IgnoreError'
                $children = Get-ObjectProperty $item 'Children'
                if (!$apiVersion) { $apiVersion = 'v1.0' }
                if ($command) {
                    #FIXME pri exportu pocitam ze ma RequestId
                    $parents | % {
                        $command += " -Parents $_"
                        $result = Invoke-Expression -Command $command
                        if ($result) {
                            $results.Add($result)
                        }
                    }
                }
                else {
                    # New-GraphBatchRequest uses <placeholder> instead of {id}
                    $graphUri = $graphUri -replace '{id}', '<placeholder>'

                    $uri = New-FinalUri -RelativeUri $graphUri -Select (Get-ObjectProperty $item 'Select') -QueryParameters (Get-ObjectProperty $item 'QueryParameters')
                    # New-GraphBatchRequest needs to have uri where < and > aren't URL encoded
                    $uri = $uri -replace "%3C", "<" -replace "%3E", ">"

                    $parents | % {
                        if ($item.Path -match "\.json$") {
                            $outputFileName = Join-Path -Path $parentPath -ChildPath $item.Path
                        } else {
                            $outputFileName = Join-Path -Path $parentPath -ChildPath $_
                            $outputFileName = Join-Path -Path $outputFileName -ChildPath $item.Path
                        }
                        # batch request id cannot contain '\' character
                        $id = $outputFileName -replace '\\', '/'

                        # to avoid duplicated ids in batch request if there are multiple $ExportSchema items with the same path ('Groups' in this case)
                        $id = _randomizeRequestId $id

                        Write-Verbose "Adding request '$uri' with id '$id' to the batch"

                        $request = New-GraphBatchRequest -Url $uri -Id $id -placeholder $_ -header @{ ConsistencyLevel = 'eventual' }

                        if ($apiVersion -eq 'beta') {
                            $batchRequestBetaApi.Add($request)
                        }
                        else {
                            $batchRequestStableApi.Add($request)
                        }
                    }
                }
            }
        }
    }

    # get the results
    if ($batchRequestStableApi) {
        Write-Verbose "Processing $($batchRequestStableApi.count) v1.0 API requests"
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestStableApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }

    if ($batchRequestBetaApi) {
        Write-Verbose "Processing $($batchRequestBetaApi.count) beta API requests"
        $batchResults = Invoke-GraphBatchRequest -batchRequest $batchRequestBetaApi -ErrorAction Continue
        if ($batchResults) {
            $results.AddRange($batchResults)
        }
    }
    #endregion get children of children results

    #region output results
    foreach ($item in $results) {
        if (!(Get-ObjectProperty $item 'Id')){
            <#
            In some special cases it can happen that 'id' property is missing like:
 
            isEnabled : True
            notifyReviewers : True
            remindersEnabled : False
            requestDurationInDays : 14
            version : 0
            reviewers : {@{query=/v1.0/groups/b3dbfaaa-4447-4ebe-8d28-c885c851828b/transitiveMembers/microsoft.graph.user; queryType=MicrosoftGraph; queryRoot=}, @{query=/beta/roleManagement/directory/roleAssignments?$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'; queryType=MicrosoftGraph; queryRoot=}}
            RequestId : C:/temp/bkp3/Policies/AdminConsentRequestPolicy
 
            tenantId : 6abd85ef-c27c-4e71-b000-4c68074a6f7b
            isServiceProvider : True
            isInMultiTenantOrganization : False
            inboundTrust :
            b2bCollaborationOutbound :
            b2bCollaborationInbound :
            b2bDirectConnectOutbound :
            b2bDirectConnectInbound :
            tenantRestrictions :
            automaticUserConsentSettings : @{inboundAllowed=; outboundAllowed=}
            RequestId : C:/temp/bkp3/Policies/CrossTenantAccessPolicy/Partners
            #>


            $itemId = ($item.RequestId -split "/")[-1]
            # remove the random number added to avoid duplicated ids in batch requests
            $itemId = _normalizeRequestId $itemId

            Write-Verbose ($item | convertto-json)
            Write-Warning "Result without 'id' property, using '$itemId' instead (RequestId '$($item.RequestId)')!"
        } else {
            $itemId = $item.id
        }

        if (!$item.RequestId) {
            $item
            throw "Item without RequestId. Shouldn't happen!"
        }

        $outputFileName = $item.RequestId -replace "/", "\"
        # remove the random number added to avoid duplicated ids in batch requests
        $outputFileName = _normalizeRequestId $outputFileName

        if ($outputFileName -notmatch "\.json$") {
            $outputFileName = Join-Path (Join-Path -Path $outputFileName -ChildPath $itemId) -ChildPath "$itemId.json"
        }

        $item | select * -ExcludeProperty RequestId | ConvertTo-Json -depth 100 | Out-File (New-Item -Path $outputFileName -Force)
    }
    #endregion output results
}