scripts/Generate-PermissionsIndex.ps1

#requires -Version 7.0
<#
.SYNOPSIS
    Manifest-driven generator for the PERMISSIONS.md per-tool index.

.DESCRIPTION
    Reads tools/tool-manifest.json (single source of truth) and rewrites the
    INDEX section of PERMISSIONS.md (between the BEGIN INDEX / END INDEX
    markers) so that every enabled tool appears with a link to its detail
    page under docs/reference/permissions/<tool>.md.

    Also verifies that a docs/reference/permissions/<tool>.md file exists for
    every enabled tool. In WRITE mode (default) any missing page is
    auto-created as a TODO stub so reviewers can fill in the real RBAC /
    Graph / GitHub scopes without blocking the manifest bump. In -CheckOnly
    mode missing pages still fail (exit 1) so CI continues to enforce that
    every shipped tool has a documented permission page on main.

    The generator is idempotent. Running it twice on a clean tree produces
    no diff. CI uses -CheckOnly mode to fail when the committed index is
    stale or when a per-tool page is missing.

.PARAMETER ManifestPath
    Path to tools/tool-manifest.json. Defaults to the repo-relative location.

.PARAMETER PermissionsPath
    Path to the root PERMISSIONS.md file. Defaults to the repo-relative location.

.PARAMETER PagesDir
    Directory containing per-tool permission detail pages.
    Defaults to docs/reference/permissions.

.PARAMETER CheckOnly
    Do not write files. Compare the generated content with what is on disk
    and verify per-tool pages exist. Exits 0 when in sync, exits 1 when
    stale or pages are missing.

.EXAMPLE
    pwsh -File scripts/Generate-PermissionsIndex.ps1
    Regenerate the INDEX section of PERMISSIONS.md from the manifest.

.EXAMPLE
    pwsh -File scripts/Generate-PermissionsIndex.ps1 -CheckOnly
    Used by CI: fail if the committed index is stale or pages are missing.
#>

[CmdletBinding()]
param(
    [string]$ManifestPath,
    [string]$PermissionsPath,
    [string]$PagesDir,
    [switch]$CheckOnly
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-RepoRoot {
    $candidate = Split-Path -Parent $PSScriptRoot
    if (-not $candidate) { $candidate = (Get-Location).Path }
    return $candidate
}

$repoRoot = Get-RepoRoot
if (-not $ManifestPath)    { $ManifestPath    = Join-Path $repoRoot 'tools/tool-manifest.json' }
if (-not $PermissionsPath) { $PermissionsPath = Join-Path $repoRoot 'PERMISSIONS.md' }
if (-not $PagesDir)        { $PagesDir        = Join-Path $repoRoot 'docs/reference/permissions' }

if (-not (Test-Path -LiteralPath $ManifestPath))    { throw "Manifest not found at: $ManifestPath" }
if (-not (Test-Path -LiteralPath $PermissionsPath)) { throw "PERMISSIONS.md not found at: $PermissionsPath" }
if (-not (Test-Path -LiteralPath $PagesDir))        { throw "Pages dir not found: $PagesDir" }

$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
if (-not $manifest.tools) { throw "Manifest at $ManifestPath has no 'tools' array." }

# Friendly category buckets for the index. Maps provider -> heading.
$providerHeadings = [ordered]@{
    'azure'        = 'Azure (Reader baseline)'
    'microsoft365' = 'Microsoft 365 / Entra (Microsoft Graph)'
    'graph'        = 'Identity correlation (optional Microsoft Graph)'
    'github'       = 'GitHub'
    'ado'          = 'Azure DevOps'
    'cli'          = 'Local CLI / IaC (no cloud permissions)'
    'easm'         = 'External Attack Surface (no cloud permissions)'
}

function Get-ScopeBadge {
    param($scope)
    switch ($scope) {
        'subscription'    { 'Subscription' }
        'managementGroup' { 'Management Group' }
        'tenant'          { 'Tenant' }
        'workspace'       { 'Workspace' }
        'repository'      { 'Repository' }
        'ado'             { 'ADO Org' }
        default           { [string]$scope }
    }
}

function New-IndexSection {
    param($tools, [string]$pagesDirRelative)

    $enabledTools = $tools | Where-Object { $_.enabled } | Sort-Object name

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('<!-- BEGIN INDEX (generated by scripts/Generate-PermissionsIndex.ps1; do not edit by hand) -->')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('Per-tool permission detail lives under [`docs/consumer/permissions/`](docs/consumer/permissions/README.md). The index below is regenerated from `tools/tool-manifest.json` by `scripts/Generate-PermissionsIndex.ps1` and enforced by the `permissions-pages-fresh` CI check.')
    [void]$sb.AppendLine()

    foreach ($provider in $providerHeadings.Keys) {
        $bucket = @($enabledTools | Where-Object { $_.provider -eq $provider })
        if ($bucket.Count -eq 0) { continue }

        [void]$sb.AppendLine("### $($providerHeadings[$provider])")
        [void]$sb.AppendLine()
        [void]$sb.AppendLine('| Tool | Scope | Detail |')
        [void]$sb.AppendLine('|---|---|---|')
        foreach ($t in $bucket) {
            $page = "$pagesDirRelative/$($t.name).md"
            $row = '| **{0}** | {1} | [`{2}.md`]({3}) |' -f $t.displayName, (Get-ScopeBadge $t.scope), $t.name, $page
            [void]$sb.AppendLine($row)
        }
        [void]$sb.AppendLine()
    }

    [void]$sb.AppendLine('### Cross-cutting topics')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('| Topic | Detail |')
    [void]$sb.AppendLine('|---|---|')
    [void]$sb.AppendLine("| Cross-tool matrix, tiers, least-privilege | [``$pagesDirRelative/_summary.md``]($pagesDirRelative/_summary.md) |")
    [void]$sb.AppendLine("| Continuous Control Function App (#165) | [``$pagesDirRelative/_continuous-control.md``]($pagesDirRelative/_continuous-control.md) |")
    [void]$sb.AppendLine("| Multi-tenant fan-out (#163) | [``$pagesDirRelative/_multi-tenant.md``]($pagesDirRelative/_multi-tenant.md) |")
    [void]$sb.AppendLine("| Management-group recursion | [``$pagesDirRelative/_management-group.md``]($pagesDirRelative/_management-group.md) |")
    [void]$sb.AppendLine("| Auth troubleshooting | [``$pagesDirRelative/_troubleshooting.md``]($pagesDirRelative/_troubleshooting.md) |")
    [void]$sb.AppendLine()

    [void]$sb.Append('<!-- END INDEX -->')
    return $sb.ToString()
}

function Test-PerToolPages {
    param($tools, [string]$pagesDir)

    $enabled = $tools | Where-Object { $_.enabled } | Sort-Object name
    $missing = @()
    foreach ($t in $enabled) {
        $path = Join-Path $pagesDir "$($t.name).md"
        if (-not (Test-Path -LiteralPath $path)) {
            $missing += $t.name
        }
    }
    return ,$missing
}

function New-PermissionsStub {
    param(
        [Parameter(Mandatory)]$Tool,
        [Parameter(Mandatory)][string]$PagesDirRelative
    )

    $displayName = if ($Tool.PSObject.Properties.Name -contains 'displayName' -and $Tool.displayName) {
        [string]$Tool.displayName
    } else {
        [string]$Tool.name
    }
    $scope    = if ($Tool.PSObject.Properties.Name -contains 'scope')    { [string]$Tool.scope }    else { 'unknown' }
    $provider = if ($Tool.PSObject.Properties.Name -contains 'provider') { [string]$Tool.provider } else { 'unknown' }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("# $displayName - Required Permissions")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Source:** ``tools/tool-manifest.json`` (auto-generated stub from ``scripts/Generate-PermissionsIndex.ps1``).")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Name:** ``$($Tool.name)``")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Display name:** $displayName")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Scope:** $scope | **Provider:** $provider")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('## Required permissions')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("<!-- TODO: document required RBAC roles, scopes, or API permissions for $($Tool.name). -->")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('| Capability | Scope | Role / scope | Why |')
    [void]$sb.AppendLine('|---|---|---|---|')
    [void]$sb.AppendLine('| TODO | TODO | TODO | TODO |')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('## See also')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('- Root [`PERMISSIONS.md`](../../../PERMISSIONS.md) for the cross-tool index.')
    [void]$sb.AppendLine('- [`docs/reference/tool-catalog.md`](../tool-catalog.md) for the manifest projection.')
    [void]$sb.AppendLine("- [``$PagesDirRelative/_summary.md``](_summary.md) for the cross-tool matrix and tier breakdown.")
    [void]$sb.AppendLine()
    return $sb.ToString()
}

function Convert-ToLfText {
    param([string]$Text)
    return ($Text -replace "`r`n", "`n")
}

$beginMarker = '<!-- BEGIN INDEX (generated by scripts/Generate-PermissionsIndex.ps1; do not edit by hand) -->'
$endMarker   = '<!-- END INDEX -->'

$pagesDirRelative = 'docs/consumer/permissions'
$newSection       = New-IndexSection $manifest.tools $pagesDirRelative
$current          = Convert-ToLfText (Get-Content -LiteralPath $PermissionsPath -Raw)

$beginIdx = $current.IndexOf($beginMarker)
$endIdx   = $current.IndexOf($endMarker)
if ($beginIdx -lt 0 -or $endIdx -lt 0 -or $endIdx -lt $beginIdx) {
    throw "PERMISSIONS.md is missing the BEGIN INDEX / END INDEX markers. Add them around the index block before running the generator."
}
$endIdx = $endIdx + $endMarker.Length

$updated = $current.Substring(0, $beginIdx) + $newSection + $current.Substring($endIdx)
$updated = Convert-ToLfText $updated

$missing = Test-PerToolPages $manifest.tools $PagesDir

if ($CheckOnly) {
    $errors = @()
    if ($current -ne $updated) {
        $errors += "PERMISSIONS.md INDEX section is stale relative to the manifest."
    }
    if ($missing.Count -gt 0) {
        $errors += "Missing per-tool permission pages for enabled tools: $($missing -join ', '). Add a page under docs/consumer/permissions/<name>.md for each."
    }
    if ($errors.Count -gt 0) {
        foreach ($e in $errors) { Write-Host "[stale] $e" }
        Write-Host ''
        Write-Host 'Run: pwsh -File scripts/Generate-PermissionsIndex.ps1'
        exit 1
    }
    Write-Host "[ok] PERMISSIONS.md index in sync; all enabled tools have a per-tool page."
    exit 0
}

if ($missing.Count -gt 0) {
    # Write stubs to $PagesDir AND mirror to a sibling consumer/permissions dir
    # ONLY when $PagesDir resolves to the default reference/permissions location.
    # The mirror is a workaround for pre-existing repo drift between
    # docs/consumer/permissions/ and docs/reference/permissions/ (#257 / #418):
    # the rendered index still links to the consumer dir, but the existence
    # check defaults to the reference dir. Constraining the mirror to the
    # default $PagesDir prevents test fixtures with custom $PagesDir from
    # leaking stubs into the live repo dirs.
    $defaultPagesDir = Join-Path $repoRoot 'docs/reference/permissions'
    $defaultPagesDirAbs = if (Test-Path -LiteralPath $defaultPagesDir) {
        (Resolve-Path -LiteralPath $defaultPagesDir).Path
    } else { $null }
    $primaryDirAbsolute = (Resolve-Path -LiteralPath $PagesDir).Path

    $targetDirs = @($PagesDir)
    if ($defaultPagesDirAbs -and ($primaryDirAbsolute -eq $defaultPagesDirAbs)) {
        $linkedPagesDir = Join-Path $repoRoot $pagesDirRelative
        if (Test-Path -LiteralPath $linkedPagesDir) {
            $linkedDirAbsolute = (Resolve-Path -LiteralPath $linkedPagesDir).Path
            if ($linkedDirAbsolute -ne $primaryDirAbsolute) {
                $targetDirs += $linkedPagesDir
            }
        }
    }

    foreach ($name in $missing) {
        $tool = $manifest.tools | Where-Object { $_.name -eq $name } | Select-Object -First 1
        if (-not $tool) { continue }
        $stubContent = New-PermissionsStub -Tool $tool -PagesDirRelative $pagesDirRelative
        $stubContent = Convert-ToLfText $stubContent

        foreach ($dir in $targetDirs) {
            $stubPath = Join-Path $dir "$name.md"
            if (Test-Path -LiteralPath $stubPath) { continue }
            [System.IO.File]::WriteAllText($stubPath, $stubContent, [System.Text.UTF8Encoding]::new($false))
            $relativeStub = (Resolve-Path -LiteralPath $stubPath -Relative).Replace('\','/').TrimStart('./')
            Write-Host "Created stub: $relativeStub (review and fill in TODOs)"
        }
    }

    # Re-evaluate so the index section we write below references real, on-disk files.
    $missing = Test-PerToolPages $manifest.tools $PagesDir
    if ($missing.Count -gt 0) {
        throw "Failed to create stub permission pages for: $($missing -join ', ')."
    }
}

[System.IO.File]::WriteAllText($PermissionsPath, $updated, [System.Text.UTF8Encoding]::new($false))
Write-Host "[wrote] $PermissionsPath"
exit 0