From 713a77b8b88ecc2e901e29a425ea474816f09ae9 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 10 Jul 2026 13:57:31 +0200 Subject: [PATCH 1/3] feat: add read-only trust status collector and HTML matrix report Introduce the reporting/audit building blocks for the trust matrix (issue #8): - Get-SPSTrustStatus (public): read-only collector that walks Farms x Trusts x Services x RemoteFarms and resolves each trust dimension (ROOT, STS, Published, Topology permission, SA permission, Proxy) via the existing Get-SPS* getters. Performs no changes; 'Content' services are marked N/A for STS/Published/SA-permission/Proxy; getter failures are captured as 'Error' + a per-row Note without aborting the audit. Returns an ordered hashtable ready for ConvertTo-Json / Export-SPSTrustReport. - Export-SPSTrustReport (public): renders a status object (-Status) or a results JSON file (-InputFile) into a self-contained, offline HTML report with summary cards and an interactive (search + click-to-sort) trust matrix using status pills. - Backup-SPSJsonFile (public): ported archive-before-overwrite helper. - Private HTML helpers: ConvertTo-SPSHtmlEncoded, Get-SPSReportHtmlHead (with pill styles), Get-SPSReportCardHtml, Get-SPSReportHtmlScript (DOM-based search/sort). - Manifest bumped to 2.1.0; the 3 new public functions exported. - Tests: tests/Modules/SPSTrust.Report.Tests.ps1 covers the collector (row shape, N/A handling, error capture, no state-changing calls), the renderer (HTML shape, pills, dir creation, JSON round-trip, HTML encoding) and Backup-SPSJsonFile. 81 tests pass; PSScriptAnalyzer clean. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/ConvertTo-SPSHtmlEncoded.ps1 | 50 ++++ .../Private/Get-SPSReportCardHtml.ps1 | 45 ++++ .../Private/Get-SPSReportHtmlHead.ps1 | 58 +++++ .../Private/Get-SPSReportHtmlScript.ps1 | 71 +++++ .../Public/Backup-SPSJsonFile.ps1 | 68 +++++ .../Public/Export-SPSTrustReport.ps1 | 171 ++++++++++++ .../Public/Get-SPSTrustStatus.ps1 | 224 ++++++++++++++++ .../SPSTrust.Common/SPSTrust.Common.psd1 | 5 +- tests/Modules/SPSTrust.Common.Tests.ps1 | 6 + tests/Modules/SPSTrust.Report.Tests.ps1 | 244 ++++++++++++++++++ 10 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 src/Modules/SPSTrust.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 create mode 100644 src/Modules/SPSTrust.Common/Private/Get-SPSReportCardHtml.ps1 create mode 100644 src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlHead.ps1 create mode 100644 src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlScript.ps1 create mode 100644 src/Modules/SPSTrust.Common/Public/Backup-SPSJsonFile.ps1 create mode 100644 src/Modules/SPSTrust.Common/Public/Export-SPSTrustReport.ps1 create mode 100644 src/Modules/SPSTrust.Common/Public/Get-SPSTrustStatus.ps1 create mode 100644 tests/Modules/SPSTrust.Report.Tests.ps1 diff --git a/src/Modules/SPSTrust.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 b/src/Modules/SPSTrust.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 new file mode 100644 index 0000000..2c64eb1 --- /dev/null +++ b/src/Modules/SPSTrust.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 @@ -0,0 +1,50 @@ +function ConvertTo-SPSHtmlEncoded { + <# + .SYNOPSIS + HTML-encodes a string for safe insertion into generated HTML reports. + + .DESCRIPTION + Replaces the five characters that are significant in HTML markup + (& < > " ') with their entity equivalents. Used by + Export-SPSTrustReport to neutralize values (database names, + site/web IDs, logins, messages) before baking them into the report. + + Returns an empty string for null or empty input. + + .PARAMETER Value + The string to encode. + + .EXAMPLE + ConvertTo-SPSHtmlEncoded -Value 'SERVICES & "co"' + # SERVICES <prod> & "co" + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [AllowNull()] + [System.String] + $Value + ) + + process { + if ([string]::IsNullOrEmpty($Value)) { + return '' + } + + $sb = [System.Text.StringBuilder]::new() + foreach ($char in $Value.ToCharArray()) { + switch ($char) { + '&' { [void]$sb.Append('&') } + '<' { [void]$sb.Append('<') } + '>' { [void]$sb.Append('>') } + '"' { [void]$sb.Append('"') } + "'" { [void]$sb.Append(''') } + default { [void]$sb.Append($char) } + } + } + return $sb.ToString() + } +} diff --git a/src/Modules/SPSTrust.Common/Private/Get-SPSReportCardHtml.ps1 b/src/Modules/SPSTrust.Common/Private/Get-SPSReportCardHtml.ps1 new file mode 100644 index 0000000..0a8d9d4 --- /dev/null +++ b/src/Modules/SPSTrust.Common/Private/Get-SPSReportCardHtml.ps1 @@ -0,0 +1,45 @@ +function Get-SPSReportCardHtml { + <# + .SYNOPSIS + Builds the HTML for one summary "card" (a big number plus a label). + + .PARAMETER Value + The value shown as the big number. + + .PARAMETER Label + The label shown under the value. + + .PARAMETER Sub + Optional sub-label shown under the label. + + .PARAMETER Tone + Optional visual tone: '', 'accent', 'clean' (green) or 'alert' (red). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + $Value, + + [Parameter(Mandatory = $true)] + [System.String] + $Label, + + [Parameter()] + [System.String] + $Sub = '', + + [Parameter()] + [ValidateSet('', 'accent', 'clean', 'alert')] + [System.String] + $Tone = '' + ) + + $encValue = ConvertTo-SPSHtmlEncoded -Value ("$Value") + $encLabel = ConvertTo-SPSHtmlEncoded -Value $Label + $encSub = ConvertTo-SPSHtmlEncoded -Value $Sub + $toneClass = if ([string]::IsNullOrEmpty($Tone)) { '' } else { " $Tone" } + $subHtml = if ([string]::IsNullOrEmpty($encSub)) { '' } else { "
$encSub
" } + return "
$encValue
$encLabel
$subHtml
" +} diff --git a/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlHead.ps1 new file mode 100644 index 0000000..eb5525f --- /dev/null +++ b/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -0,0 +1,58 @@ +function Get-SPSReportHtmlHead { + <# + .SYNOPSIS + Returns the document head (with the embedded stylesheet) and the opening body tag. + + .DESCRIPTION + Emits a self-contained block (no CDN, works offline on a SharePoint + server) with the embedded SPSTrust report stylesheet, followed by the opening + tag. The stylesheet includes the status "pill" styles used to render the + trust matrix (present / absent / n-a). + + .PARAMETER Title + Page title used in the element. + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Title + ) + + $css = @' +:root{--brand:#1f6fb2;--brand-dark:#155a91;--ink:#222;--muted:#666;--line:#e3e3e3;--zebra:#f7f9fb;--ok:#2e9b57;--warn:#c19c00;--alert:#c0392b} +*{box-sizing:border-box} +body{font-family:'Aptos','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--ink);margin:0;padding:24px;background:#fff} +h1{color:var(--brand);font-size:22px;margin:0 0 4px} +h2{color:var(--brand);font-size:16px;margin:24px 0 8px;border-bottom:2px solid var(--brand);padding-bottom:4px} +h3{color:var(--brand-dark);font-size:13px;margin:0 0 6px} +.meta{color:var(--muted);font-size:12px;margin-bottom:16px} +.summary{background:#eef5fb;border:1px solid #cfe0ef;border-left:4px solid var(--brand);border-radius:6px;padding:16px;margin-bottom:8px} +.cards{display:flex;flex-wrap:wrap;gap:12px} +.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 16px;min-width:120px} +.card-value{font-size:24px;font-weight:700;color:var(--brand)} +.card-label{font-size:12px;color:var(--muted)} +.card-sub{font-size:11px;color:var(--muted);margin-top:2px} +.card.accent{background:#eef5fb;border-color:#cfe0ef} +.card.clean .card-value{color:var(--ok)} +.card.alert .card-value{color:var(--alert)} +table{border-collapse:collapse;width:100%;font-size:12px} +th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top;word-break:break-word} +th{background:var(--brand);color:#fff;cursor:pointer;user-select:none;position:sticky;top:0} +td.num,th.num{text-align:right} +tbody tr:nth-child(even){background:var(--zebra)} +.controls{display:flex;justify-content:space-between;align-items:center;margin:12px 0;flex-wrap:wrap;gap:8px} +.search{padding:6px 10px;border:1px solid var(--line);border-radius:4px;font-size:13px;width:280px;max-width:100%} +.info{color:var(--muted);font-size:12px} +.pill{display:inline-block;min-width:64px;text-align:center;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;line-height:1.4} +.pill-present{background:#e6f4ec;color:var(--ok);border:1px solid #bfe2cd} +.pill-absent{background:#fbeceb;color:var(--alert);border:1px solid #f0c9c5} +.pill-na{background:#f0f0f0;color:var(--muted);border:1px solid #e0e0e0} +.empty{color:var(--ok);font-size:13px;margin:8px 0 20px;font-weight:600} +.footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} +'@ + + return "<!DOCTYPE html><html lang=`"en`"><head><meta charset=`"utf-8`"><meta name=`"viewport`" content=`"width=device-width, initial-scale=1`"><title>$Title" +} diff --git a/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlScript.ps1 b/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlScript.ps1 new file mode 100644 index 0000000..af72fa1 --- /dev/null +++ b/src/Modules/SPSTrust.Common/Private/Get-SPSReportHtmlScript.ps1 @@ -0,0 +1,71 @@ +function Get-SPSReportHtmlScript { + <# + .SYNOPSIS + Returns the vanilla-JavaScript block that makes the trust matrix table interactive. + + .DESCRIPTION + Operates on the static rendered server-side by + Export-SPSTrustReport (so the status "pills" are real HTML). It wires: + + - a search box (id="matrix-search") that shows/hides rows by matching text in + any cell (case-insensitive), and + - clickable column headers that sort the visible rows. Sorting uses each cell's + textContent, which for status columns is the pill label (Present/Absent/N/A). + + The row count indicator (id="matrix-info") is updated on every filter. No + external dependency (works offline on a SharePoint server). + #> + [CmdletBinding()] + [OutputType([System.String])] + param () + + $js = @' +(function(){ + var table = document.getElementById('trust-matrix'); + if (!table) { return; } + var tbody = table.tBodies[0]; + var search = document.getElementById('matrix-search'); + var info = document.getElementById('matrix-info'); + var allRows = Array.prototype.slice.call(tbody.rows); + var sortCol = -1, sortDir = 1; + + function cellText(row, i){ + var c = row.cells[i]; + return c ? (c.textContent || '').trim().toLowerCase() : ''; + } + + function applyFilter(){ + var q = (search && search.value || '').trim().toLowerCase(); + var shown = 0; + allRows.forEach(function(r){ + var match = !q || (r.textContent || '').toLowerCase().indexOf(q) !== -1; + r.style.display = match ? '' : 'none'; + if (match) { shown++; } + }); + if (info) { info.textContent = shown + ' / ' + allRows.length + ' rows'; } + } + + function applySort(col){ + if (sortCol === col) { sortDir = -sortDir; } else { sortCol = col; sortDir = 1; } + var rows = allRows.slice(); + rows.sort(function(a,b){ + var x = cellText(a, col), y = cellText(b, col); + if (x < y) { return -1 * sortDir; } + if (x > y) { return 1 * sortDir; } + return 0; + }); + rows.forEach(function(r){ tbody.appendChild(r); }); + } + + var headers = table.tHead ? table.tHead.rows[0].cells : []; + Array.prototype.forEach.call(headers, function(th, i){ + th.addEventListener('click', function(){ applySort(i); }); + }); + + if (search) { search.addEventListener('input', applyFilter); } + applyFilter(); +})(); +'@ + + return "" +} diff --git a/src/Modules/SPSTrust.Common/Public/Backup-SPSJsonFile.ps1 b/src/Modules/SPSTrust.Common/Public/Backup-SPSJsonFile.ps1 new file mode 100644 index 0000000..4dd6a7d --- /dev/null +++ b/src/Modules/SPSTrust.Common/Public/Backup-SPSJsonFile.ps1 @@ -0,0 +1,68 @@ +function Backup-SPSJsonFile { + <# + .SYNOPSIS + Archives an existing results JSON snapshot into a history folder before it is overwritten. + + .DESCRIPTION + Backup-SPSJsonFile copies the file at -Path into -HistoryFolder, appending a + timestamp to the file name (e.g. CONTOSO-PROD-SPSE-20260709-1615.json). The + original file is left untouched so the caller can overwrite it with a fresh + audit afterwards. + + The function does not perform retention/rotation itself: call + Clear-SPSLogFolder -Extension '*.json' on the history folder to prune old + snapshots, reusing the toolkit's single rotation implementation. + + Returns the full path of the backup that was created, or $null when -Path does + not exist (first run, nothing to archive). + + .PARAMETER Path + The results JSON file about to be overwritten. + + .PARAMETER HistoryFolder + Destination folder for the timestamped copy. Created if missing. + + .PARAMETER TimeStamp + Timestamp string injected into the backup file name. Defaults to the current + date/time as yyyyMMdd-HHmm. Exposed mainly for deterministic testing. + + .EXAMPLE + $previous = Backup-SPSJsonFile -Path $pathJsonFile -HistoryFolder $historyFolder + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Path, + + [Parameter(Mandatory = $true)] + [System.String] + $HistoryFolder, + + [Parameter()] + [System.String] + $TimeStamp = (Get-Date -Format yyyyMMdd-HHmm) + ) + + if (-not (Test-Path -Path $Path)) { + Write-Verbose -Message "Backup-SPSJsonFile: no existing file to archive at '$Path'." + return $null + } + + if (-not (Test-Path -Path $HistoryFolder)) { + $null = New-Item -Path $HistoryFolder -ItemType Directory -Force + } + + $leaf = Split-Path -Path $Path -Leaf + $name = [System.IO.Path]::GetFileNameWithoutExtension($leaf) + $extension = [System.IO.Path]::GetExtension($leaf) + $backupName = "$name-$TimeStamp$extension" + $backupPath = Join-Path -Path $HistoryFolder -ChildPath $backupName + + Copy-Item -Path $Path -Destination $backupPath -Force + Write-Verbose -Message "Backup-SPSJsonFile: archived '$Path' to '$backupPath'." + + return $backupPath +} diff --git a/src/Modules/SPSTrust.Common/Public/Export-SPSTrustReport.ps1 b/src/Modules/SPSTrust.Common/Public/Export-SPSTrustReport.ps1 new file mode 100644 index 0000000..84b268f --- /dev/null +++ b/src/Modules/SPSTrust.Common/Public/Export-SPSTrustReport.ps1 @@ -0,0 +1,171 @@ +function Export-SPSTrustReport { + <# + .SYNOPSIS + Renders a trust status object (or results JSON file) into a self-contained HTML report. + + .DESCRIPTION + Export-SPSTrustReport takes the object produced by Get-SPSTrustStatus - either + passed directly with -Status, or read from a results JSON file with -InputFile - + and writes a single self-contained HTML file (no external CSS/JS/CDN, so it opens + offline on a SharePoint server). + + The report shows: + - a metadata line (application, environment, domain, generation time, version), + - summary cards (total relationships, and Present / Absent / N/A counts computed + across every status cell), and + - an interactive "trust matrix" table: one row per publishing-farm / consuming-farm + / service, with a status "pill" for each trust dimension (RootTrust, StsTrust, + Published, TopologyPermission, ServiceAppPermission, Proxy). The table supports + a search box and click-to-sort headers via Get-SPSReportHtmlScript. + + Returns the full path of the HTML file that was written. + + .PARAMETER Status + The status object returned by Get-SPSTrustStatus. + + .PARAMETER InputFile + Path to a results JSON file previously produced from a status object. Mutually + exclusive with -Status. + + .PARAMETER OutputPath + Full path of the HTML file to write. + + .EXAMPLE + Export-SPSTrustReport -Status $status -OutputPath 'D:\Tools\Reports\CONTOSO-PROD.html' + + .EXAMPLE + Export-SPSTrustReport -InputFile 'D:\Tools\Results\CONTOSO-PROD.json' -OutputPath 'D:\Tools\Reports\CONTOSO-PROD.html' + #> + [CmdletBinding(DefaultParameterSetName = 'Status')] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ParameterSetName = 'Status')] + [System.Object] + $Status, + + [Parameter(Mandatory = $true, ParameterSetName = 'InputFile')] + [System.String] + $InputFile, + + [Parameter(Mandatory = $true)] + [System.String] + $OutputPath + ) + + if ($PSCmdlet.ParameterSetName -eq 'InputFile') { + if (-not (Test-Path -Path $InputFile)) { + throw "Export-SPSTrustReport: input file '$InputFile' not found." + } + $Status = Get-Content -Path $InputFile -Raw | ConvertFrom-Json + } + + $rows = @($Status.Rows) + + # Status dimensions rendered as pills, in column order. + $dimensions = @( + @{ Field = 'RootTrust'; Label = 'ROOT' } + @{ Field = 'StsTrust'; Label = 'STS' } + @{ Field = 'Published'; Label = 'Published' } + @{ Field = 'TopologyPermission'; Label = 'Topology' } + @{ Field = 'ServiceAppPermission'; Label = 'SA Perm' } + @{ Field = 'Proxy'; Label = 'Proxy' } + ) + + # Compute summary counts across every status cell. + $presentCount = 0 + $absentCount = 0 + $naCount = 0 + $errorCount = 0 + foreach ($row in $rows) { + foreach ($dim in $dimensions) { + switch ("$($row.$($dim.Field))") { + 'Present' { $presentCount++ } + 'Absent' { $absentCount++ } + 'N/A' { $naCount++ } + 'Error' { $errorCount++ } + } + } + } + + # Build a status pill. + function Get-Pill { + param([System.String] $Value) + $safe = ConvertTo-SPSHtmlEncoded -Value $Value + switch ($Value) { + 'Present' { return "$safe" } + 'Absent' { return "$safe" } + 'Error' { return "$safe" } + default { return "$safe" } + } + } + + $sb = [System.Text.StringBuilder]::new() + + [void]$sb.Append((Get-SPSReportHtmlHead -Title 'SPSTrust - Trust Matrix Report')) + + $encApp = ConvertTo-SPSHtmlEncoded -Value "$($Status.Application)" + $encEnv = ConvertTo-SPSHtmlEncoded -Value "$($Status.Environment)" + $encDomain = ConvertTo-SPSHtmlEncoded -Value "$($Status.Domain)" + $encGen = ConvertTo-SPSHtmlEncoded -Value "$($Status.GeneratedAtUtc)" + $encVersion = ConvertTo-SPSHtmlEncoded -Value "$($Status.Version)" + + [void]$sb.Append("

SPSTrust - Trust Matrix Report

") + [void]$sb.Append("
Application: $encApp · Environment: $encEnv · Domain: $encDomain · Generated: $encGen (UTC) · Version: $encVersion
") + + # Summary cards. + [void]$sb.Append("
") + [void]$sb.Append((Get-SPSReportCardHtml -Value $rows.Count -Label 'Relationships' -Sub 'farm / farm / service' -Tone 'accent')) + [void]$sb.Append((Get-SPSReportCardHtml -Value $presentCount -Label 'Present' -Tone 'clean')) + [void]$sb.Append((Get-SPSReportCardHtml -Value $absentCount -Label 'Absent' -Tone $(if ($absentCount -gt 0) { 'alert' } else { '' }))) + [void]$sb.Append((Get-SPSReportCardHtml -Value $naCount -Label 'N/A')) + if ($errorCount -gt 0) { + [void]$sb.Append((Get-SPSReportCardHtml -Value $errorCount -Label 'Errors' -Tone 'alert')) + } + [void]$sb.Append("
") + + # Controls. + [void]$sb.Append("
") + + if ($rows.Count -eq 0) { + [void]$sb.Append("

No trust relationships are declared in the configuration.

") + } + else { + [void]$sb.Append("
") + foreach ($col in @('Publishing Farm', 'Consuming Farm', 'Service')) { + [void]$sb.Append("") + } + foreach ($dim in $dimensions) { + [void]$sb.Append("") + } + [void]$sb.Append("") + + foreach ($row in $rows) { + [void]$sb.Append("") + [void]$sb.Append("") + [void]$sb.Append("") + [void]$sb.Append("") + foreach ($dim in $dimensions) { + [void]$sb.Append("") + } + [void]$sb.Append("") + [void]$sb.Append("") + } + + [void]$sb.Append("
$col$($dim.Label)Notes
$(ConvertTo-SPSHtmlEncoded -Value "$($row.PublishingFarm)")$(ConvertTo-SPSHtmlEncoded -Value "$($row.ConsumingFarm)")$(ConvertTo-SPSHtmlEncoded -Value "$($row.Service)")$(Get-Pill -Value "$($row.$($dim.Field))")$(ConvertTo-SPSHtmlEncoded -Value "$($row.Notes)")
") + } + + [void]$sb.Append("
Generated by SPSTrust · github.com/luigilink/SPSTrust
") + [void]$sb.Append((Get-SPSReportHtmlScript)) + [void]$sb.Append("") + + $outDir = Split-Path -Path $OutputPath -Parent + if ($outDir -and -not (Test-Path -Path $outDir)) { + $null = New-Item -Path $outDir -ItemType Directory -Force + } + + Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8 + Write-Verbose -Message "Export-SPSTrustReport: wrote report to '$OutputPath'." + + return $OutputPath +} diff --git a/src/Modules/SPSTrust.Common/Public/Get-SPSTrustStatus.ps1 b/src/Modules/SPSTrust.Common/Public/Get-SPSTrustStatus.ps1 new file mode 100644 index 0000000..df92347 --- /dev/null +++ b/src/Modules/SPSTrust.Common/Public/Get-SPSTrustStatus.ps1 @@ -0,0 +1,224 @@ +function Get-SPSTrustStatus { + <# + .SYNOPSIS + Collects the current cross-farm trust state as a read-only status object. + + .DESCRIPTION + Get-SPSTrustStatus walks the declared topology (Farms x Trusts x Services x + RemoteFarms) and resolves, for every publishing-farm / consuming-farm / service + combination, the current state of each trust dimension by calling only the + read-only Get-SPS* functions of the module: + + - RootTrust (Get-SPSTrustedRootAuthority) + - StsTrust (Get-SPSTrustedServiceTokenIssuer) + - Published (Get-SPSPublishedServiceApplication) + - TopologyPermission (Get-SPSTopologyServiceAppPermission) + - ServiceAppPermission (Get-SPSPublishedServiceAppPermission) + - Proxy (Get-SPSPublishedServiceAppProxy) + + The function performs **no** changes: it never publishes, grants, revokes, + connects or removes anything. It is safe to run at any time to audit or document + a topology, and is also used by SPSTrust.ps1 to produce the post-run report. + + Each dimension is reported as 'Present', 'Absent', 'N/A' (not applicable, e.g. + STS/publish/proxy for a 'Content' service) or 'Error' (the underlying getter + threw - the message is captured in the row's Notes field). One failing farm does + not abort the whole audit. + + Returns an ordered hashtable suitable for ConvertTo-Json and for + Export-SPSTrustReport. + + .PARAMETER Farms + The farm inventory: an array of objects/hashtables each exposing Name and Server. + + .PARAMETER Trusts + The trust definitions: an array of objects/hashtables each exposing LocalFarm, + RemoteFarms and Services. + + .PARAMETER Domain + DNS suffix appended to each farm Server value to build the target FQDN. + + .PARAMETER InstallAccount + Credential used for the CredSSP remote sessions (the farm service account). + + .PARAMETER Application + Application short name, stored in the status metadata. + + .PARAMETER Environment + Environment name (e.g. PROD), stored in the status metadata. + + .PARAMETER Version + Tool version string, stored in the status metadata. + + .EXAMPLE + $status = Get-SPSTrustStatus -Farms $cfg.Farms -Trusts $cfg.Trusts -Domain $cfg.Domain -InstallAccount $cred + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param + ( + [Parameter(Mandatory = $true)] + [System.Object[]] + $Farms, + + [Parameter(Mandatory = $true)] + [System.Object[]] + $Trusts, + + [Parameter(Mandatory = $true)] + [System.String] + $Domain, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $InstallAccount, + + [Parameter()] + [System.String] + $Application = '', + + [Parameter()] + [System.String] + $Environment = '', + + [Parameter()] + [System.String] + $Version = '' + ) + + # Build a farm-name -> target FQDN lookup from the inventory. + $fqdnByFarm = @{} + foreach ($farm in $Farms) { + $fqdnByFarm[$farm.Name] = "$($farm.Server).$Domain" + } + + $rows = [System.Collections.Generic.List[object]]::new() + + foreach ($trust in $Trusts) { + $publishingFarm = $trust.LocalFarm + $publishingServer = $fqdnByFarm[$publishingFarm] + $services = @($trust.Services) + + foreach ($consumingFarm in $trust.RemoteFarms) { + $consumingServer = $fqdnByFarm[$consumingFarm] + + # Per (publishing, consuming) dimensions: ROOT trust, Topology permission, Farm Id. + $rootTrust = 'Error' + $topologyPermission = 'Error' + $notes = [System.Collections.Generic.List[string]]::new() + + try { + $rootResult = Get-SPSTrustedRootAuthority -Name "$($consumingFarm)_ROOT" ` + -Server $publishingServer -InstallAccount $InstallAccount + $rootTrust = if ($rootResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $notes.Add("ROOT: $($_.Exception.Message)") + } + + $farmId = $null + try { + $farmId = Get-SPSFarmId -Server $consumingServer -InstallAccount $InstallAccount + } + catch { + $notes.Add("FarmId: $($_.Exception.Message)") + } + + if ($null -ne $farmId) { + try { + $topoResult = Get-SPSTopologyServiceAppPermission -FarmId "$farmId" ` + -Server $publishingServer -InstallAccount $InstallAccount + $topologyPermission = if ($topoResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $notes.Add("Topology: $($_.Exception.Message)") + } + } + else { + $topologyPermission = 'Error' + } + + foreach ($service in $services) { + $isContent = ($service -eq 'Content') + $stsTrust = 'N/A' + $published = 'N/A' + $serviceAppPermission = 'N/A' + $proxy = 'N/A' + + if (-not $isContent) { + try { + $stsResult = Get-SPSTrustedServiceTokenIssuer -Name "$($consumingFarm)_STS" ` + -Server $publishingServer -InstallAccount $InstallAccount + $stsTrust = if ($stsResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $stsTrust = 'Error' + $notes.Add("STS: $($_.Exception.Message)") + } + + try { + $pubResult = Get-SPSPublishedServiceApplication -Name $service ` + -Server $publishingServer -InstallAccount $InstallAccount + $published = if ($pubResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $published = 'Error' + $notes.Add("Published: $($_.Exception.Message)") + } + + if ($null -ne $farmId) { + try { + $saResult = Get-SPSPublishedServiceAppPermission -FarmId "$farmId" -Name $service ` + -Server $publishingServer -InstallAccount $InstallAccount + $serviceAppPermission = if ($saResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $serviceAppPermission = 'Error' + $notes.Add("SAPermission: $($_.Exception.Message)") + } + } + else { + $serviceAppPermission = 'Error' + } + + try { + $proxyResult = Get-SPSPublishedServiceAppProxy -Name $service ` + -Server $publishingServer -InstallAccount $InstallAccount + $proxy = if ($proxyResult.Ensure -eq 'Present') { 'Present' } else { 'Absent' } + } + catch { + $proxy = 'Error' + $notes.Add("Proxy: $($_.Exception.Message)") + } + } + + $rows.Add([ordered]@{ + PublishingFarm = $publishingFarm + PublishingServer = $publishingServer + ConsumingFarm = $consumingFarm + ConsumingServer = $consumingServer + Service = $service + RootTrust = $rootTrust + StsTrust = $stsTrust + Published = $published + TopologyPermission = $topologyPermission + ServiceAppPermission = $serviceAppPermission + Proxy = $proxy + Notes = ($notes -join '; ') + }) + } + } + } + + $status = [ordered]@{ + Application = $Application + Environment = $Environment + Domain = $Domain + Version = $Version + GeneratedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + RowCount = $rows.Count + Rows = $rows.ToArray() + } + + return $status +} diff --git a/src/Modules/SPSTrust.Common/SPSTrust.Common.psd1 b/src/Modules/SPSTrust.Common/SPSTrust.Common.psd1 index a3a978a..57d7e05 100644 --- a/src/Modules/SPSTrust.Common/SPSTrust.Common.psd1 +++ b/src/Modules/SPSTrust.Common/SPSTrust.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'SPSTrust.Common.psm1' - ModuleVersion = '2.0.0' + ModuleVersion = '2.1.0' GUID = 'e1cce4f2-12de-4923-8e67-f37a081944aa' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' @@ -10,9 +10,11 @@ PowerShellVersion = '5.1' FunctionsToExport = @( + 'Backup-SPSJsonFile' 'Clear-SPSLogFolder' 'Export-SPSSecurityTokenCertificate' 'Export-SPSTrustedRootAuthority' + 'Export-SPSTrustReport' 'Get-SPSFarmId' 'Get-SPSPublishedServiceAppPermission' 'Get-SPSPublishedServiceAppProxy' @@ -21,6 +23,7 @@ 'Get-SPSTopologyServiceAppPermission' 'Get-SPSTrustedRootAuthority' 'Get-SPSTrustedServiceTokenIssuer' + 'Get-SPSTrustStatus' 'New-SPSPublishedServiceAppProxy' 'Publish-SPSServiceApplication' 'Remove-SPSPublishedServiceAppProxy' diff --git a/tests/Modules/SPSTrust.Common.Tests.ps1 b/tests/Modules/SPSTrust.Common.Tests.ps1 index d176774..99faa95 100644 --- a/tests/Modules/SPSTrust.Common.Tests.ps1 +++ b/tests/Modules/SPSTrust.Common.Tests.ps1 @@ -66,9 +66,11 @@ Describe 'SPSTrust.Common Module' { Describe 'SPSTrust.Common Public Functions' { $publicFunctions = @( + 'Backup-SPSJsonFile', 'Clear-SPSLogFolder', 'Export-SPSSecurityTokenCertificate', 'Export-SPSTrustedRootAuthority', + 'Export-SPSTrustReport', 'Get-SPSFarmId', 'Get-SPSPublishedServiceAppPermission', 'Get-SPSPublishedServiceAppProxy', @@ -77,6 +79,7 @@ Describe 'SPSTrust.Common Public Functions' { 'Get-SPSTopologyServiceAppPermission', 'Get-SPSTrustedRootAuthority', 'Get-SPSTrustedServiceTokenIssuer', + 'Get-SPSTrustStatus', 'New-SPSPublishedServiceAppProxy', 'Publish-SPSServiceApplication', 'Remove-SPSPublishedServiceAppProxy', @@ -92,9 +95,11 @@ Describe 'SPSTrust.Common Public Functions' { It 'manifest FunctionsToExport matches the exported set' { $expectedExports = @( + 'Backup-SPSJsonFile', 'Clear-SPSLogFolder', 'Export-SPSSecurityTokenCertificate', 'Export-SPSTrustedRootAuthority', + 'Export-SPSTrustReport', 'Get-SPSFarmId', 'Get-SPSPublishedServiceAppPermission', 'Get-SPSPublishedServiceAppProxy', @@ -103,6 +108,7 @@ Describe 'SPSTrust.Common Public Functions' { 'Get-SPSTopologyServiceAppPermission', 'Get-SPSTrustedRootAuthority', 'Get-SPSTrustedServiceTokenIssuer', + 'Get-SPSTrustStatus', 'New-SPSPublishedServiceAppProxy', 'Publish-SPSServiceApplication', 'Remove-SPSPublishedServiceAppProxy', diff --git a/tests/Modules/SPSTrust.Report.Tests.ps1 b/tests/Modules/SPSTrust.Report.Tests.ps1 new file mode 100644 index 0000000..c164ee1 --- /dev/null +++ b/tests/Modules/SPSTrust.Report.Tests.ps1 @@ -0,0 +1,244 @@ +# Pester tests for the SPSTrust.Common reporting/audit functions (2.1.0): +# Get-SPSTrustStatus, Export-SPSTrustReport, Backup-SPSJsonFile and the private +# HTML report helpers. + +BeforeAll { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:moduleRoot = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSTrust.Common' + $script:moduleManifest = Join-Path -Path $script:moduleRoot -ChildPath 'SPSTrust.Common.psd1' + $script:moduleName = 'SPSTrust.Common' + + # Stub SharePoint cmdlets so the module imports on non-Windows hosts. + $spsStubs = @( + 'Get-SPServer', 'Get-SPFarm', 'Get-SPServiceApplication', 'Get-SPTrustedRootAuthority', + 'Get-SPTrustedServiceTokenIssuer', 'Add-PSSnapin', 'Get-PSSnapin', 'New-PSSession', + 'Remove-PSSession', 'Invoke-Command' + ) + foreach ($name in $spsStubs) { + if (-not (Get-Command -Name $name -ErrorAction SilentlyContinue)) { + $sb = [ScriptBlock]::Create("function global:$name { param() }") + & $sb + } + } + + Import-Module -Name $script:moduleManifest -Force -DisableNameChecking + + $script:sampleFarms = @( + [pscustomobject]@{ Name = 'SEARCH'; Server = 'srvsearch' } + [pscustomobject]@{ Name = 'SERVICES'; Server = 'srvservices' } + [pscustomobject]@{ Name = 'CONTENT'; Server = 'srvcontent' } + ) + $script:sampleTrusts = @( + [pscustomobject]@{ LocalFarm = 'SERVICES'; RemoteFarms = @('CONTENT'); Services = @('CONTOSOPRODUPS') } + [pscustomobject]@{ LocalFarm = 'CONTENT'; RemoteFarms = @('SEARCH'); Services = @('Content') } + ) + $securePwd = ConvertTo-SecureString 'p@ssw0rd!' -AsPlainText -Force + $script:sampleCred = [System.Management.Automation.PSCredential]::new('CONTOSO\svc', $securePwd) +} + +AfterAll { + Remove-Module -Name 'SPSTrust.Common' -Force -ErrorAction SilentlyContinue +} + +Describe 'SPSTrust.Common reporting public surface' { + + It 'exports <_>' -ForEach @('Backup-SPSJsonFile', 'Get-SPSTrustStatus', 'Export-SPSTrustReport') { + Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It 'keeps the HTML helper <_> private (module scope only)' -ForEach @( + 'ConvertTo-SPSHtmlEncoded', 'Get-SPSReportHtmlHead', 'Get-SPSReportCardHtml', 'Get-SPSReportHtmlScript') { + (Get-Module -Name $script:moduleName).ExportedFunctions.Keys | Should -Not -Contain $_ + $helper = $_ + InModuleScope -ModuleName 'SPSTrust.Common' -Parameters @{ helper = $helper } { + param($helper) + Get-Command -Name $helper -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + } +} + +Describe 'ConvertTo-SPSHtmlEncoded' { + It 'escapes HTML metacharacters' { + InModuleScope -ModuleName 'SPSTrust.Common' { + ConvertTo-SPSHtmlEncoded -Value 'a&"x''y' | Should -Be 'a<b>&"x'y' + } + } + It 'returns empty string for null/empty input' { + InModuleScope -ModuleName 'SPSTrust.Common' { + ConvertTo-SPSHtmlEncoded -Value '' | Should -Be '' + } + } +} + +Describe 'Get-SPSTrustStatus' { + + BeforeEach { + # Default: everything Present. Individual tests override specific getters. + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSFarmId -MockWith { @{ Value = [guid]::NewGuid() } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSTrustedRootAuthority -MockWith { @{ Ensure = 'Present' } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSTrustedServiceTokenIssuer -MockWith { @{ Ensure = 'Present' } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSPublishedServiceApplication -MockWith { @{ Ensure = 'Present'; Uri = 'urn:x'; Type = 'T' } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSTopologyServiceAppPermission -MockWith { @{ Ensure = 'Present' } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSPublishedServiceAppPermission -MockWith { @{ Ensure = 'Present' } } + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSPublishedServiceAppProxy -MockWith { @{ Ensure = 'Present' } } + } + + It 'produces one row per publishing/consuming/service combination' { + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + $status.RowCount | Should -Be 2 + @($status.Rows).Count | Should -Be 2 + } + + It 'carries metadata through' { + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred ` + -Application 'contoso' -Environment 'PROD' -Version '2.1.0' + $status.Application | Should -Be 'contoso' + $status.Environment | Should -Be 'PROD' + $status.Domain | Should -Be 'contoso.com' + $status.Version | Should -Be '2.1.0' + $status.GeneratedAtUtc | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$' + } + + It 'resolves target server FQDNs from the farm inventory' { + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + $svcRow = $status.Rows | Where-Object { $_.PublishingFarm -eq 'SERVICES' } + $svcRow.PublishingServer | Should -Be 'srvservices.contoso.com' + $svcRow.ConsumingServer | Should -Be 'srvcontent.contoso.com' + } + + It 'marks STS/Published/SA-Permission/Proxy as N/A for a Content service' { + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + $contentRow = $status.Rows | Where-Object { $_.Service -eq 'Content' } + $contentRow.StsTrust | Should -Be 'N/A' + $contentRow.Published | Should -Be 'N/A' + $contentRow.ServiceAppPermission | Should -Be 'N/A' + $contentRow.Proxy | Should -Be 'N/A' + # ROOT and Topology still apply to a content farm. + $contentRow.RootTrust | Should -Be 'Present' + $contentRow.TopologyPermission | Should -Be 'Present' + } + + It 'reflects an Absent dimension' { + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSPublishedServiceAppProxy -MockWith { @{ Ensure = 'Absent' } } + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + $svcRow = $status.Rows | Where-Object { $_.Service -eq 'CONTOSOPRODUPS' } + $svcRow.Proxy | Should -Be 'Absent' + } + + It 'captures Error and a Note when a getter throws (does not abort)' { + Mock -ModuleName 'SPSTrust.Common' -CommandName Get-SPSTrustedRootAuthority -MockWith { throw 'boom' } + $status = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + $status.RowCount | Should -Be 2 + $svcRow = $status.Rows | Where-Object { $_.Service -eq 'CONTOSOPRODUPS' } + $svcRow.RootTrust | Should -Be 'Error' + $svcRow.Notes | Should -Match 'ROOT: boom' + } + + It 'never calls a state-changing function' { + Mock -ModuleName 'SPSTrust.Common' -CommandName Set-SPSTrustedRootAuthority -MockWith { } + Mock -ModuleName 'SPSTrust.Common' -CommandName Publish-SPSServiceApplication -MockWith { } + Mock -ModuleName 'SPSTrust.Common' -CommandName New-SPSPublishedServiceAppProxy -MockWith { } + $null = Get-SPSTrustStatus -Farms $script:sampleFarms -Trusts $script:sampleTrusts ` + -Domain 'contoso.com' -InstallAccount $script:sampleCred + Should -Invoke -ModuleName 'SPSTrust.Common' -CommandName Set-SPSTrustedRootAuthority -Times 0 + Should -Invoke -ModuleName 'SPSTrust.Common' -CommandName Publish-SPSServiceApplication -Times 0 + Should -Invoke -ModuleName 'SPSTrust.Common' -CommandName New-SPSPublishedServiceAppProxy -Times 0 + } +} + +Describe 'Export-SPSTrustReport' { + + BeforeEach { + $script:tempRoot = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:tempRoot -ItemType Directory -Force + $script:status = [ordered]@{ + Application = 'contoso' + Environment = 'PROD' + Domain = 'contoso.com' + Version = '2.1.0' + GeneratedAtUtc = '2026-07-10T12:00:00Z' + RowCount = 2 + Rows = @( + [ordered]@{ PublishingFarm = 'SERVICES'; PublishingServer = 'srvservices.contoso.com'; ConsumingFarm = 'CONTENT'; ConsumingServer = 'srvcontent.contoso.com'; Service = 'CONTOSOPRODUPS'; RootTrust = 'Present'; StsTrust = 'Present'; Published = 'Present'; TopologyPermission = 'Present'; ServiceAppPermission = 'Absent'; Proxy = 'Absent'; Notes = '' } + [ordered]@{ PublishingFarm = 'CONTENT'; PublishingServer = 'srvcontent.contoso.com'; ConsumingFarm = 'SEARCH'; ConsumingServer = 'srvsearch.contoso.com'; Service = 'Content'; RootTrust = 'Present'; StsTrust = 'N/A'; Published = 'N/A'; TopologyPermission = 'Present'; ServiceAppPermission = 'N/A'; Proxy = 'N/A'; Notes = '' } + ) + } + } + + It 'writes an HTML file and returns its path' { + $out = Join-Path -Path $script:tempRoot -ChildPath 'report.html' + $result = Export-SPSTrustReport -Status $script:status -OutputPath $out + $result | Should -Be $out + $out | Should -Exist + } + + It 'renders the interactive matrix table with status pills' { + $out = Join-Path -Path $script:tempRoot -ChildPath 'report.html' + Export-SPSTrustReport -Status $script:status -OutputPath $out | Out-Null + $html = Get-Content -Path $out -Raw + $html | Should -Match 'id="trust-matrix"' + $html | Should -Match 'pill-present' + $html | Should -Match 'pill-absent' + $html | Should -Match 'pill-na' + $html | Should -Match '' + $out = Join-Path -Path $script:tempRoot -ChildPath 'enc.html' + Export-SPSTrustReport -Status $script:status -OutputPath $out | Out-Null + $html = Get-Content -Path $out -Raw + $html | Should -Match '<script>alert\(1\)</script>' + } +} + +Describe 'Backup-SPSJsonFile' { + + BeforeEach { + $script:tempRoot = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:tempRoot -ItemType Directory -Force + $script:jsonPath = Join-Path -Path $script:tempRoot -ChildPath 'CONTOSO-PROD.json' + $script:historyFolder = Join-Path -Path $script:tempRoot -ChildPath 'history' + } + + It 'returns $null and creates nothing when the source does not exist' { + $result = Backup-SPSJsonFile -Path $script:jsonPath -HistoryFolder $script:historyFolder + $result | Should -BeNullOrEmpty + $script:historyFolder | Should -Not -Exist + } + + It 'archives the existing file with a timestamped name and leaves the original' { + Set-Content -Path $script:jsonPath -Value '{"a":1}' + $result = Backup-SPSJsonFile -Path $script:jsonPath -HistoryFolder $script:historyFolder -TimeStamp '20260710-1200' + $result | Should -Exist + (Split-Path -Path $result -Leaf) | Should -Be 'CONTOSO-PROD-20260710-1200.json' + $script:jsonPath | Should -Exist + } +} From 2a99385d261beca96c5104ba503861827914eb7e Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 10 Jul 2026 15:04:58 +0200 Subject: [PATCH 2/3] feat: wire -ReportOnly audit mode and post-run trust report into SPSTrust.ps1 - Add -ReportOnly switch (read-only audit: skips the four mutating stages and only collects state + writes the report) and -HistoryRetentionDays (default 30). - Initialize Results/ (+ history/) and Reports/ folders; add a run-mode banner line. - Guard the certificate-exchange / publish / permission / proxy stages behind 'if (-not $ReportOnly)'. - Always run a read-only stage 5: Get-SPSTrustStatus -> archive previous results with Backup-SPSJsonFile -> prune history via Clear-SPSLogFolder -Extension '*.json' -> write Results JSON -> Export-SPSTrustReport HTML. Wrapped in try/catch so a report failure never masks the run outcome. - .gitignore: exclude runtime Logs/, Results/, Reports/. - Tests: cover the new params (ReportOnly switch, HistoryRetentionDays) and the report wiring (guard, Get-SPSTrustStatus, Backup-SPSJsonFile, Export-SPSTrustReport, history pruning). 88 tests pass; analyzer clean. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 7 ++++- src/SPSTrust.ps1 | 67 +++++++++++++++++++++++++++++++++++++--- tests/SPSTrust.Tests.ps1 | 38 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 6a3e68d..78908fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -**/.DS_Store \ No newline at end of file +**/.DS_Store + +# Runtime output folders +Logs/ +Results/ +Reports/ diff --git a/src/SPSTrust.ps1 b/src/SPSTrust.ps1 index d48b7b0..91ba89f 100644 --- a/src/SPSTrust.ps1 +++ b/src/SPSTrust.ps1 @@ -50,9 +50,17 @@ param( [switch] $CleanServices, # Switch parameter to clean services + [Parameter()] + [switch] + $ReportOnly, # Read-only audit: collect trust state and write a report, change nothing + + [Parameter()] + [System.UInt32] + $LogRetentionDays = 180, # Number of days of transcript logs to keep + [Parameter()] [System.UInt32] - $LogRetentionDays = 180 # Number of days of transcript logs to keep + $HistoryRetentionDays = 30 # Number of days of archived result snapshots to keep ) #requires -Version 5.1 @@ -111,14 +119,27 @@ $getDateFormatted = Get-Date -Format yyyy-MM-dd $spsTrustFileName = "$($Application)-$($Environment)-$($getDateFormatted)" $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name $pathLogsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Logs' - -# Initialize logs +$pathResultsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Results' +$pathHistoryFolder = Join-Path -Path $pathResultsFolder -ChildPath 'history' +$pathReportsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Reports' +$resultsBaseName = "$($Application)-$($Environment)" +$pathResultsFile = Join-Path -Path $pathResultsFolder -ChildPath ($resultsBaseName + '.json') +$pathReportFile = Join-Path -Path $pathReportsFolder -ChildPath ($resultsBaseName + '.html') + +# Initialize logs, results and reports folders if (-Not (Test-Path -Path $pathLogsFolder)) { New-Item -ItemType Directory -Path $pathLogsFolder -Force } +if (-Not (Test-Path -Path $pathResultsFolder)) { + New-Item -ItemType Directory -Path $pathResultsFolder -Force +} +if (-Not (Test-Path -Path $pathReportsFolder)) { + New-Item -ItemType Directory -Path $pathReportsFolder -Force +} $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ($spsTrustFileName + '.log') $DateStarted = Get-Date $psVersion = ($Host).Version.ToString() +$runMode = if ($ReportOnly) { 'ReportOnly (read-only audit)' } elseif ($CleanServices) { 'CleanServices' } else { 'Configure' } # Start transcript to log the output Start-Transcript -Path $pathLogFile -IncludeInvocationHeader @@ -128,6 +149,7 @@ Write-Output '-----------------------------------------------' Write-Output "| SPSTrust Configuration Script v$SPSTrustVersion" Write-Output "| Started on - $DateStarted by $currentUser" Write-Output "| PowerShell Version - $psVersion" +Write-Output "| Mode - $runMode" Write-Output '-----------------------------------------------' #endregion @@ -137,7 +159,8 @@ Write-Output '-----------------------------------------------' Write-Verbose -Message "Setting power management plan to 'High Performance'..." Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow -# 1. Exchange trust certificates between the farms +if (-not $ReportOnly) { + # 1. Exchange trust certificates between the farms # 1.1 Export STS and ROOT certificates for each Farm foreach ($spFarm in $spFarmsObj) { $spRootCertPath = "$($certFolder)\$($spFarm.Name)_ROOT.cer" @@ -639,6 +662,42 @@ Exception: $_ } } } +} +# end of if (-not $ReportOnly) - the four mutating stages are skipped in report-only mode + +# 5. Collect the current trust state and produce the JSON results + HTML report. +# This is always done (both after a normal/clean run and in -ReportOnly audit mode) +# and is strictly read-only. +Write-Output '-----------------------------------------------' +Write-Output "| Collecting trust state and generating report" +Write-Output '-----------------------------------------------' +try { + $trustStatus = Get-SPSTrustStatus -Farms $spFarmsObj ` + -Trusts $spTrustsObj ` + -Domain $scriptFQDN ` + -InstallAccount $FarmAccount ` + -Application $Application ` + -Environment $Environment ` + -Version $SPSTrustVersion + + # Archive the previous results snapshot before overwriting, then prune old snapshots. + $null = Backup-SPSJsonFile -Path $pathResultsFile -HistoryFolder $pathHistoryFolder + Clear-SPSLogFolder -Path $pathHistoryFolder -Retention $HistoryRetentionDays -Extension '*.json' + + # Write the fresh results JSON. + $trustStatus | ConvertTo-Json -Depth 6 | Set-Content -Path $pathResultsFile -Encoding UTF8 + Write-Output "[Results] $pathResultsFile" + + # Render the HTML trust matrix report. + $null = Export-SPSTrustReport -Status $trustStatus -OutputPath $pathReportFile + Write-Output "[Report] $pathReportFile" +} +catch { + Write-Error -Message @" +Failed to collect trust state or generate the report. +Exception: $_ +"@ +} #endregion # Clean-Up diff --git a/tests/SPSTrust.Tests.ps1 b/tests/SPSTrust.Tests.ps1 index 2e45edb..74cbf55 100644 --- a/tests/SPSTrust.Tests.ps1 +++ b/tests/SPSTrust.Tests.ps1 @@ -118,6 +118,44 @@ Describe 'SPSTrust.ps1 Parameters' { } $logParam | Should -Not -BeNullOrEmpty } + + It 'Should expose a ReportOnly switch' { + $reportParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'ReportOnly' + } + $reportParam | Should -Not -BeNullOrEmpty + $reportParam.StaticType.Name | Should -Be 'SwitchParameter' + } + + It 'Should expose a HistoryRetentionDays parameter' { + $histParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'HistoryRetentionDays' + } + $histParam | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSTrust.ps1 report wiring' { + + It 'guards the mutating stages behind -ReportOnly' { + $script:scriptContent | Should -Match 'if \(-not \$ReportOnly\)' + } + + It 'collects state with the read-only Get-SPSTrustStatus' { + $script:scriptContent | Should -Match 'Get-SPSTrustStatus' + } + + It 'archives the previous results with Backup-SPSJsonFile' { + $script:scriptContent | Should -Match 'Backup-SPSJsonFile' + } + + It 'renders the report with Export-SPSTrustReport' { + $script:scriptContent | Should -Match 'Export-SPSTrustReport' + } + + It 'prunes result-history snapshots with Clear-SPSLogFolder' { + $script:scriptContent | Should -Match "Clear-SPSLogFolder -Path \`$pathHistoryFolder" + } } Describe 'SPSTrust example configuration' { From 2953ad6a66b1ad1e0f644eb726809d4eb0e1a10d Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 10 Jul 2026 15:06:48 +0200 Subject: [PATCH 3/3] docs: document trust matrix report and 2.1.0 release - Wiki: add Reports.md (trust matrix dimensions, read-only guarantee, when the report is generated, Results/Reports output layout, regeneration from a saved snapshot, related parameters). Link it from _Sidebar and Home; update Usage with -ReportOnly, -HistoryRetentionDays, a report example and the new output section. - CHANGELOG.md: add the 2.1.0 section (Added/Changed). - RELEASE-NOTES.md: replace with the 2.1.0 section (used verbatim as the Release body), noting full backward compatibility with 2.0.0. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 34 ++++++++++++++++++++ RELEASE-NOTES.md | 43 ++++++++++++------------- wiki/Home.md | 2 ++ wiki/Reports.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++ wiki/Usage.md | 28 ++++++++++++---- wiki/_Sidebar.md | 1 + 6 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 wiki/Reports.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3fea7..a284577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-07-10 + +### Added + +- Read-only **trust matrix** reporting: + - `Get-SPSTrustStatus` (public) — collects the current cross-farm trust state + (ROOT, STS, Published, Topology permission, SA permission, Proxy) by calling only + the read-only `Get-SPS*` functions. Never changes anything; `Content` services are + reported as N/A for STS/Published/SA-permission/Proxy; getter failures are captured + as `Error` with a per-row note instead of aborting the audit. + - `Export-SPSTrustReport` (public) — renders a status object (`-Status`) or a results + JSON file (`-InputFile`) into a self-contained, offline HTML report with summary + cards and an interactive (search + click-to-sort) trust matrix. + - `Backup-SPSJsonFile` (public) — archives the previous results JSON before overwrite. + - Private HTML report helpers: `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportHtmlHead`, + `Get-SPSReportCardHtml`, `Get-SPSReportHtmlScript`. +- `SPSTrust.ps1`: + - `-ReportOnly` switch — read-only audit that skips all four configuration stages and + only collects state and writes the report. + - `-HistoryRetentionDays` parameter (default 30) — rotation of archived result + snapshots in `Results\history\`. + - A post-run reporting stage (runs after both a normal/clean run and a `-ReportOnly` + audit): writes `Results\-.json` and + `Reports\-.html`, archiving the previous snapshot and + pruning history first. +- Wiki: new `Reports.md` (trust matrix, output layout, regeneration) linked from the + sidebar; Home and Usage updated for the new parameters and outputs. +- Tests for the collector, the renderer, `Backup-SPSJsonFile`, and the script report + wiring. + +### Changed + +- `.gitignore` now excludes the runtime `Logs/`, `Results/` and `Reports/` folders. + ## [2.0.0] - 2026-07-10 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a13ce49..0bacaf0 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,36 +1,33 @@ # SPSTrust - Release Notes -## [2.0.0] - 2026-07-10 +## [2.1.0] - 2026-07-10 -> [!IMPORTANT] -> This is a major release with breaking changes. The package layout moved from -> `scripts/` to `src/`, and the configuration format changed from JSON to a PowerShell -> data file (`.psd1`). Convert your JSON configuration to the equivalent `.psd1` hashtable -> (see the [Configuration](https://github.com/luigilink/SPSTrust/wiki/Configuration) wiki -> page) before upgrading. +This release adds a read-only **trust matrix** report, produced both at the end of a +normal run and in a new dedicated audit mode. It is fully backward compatible with 2.0.0. ### Added -- New reusable `SPSTrust.Common` module (Public/Private layout, manifest-driven version). -- `-LogRetentionDays` parameter with automatic transcript log rotation. -- Example `.psd1` configuration, Pester test suite, PSScriptAnalyzer settings, and a - Pester CI workflow. -- Wiki sidebar and Release Process page. +- **`-ReportOnly`** — read-only audit mode: skips all configuration stages and only + collects the current trust state and writes the report (changes nothing). +- **Trust matrix report** — every run now writes a JSON snapshot + (`Results\-.json`) and a self-contained, offline HTML report + (`Reports\-.html`) showing, per publishing-farm / + consuming-farm / service, the state of each trust dimension (ROOT, STS, Published, + Topology permission, SA permission, Proxy) as Present / Absent / N/A / Error. +- New public functions in `SPSTrust.Common`: `Get-SPSTrustStatus` (read-only collector), + `Export-SPSTrustReport` (HTML renderer, also usable standalone via `-InputFile`) and + `Backup-SPSJsonFile`. +- **`-HistoryRetentionDays`** (default 30) — rotation of archived result snapshots in + `Results\history\`. +- Wiki: new **Reports & Audit** page. ### Changed -- **BREAKING**: `scripts/` → `src/`; release archives extract to `SPSTrust.ps1` + `Modules/`. -- **BREAKING**: configuration is now a `.psd1` data file loaded with `Import-PowerShellDataFile`. -- Script version sourced from the module manifest. -- Workflows bumped (`checkout@v7`, `action-gh-release@v3`); README trimmed to defer to the wiki. +- `.gitignore` excludes the runtime `Logs/`, `Results/` and `Reports/` folders. -### Fixed +### Compatibility -- Wiki `Usage` examples (wrong script name and a missing mandatory `-FarmAccount`). - -### Removed - -- `scripts/` folder, the monolithic `sps.util.psm1` / `util.psm1` modules, and the unused - `Clear-SPSLog` helper. +- No breaking changes. Existing `-ConfigFile` / `-FarmAccount` / `-CleanServices` usage is + unchanged; the reporting stage is additive and read-only. A full list of changes in each version can be found in the [change log](CHANGELOG.md). diff --git a/wiki/Home.md b/wiki/Home.md index aed6d1a..23a1545 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -9,6 +9,7 @@ It follows the Microsoft guidance [Share service applications across farms in Sh - Declarative, JSON-free configuration via a PowerShell data file (`.psd1`) - Idempotent: safe to re-run — it only creates what is missing - `-CleanServices` switch to tear down published services and trust +- `-ReportOnly` read-only audit mode producing a JSON + HTML trust matrix - Shared logic packaged in the reusable `SPSTrust.Common` module - Transcript logging with automatic retention/rotation @@ -17,6 +18,7 @@ It follows the Microsoft guidance [Share service applications across farms in Sh - [🚀 Getting Started](./Getting-Started) - [⚙️ Configuration](./Configuration) - [📖 Usage](./Usage) +- [📊 Reports & Audit](./Reports) - [📦 Release Process](./Release-Process) ## Requirements diff --git a/wiki/Reports.md b/wiki/Reports.md new file mode 100644 index 0000000..c52be36 --- /dev/null +++ b/wiki/Reports.md @@ -0,0 +1,84 @@ +# Reports & Audit + +Starting with **2.1.0**, SPSTrust can produce a read-only **trust matrix**: a snapshot of +the current cross-farm trust state, written both as a machine-readable JSON file and as a +self-contained HTML report. + +## What is reported + +For every publishing-farm / consuming-farm / service relationship declared in your +configuration, SPSTrust resolves the state of each trust dimension: + +| Dimension | Source | Notes | +| ---------------------- | ---------------------------------------- | ----- | +| `RootTrust` | `Get-SPSTrustedRootAuthority` | ROOT certificate trust on the publishing farm | +| `StsTrust` | `Get-SPSTrustedServiceTokenIssuer` | STS trust (N/A for a `Content` service) | +| `Published` | `Get-SPSPublishedServiceApplication` | Service application published (N/A for `Content`) | +| `TopologyPermission` | `Get-SPSTopologyServiceAppPermission` | Application Discovery & Load Balancing permission | +| `ServiceAppPermission` | `Get-SPSPublishedServiceAppPermission` | Per-service permission (N/A for `Content`) | +| `Proxy` | `Get-SPSPublishedServiceAppProxy` | Service application proxy on the consuming farm (N/A for `Content`) | + +Each cell is reported as **Present**, **Absent**, **N/A** (not applicable) or **Error** +(the underlying check failed — the message is captured in the row's *Notes*). A single +failing farm never aborts the whole audit. + +> [!NOTE] +> Collection is strictly **read-only**. It calls only the `Get-SPS*` functions and never +> publishes, grants, revokes, connects or removes anything. + +## When the report is generated + +The report is produced in **two** situations: + +1. **At the end of every normal run** (`Configure` or `-CleanServices`) — a post-run + snapshot of the resulting state. +2. **In `-ReportOnly` mode** — an audit that skips all four configuration stages and only + collects state and writes the report. Use this to document or verify an existing + topology without changing anything. + +```powershell +# Read-only audit only (no changes) +.\SPSTrust.ps1 -ConfigFile '.\Config\CONTOSO-PROD.psd1' -FarmAccount (Get-Credential) -ReportOnly +``` + +## Output layout + +Relative to `SPSTrust.ps1`: + +``` +Results\ + CONTOSO-PROD.json # latest results snapshot (overwritten each run) + history\ + CONTOSO-PROD-20260710-1200.json # timestamped archives of previous snapshots +Reports\ + CONTOSO-PROD.html # latest HTML trust matrix (overwritten each run) +``` + +- The results file is named `-.json`. +- Before each run the current results file is archived into `Results\history\` with a + timestamp, then snapshots older than `-HistoryRetentionDays` (default **30**) are pruned. +- The HTML report is self-contained (embedded CSS/JS, no CDN) so it opens offline on a + SharePoint server. It includes summary cards and an interactive matrix (search box and + click-to-sort headers), with a colored status pill per cell. + +## Regenerating a report from a saved snapshot + +The HTML report can be rebuilt from any results JSON without touching the farm: + +```powershell +Import-Module .\Modules\SPSTrust.Common\SPSTrust.Common.psd1 +Export-SPSTrustReport -InputFile '.\Results\history\CONTOSO-PROD-20260710-1200.json' ` + -OutputPath '.\Reports\CONTOSO-PROD-20260710-1200.html' +``` + +## Related parameters + +| Parameter | Default | Description | +| ----------------------- | ------- | ----------- | +| `-ReportOnly` | off | Read-only audit: skip all configuration stages, only collect state and write the report. | +| `-HistoryRetentionDays` | `30` | Days of archived result snapshots to keep in `Results\history\`. `0` disables pruning. | + +## See also + +- [Usage](./Usage) +- [Configuration](./Configuration) diff --git a/wiki/Usage.md b/wiki/Usage.md index 7c3437c..930ce14 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -2,12 +2,14 @@ ## Parameters -| Parameter | Required | Description | -| ------------------- | -------- | --------------------------------------------------------------------------- | -| `-ConfigFile` | Yes | Path to the `.psd1` configuration file. | -| `-FarmAccount` | Yes | Credential of the service account that runs the script (same on all farms). | -| `-CleanServices` | No | Switch. Removes published services and trust on each trusted farm. | -| `-LogRetentionDays` | No | Days of transcript logs to keep in `Logs\`. Defaults to `180`. `0` disables pruning. | +| Parameter | Required | Description | +| ----------------------- | -------- | --------------------------------------------------------------------------- | +| `-ConfigFile` | Yes | Path to the `.psd1` configuration file. | +| `-FarmAccount` | Yes | Credential of the service account that runs the script (same on all farms). | +| `-CleanServices` | No | Switch. Removes published services and trust on each trusted farm. | +| `-ReportOnly` | No | Switch. Read-only audit: skips all configuration stages and only writes the JSON + HTML trust matrix. | +| `-LogRetentionDays` | No | Days of transcript logs to keep in `Logs\`. Defaults to `180`. `0` disables pruning. | +| `-HistoryRetentionDays` | No | Days of archived result snapshots to keep in `Results\history\`. Defaults to `30`. `0` disables pruning. | ### Basic usage example @@ -26,6 +28,14 @@ is still required): .\SPSTrust.ps1 -ConfigFile '.\Config\CONTOSO-PROD.psd1' -FarmAccount (Get-Credential) -CleanServices ``` +### Read-only audit example + +Produce a trust matrix (JSON + HTML) without changing anything: + +```powershell +.\SPSTrust.ps1 -ConfigFile '.\Config\CONTOSO-PROD.psd1' -FarmAccount (Get-Credential) -ReportOnly +``` + ### Custom log retention Keep 30 days of transcript logs instead of the default 180: @@ -39,8 +49,12 @@ Keep 30 days of transcript logs instead of the default 180: - **Transcript logs** are written to a `Logs\` folder next to `SPSTrust.ps1`, named `--.log`. Logs older than `-LogRetentionDays` are pruned at the end of each run. +- **Results & report** — every run (including `-ReportOnly`) writes a JSON snapshot to + `Results\-.json` and an HTML trust matrix to + `Reports\-.html`. See the [Reports & Audit](./Reports) page. - Progress and per-farm results are streamed to the console (and captured in the transcript). ## Next Step -See the [Release Process](./Release-Process) page for how new versions are shipped. +See the [Reports & Audit](./Reports) page for the trust matrix, or the +[Release Process](./Release-Process) page for how new versions are shipped. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index 4cdbf18..02631d9 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -4,6 +4,7 @@ - [🚀 Getting Started](Getting-Started) - [⚙️ Configuration](Configuration) - [📖 Usage](Usage) +- [📊 Reports & Audit](Reports) - [📦 Release Process](Release-Process) ---