modules/Azure/Discovery/Public/Get-CIEMAzureArmHierarchy.ps1

function Get-CIEMAzureArmHierarchy {
    <#
    .SYNOPSIS
        Returns the full ARM infrastructure hierarchy as an ordered node list.
    .DESCRIPTION
        Derives the Tenant -> Subscription -> ResourceGroup -> Resource tree
        on-the-fly from azure_arm_resources rows. No new DB rows are written.
        Each node is a [PSCustomObject] with properties:
          NodeId, NodeType, Depth, ParentNodeId, Relationship, Label, Resource.
        Leaf nodes (NodeType = 'Resource') always have .Resource populated with
        the full [CIEMAzureArmResource] object. Non-leaf nodes have .Resource = $null.
        Throws a terminating error if no ARM resources exist in the database
        (typically means discovery has not been run yet).
    .PARAMETER SubscriptionId
        When specified, scopes the tree to a single subscription. Only resources
        belonging to that subscription are included.
    .EXAMPLE
        Get-CIEMAzureArmHierarchy
    .EXAMPLE
        Get-CIEMAzureArmHierarchy -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter()]
        [string]$SubscriptionId
    )

    $getParams = @{}
    if ($PSBoundParameters.ContainsKey('SubscriptionId')) {
        $getParams['SubscriptionId'] = $SubscriptionId
    }

    Write-CIEMLog -Message "HIERARCHY: querying ARM resources (SubscriptionId=$(if ($SubscriptionId) { $SubscriptionId } else { 'all' }), DB=$script:DatabasePath)" -Severity INFO -Component 'Discovery'

    $allResources = @(Get-CIEMAzureArmResource @getParams)
    Write-CIEMLog -Message "HIERARCHY: total ARM resources from DB: $($allResources.Count)" -Severity INFO -Component 'Discovery'

    # Filter to resources that can be placed in the ARM hierarchy (must have SubscriptionId).
    # Tenant-scoped resources like built-in role definitions have no subscription and can't
    # form a meaningful tree — they're still useful for CIEM checks but not for visualization.
    $resources = @($allResources | Where-Object { $_.SubscriptionId })
    Write-CIEMLog -Message "HIERARCHY: after SubscriptionId filter: $($resources.Count) (excluded $($allResources.Count - $resources.Count) tenant-scoped)" -Severity INFO -Component 'Discovery'

    if (-not $resources -or $resources.Count -eq 0) {
        throw "No ARM resources found in the database. Run Azure discovery first."
    }

    $nodes = InvokeCIEMArmHierarchyBuild -Resources $resources
    Write-CIEMLog -Message "HIERARCHY: built $($nodes.Count) tree nodes" -Severity INFO -Component 'Discovery'
    $nodes
}