src/ingest/Import-Governance.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Native governance collector — populate collect.json's `governance` object
    directly from Azure Resource Graph + the ambient ARM token, with NO
    third-party dependency on the Azure Governance Visualizer.

.DESCRIPTION
    This is the default governance ingestor (AB#5041). It replaces the
    `Import-AzGovViz` dependency for everything the assessment rules and the ALZ
    benchmark actually query:

        governance.managementGroups[] <- ARG resourcecontainers
        governance.policyAssignments[] <- ARG policyresources
        governance.roleAssignments[] <- ARG authorizationresources
        governance.budgets[] <- ARM REST (Microsoft.Consumption)
        governance.resourceLocks[] <- ARM REST (Microsoft.Authorization/locks)
        governance.pimEligibility[] <- not collected here (see NOTES)
        governance.classicAdministrators[] <- not collected here (see NOTES)

    Why native instead of AzGovViz: the visualizer is heavy third-party code that
    is not StrictMode-safe and hard-fails on retired ARM APIs (e.g.
    classicAdministrators -> 404 InvalidResourceType), which took the whole Scout
    run down with it. Every governance signal the rules read is a first-class ARG
    table (policyresources / authorizationresources / resourcecontainers) or a
    simple ambient-token ARM REST call — no cloned repo, no AzAPICall install
    prompt, no interactive prompts, fully unattended. AzGovViz remains available
    as an opt-in ingestor (`Import-AzGovViz`) for anyone who wants the full
    visualizer output, but nothing depends on it by default.

    The object is attached with the SAME `Add-Member -NotePropertyName governance`
    shape the AzGovViz ingestor used, so the rule engine, Compare-Benchmark, and
    the report renderers see an identical contract.

    Read-only throughout: Reader at the management-group root is sufficient
    (policy/role/MG reads via ARG; budgets/locks via ARM GET).

.NOTES
    Tracks ADO Story AB#5041 (supersedes the AzGovViz hard dependency; Story AB#5038).

    Two datasets are deliberately NOT collected here:
      * classicAdministrators - the classic administrators ARM API is retired
        (returns 404 InvalidResourceType) and classic co-admins are being removed
        platform-wide, so an empty set reflects reality. CAF-IDN-03 asserts
        `notExists`, i.e. an empty set is the compliant (Pass) outcome.
      * pimEligibility - PIM eligible-assignment enumeration needs an Entra ID P2
        license plus the Graph `PrivilegedAccess.Read.AzureResources` application
        permission. Collecting it is out of the Reader-at-MG-root baseline this
        collector targets; left empty (unchanged from the prior AzGovViz run,
        which was invoked with -NoPIMEligibility for the same reason).
#>

function Import-Governance {
    [CmdletBinding()]
    param($Collect, [string] $ManagementGroupId)

    Import-Module Az.ResourceGraph -ErrorAction Stop
    if (-not (Get-Command Invoke-AzRestMethod -ErrorAction SilentlyContinue)) {
        Import-Module Az.Accounts -ErrorAction Stop
    }

    # ---- ARG helper: paged, scoped to the management group when one is supplied ----
    # Mirrors Invoke-Collect's paging: Search-AzGraph rejects -Skip 0
    # (ValidateRange minimum is 1), so omit it on the first page.
    function Invoke-GovArg([string] $Query) {
        $rows = @(); $skip = 0
        do {
            $params = @{ Query = $Query; First = 1000; ErrorAction = 'Stop' }
            if ($skip -gt 0) { $params.Skip = $skip }
            if ($ManagementGroupId) { $params.ManagementGroup = $ManagementGroupId }
            $batch = @(Search-AzGraph @params)
            $rows += $batch; $skip += 1000
        } while ($batch.Count -eq 1000)
        return , $rows
    }

    # 1) management groups (Compare-Benchmark matches archetype names against .name)
    $mgs = @()
    try {
        $mgs = Invoke-GovArg @'
resourcecontainers
| where type =~ "microsoft.management/managementgroups"
| project name, id, displayName = tostring(properties.displayName),
          parent = tostring(properties.details.parent.name)
'@

    }
    catch { Write-Warning "Import-Governance: management-group query failed: $($_.Exception.Message)" }

    # 2) policy assignments - keep the nested `properties` object so rule JSONPaths
    # (@.properties.enforcementMode / .parameters / .displayName) resolve unchanged.
    $policy = @()
    try {
        $policy = Invoke-GovArg @'
policyresources
| where type =~ "microsoft.authorization/policyassignments"
| project name, id, type, properties
'@

    }
    catch { Write-Warning "Import-Governance: policy-assignment query failed: $($_.Exception.Message)" }

    # 3) role assignments - properties.principalType drives CAF-IDN-01/05.
    $roles = @()
    try {
        $roles = Invoke-GovArg @'
authorizationresources
| where type =~ "microsoft.authorization/roleassignments"
| project name, id, type, properties
'@

    }
    catch { Write-Warning "Import-Governance: role-assignment query failed: $($_.Exception.Message)" }

    # 4/5) budgets + resource locks - neither is indexed by Resource Graph, so pull
    # them per subscription via the ambient ARM token (Invoke-AzRestMethod uses
    # the current Az context; works headless under an SPN login). Read-only GET.
    $budgets = @(); $locks = @()
    $subIds = @($Collect.subscriptions | ForEach-Object {
            if ($_ -and $_.PSObject.Properties['id']) { $_.id }
        } | Where-Object { $_ })
    foreach ($sub in $subIds) {
        try {
            $resp = Invoke-AzRestMethod -Method GET -Path "/subscriptions/$sub/providers/Microsoft.Consumption/budgets?api-version=2023-11-01"
            if ($resp.StatusCode -eq 200) {
                $val = ($resp.Content | ConvertFrom-Json -Depth 100).value
                if ($val) { $budgets += $val }
            }
        }
        catch { Write-Warning "Import-Governance: budgets read failed for $sub`: $($_.Exception.Message)" }

        try {
            $resp = Invoke-AzRestMethod -Method GET -Path "/subscriptions/$sub/providers/Microsoft.Authorization/locks?api-version=2020-05-01"
            if ($resp.StatusCode -eq 200) {
                $val = ($resp.Content | ConvertFrom-Json -Depth 100).value
                if ($val) { $locks += $val }
            }
        }
        catch { Write-Warning "Import-Governance: resource-lock read failed for $sub`: $($_.Exception.Message)" }
    }

    # Normalize every dataset to a clean array of real objects. The paged ARG
    # helper's `return , $rows` idiom preserves populated arrays intact, but for a
    # query that yields ZERO rows it leaks a single-element wrapper whose element is
    # an empty array — which would otherwise report as Count=1 and trip StrictMode
    # member access downstream (and, worse, make Compare-Benchmark's governance
    # guard think management-group data exists when it does not). Filtering to
    # truthy elements collapses that wrapper back to a genuine empty set while
    # leaving real policy/role/MG objects untouched.
    $gov = [pscustomobject]@{
        managementGroups      = @($mgs     | Where-Object { $_ })
        policyAssignments     = @($policy  | Where-Object { $_ })
        roleAssignments       = @($roles   | Where-Object { $_ })
        budgets               = @($budgets | Where-Object { $_ })
        resourceLocks         = @($locks   | Where-Object { $_ })
        pimEligibility        = @()   # Entra P2 + Graph app perms required - see NOTES
        classicAdministrators = @()   # retired ARM API - see NOTES
    }
    $Collect | Add-Member -NotePropertyName governance -NotePropertyValue $gov -Force

    Write-Verbose ("Import-Governance: MGs={0} policyAssignments={1} roleAssignments={2} budgets={3} locks={4}" -f `
            @($mgs).Count, @($policy).Count, @($roles).Count, @($budgets).Count, @($locks).Count)
    return $Collect
}