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 |