|
| 1 | +function Get-CIPPTestResultsTenants { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Retrieves CIPP test results across one, many, or all tenants with flexible filtering. |
| 5 | +
|
| 6 | + .DESCRIPTION |
| 7 | + Queries the shared CippTestResults table server-side (partition / row / status / type / |
| 8 | + risk / category) so a single test can be compared across every tenant in one call. This is |
| 9 | + the cross-tenant counterpart to Get-CIPPTestResults (which is scoped to a single tenant). |
| 10 | +
|
| 11 | + Custom test rows are enriched with their definition (Description / ReturnType / |
| 12 | + MarkdownTemplate) from the CustomPowershellScripts table so the off-canvas detail renders |
| 13 | + identically to the per-tenant dashboard. Every row is stamped with its tenant identity |
| 14 | + (Tenant / TenantId / TenantName) and a serialisable LastRun timestamp for display. |
| 15 | +
|
| 16 | + .PARAMETER TenantFilter |
| 17 | + One or more tenant domains. Omit, or pass 'AllTenants' / 'allTenants', to query every tenant. |
| 18 | +
|
| 19 | + .PARAMETER TestId |
| 20 | + One or more test IDs (the row's RowKey), e.g. 'CustomScript-<guid>'. |
| 21 | +
|
| 22 | + .PARAMETER Status |
| 23 | + One or more statuses to filter on (Passed / Failed / Investigate / Skipped / Informational). |
| 24 | +
|
| 25 | + .PARAMETER TestType |
| 26 | + Restrict to a single test type (Identity / Devices / Custom). |
| 27 | +
|
| 28 | + .PARAMETER Risk |
| 29 | + Restrict to a single risk level (High / Medium / Low). |
| 30 | +
|
| 31 | + .PARAMETER Category |
| 32 | + Restrict to a single category. |
| 33 | +
|
| 34 | + .EXAMPLE |
| 35 | + Get-CIPPTestResultsTenants -TestId 'CustomScript-1234' -TestType 'Custom' |
| 36 | + #> |
| 37 | + [CmdletBinding()] |
| 38 | + param( |
| 39 | + [Parameter(Mandatory = $false)] |
| 40 | + [string[]]$TenantFilter, |
| 41 | + |
| 42 | + [Parameter(Mandatory = $false)] |
| 43 | + [string[]]$TestId, |
| 44 | + |
| 45 | + [Parameter(Mandatory = $false)] |
| 46 | + [string[]]$Status, |
| 47 | + |
| 48 | + [Parameter(Mandatory = $false)] |
| 49 | + [string]$TestType, |
| 50 | + |
| 51 | + [Parameter(Mandatory = $false)] |
| 52 | + [string]$Risk, |
| 53 | + |
| 54 | + [Parameter(Mandatory = $false)] |
| 55 | + [string]$Category |
| 56 | + ) |
| 57 | + |
| 58 | + $Table = Get-CippTable -tablename 'CippTestResults' |
| 59 | + |
| 60 | + # Build a single OData filter so all narrowing happens server-side. A cross-partition scan |
| 61 | + # (no PartitionKey clause) is acceptable here because it is always bounded by a RowKey/Status/ |
| 62 | + # TestType clause when driven from the UI. |
| 63 | + $FilterParts = [System.Collections.Generic.List[string]]::new() |
| 64 | + |
| 65 | + $AllTenants = (-not $TenantFilter) -or ($TenantFilter -contains 'AllTenants') -or ($TenantFilter -contains 'allTenants') |
| 66 | + if (-not $AllTenants) { |
| 67 | + $TenantClause = (@($TenantFilter | Where-Object { $_ }) | ForEach-Object { "PartitionKey eq '$_'" }) -join ' or ' |
| 68 | + if ($TenantClause) { $FilterParts.Add("($TenantClause)") } |
| 69 | + } |
| 70 | + if ($TestId) { |
| 71 | + $TestClause = (@($TestId | Where-Object { $_ }) | ForEach-Object { "RowKey eq '$_'" }) -join ' or ' |
| 72 | + if ($TestClause) { $FilterParts.Add("($TestClause)") } |
| 73 | + } |
| 74 | + if ($Status) { |
| 75 | + $StatusClause = (@($Status | Where-Object { $_ }) | ForEach-Object { "Status eq '$_'" }) -join ' or ' |
| 76 | + if ($StatusClause) { $FilterParts.Add("($StatusClause)") } |
| 77 | + } |
| 78 | + if ($TestType) { $FilterParts.Add("TestType eq '$TestType'") } |
| 79 | + if ($Risk) { $FilterParts.Add("Risk eq '$Risk'") } |
| 80 | + if ($Category) { $FilterParts.Add("Category eq '$Category'") } |
| 81 | + |
| 82 | + $Filter = $FilterParts -join ' and ' |
| 83 | + |
| 84 | + $GetParams = @{} |
| 85 | + if ($Filter) { $GetParams.Filter = $Filter } |
| 86 | + $Results = @(Get-CIPPAzDataTableEntity @Table @GetParams) |
| 87 | + |
| 88 | + if ($Results.Count -eq 0) { return @() } |
| 89 | + |
| 90 | + # Map tenant domain (PartitionKey) -> tenant identity for display and access control. |
| 91 | + $TenantLookup = @{} |
| 92 | + try { |
| 93 | + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { |
| 94 | + if ($Tenant.defaultDomainName) { |
| 95 | + $TenantLookup[$Tenant.defaultDomainName] = $Tenant |
| 96 | + } |
| 97 | + } |
| 98 | + } catch { |
| 99 | + Write-Warning "Get-CIPPTestResultsTenants: failed to load tenant list: $($_.Exception.Message)" |
| 100 | + } |
| 101 | + |
| 102 | + # Enrich Custom rows with their definition (latest version per ScriptGuid), mirroring the |
| 103 | + # per-tenant enrichment in Invoke-ListTests so the shared off-canvas renders identically. |
| 104 | + $CustomMeta = @{} |
| 105 | + if (@($Results | Where-Object { $_.TestType -eq 'Custom' }).Count -gt 0) { |
| 106 | + $ScriptTable = Get-CippTable -tablename 'CustomPowershellScripts' |
| 107 | + $Scripts = @(Get-CIPPAzDataTableEntity @ScriptTable -Filter "PartitionKey eq 'CustomScript'") |
| 108 | + $LatestByGuid = @{} |
| 109 | + foreach ($Script in $Scripts) { |
| 110 | + if (-not $Script.ScriptGuid) { continue } |
| 111 | + $Existing = $LatestByGuid[$Script.ScriptGuid] |
| 112 | + if (-not $Existing -or [int]$Script.Version -gt [int]$Existing.Version) { |
| 113 | + $LatestByGuid[$Script.ScriptGuid] = $Script |
| 114 | + } |
| 115 | + } |
| 116 | + foreach ($Script in $LatestByGuid.Values) { |
| 117 | + # Treat a missing Enabled property as enabled, matching Invoke-CIPPTestCollection. |
| 118 | + # A disabled (or deleted) script can still have stale results in the table; surfacing |
| 119 | + # this lets the UI show that the data no longer reflects an active test. |
| 120 | + $EnabledProp = $Script.PSObject.Properties['Enabled'] |
| 121 | + $CustomMeta[$Script.ScriptGuid] = [PSCustomObject]@{ |
| 122 | + ScriptName = $Script.ScriptName ?? '' |
| 123 | + Description = $Script.Description ?? '' |
| 124 | + ReturnType = $Script.ReturnType ?? 'JSON' |
| 125 | + MarkdownTemplate = $Script.MarkdownTemplate ?? '' |
| 126 | + Enabled = (-not $EnabledProp) -or [bool]$EnabledProp.Value |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + foreach ($Result in $Results) { |
| 132 | + $TenantInfo = $TenantLookup[$Result.PartitionKey] |
| 133 | + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Result.PartitionKey -Force |
| 134 | + $Result | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue ($TenantInfo.customerId ?? '') -Force |
| 135 | + $Result | Add-Member -NotePropertyName 'TenantName' -NotePropertyValue ($TenantInfo.displayName ?? $Result.PartitionKey) -Force |
| 136 | + |
| 137 | + # Surface the Azure entity timestamp as a stable, serialisable last-run field. |
| 138 | + $LastRun = $Result.Timestamp |
| 139 | + if ($LastRun -is [DateTimeOffset]) { |
| 140 | + $LastRun = $LastRun.UtcDateTime.ToString('o') |
| 141 | + } elseif ($LastRun) { |
| 142 | + $LastRun = [string]$LastRun |
| 143 | + } |
| 144 | + $Result | Add-Member -NotePropertyName 'LastRun' -NotePropertyValue $LastRun -Force |
| 145 | + |
| 146 | + if ($Result.TestType -eq 'Custom') { |
| 147 | + $ScriptGuid = ($Result.RowKey -replace '^CustomScript-', '') |
| 148 | + if (-not [string]::IsNullOrWhiteSpace($ScriptGuid) -and $CustomMeta.ContainsKey($ScriptGuid)) { |
| 149 | + $Meta = $CustomMeta[$ScriptGuid] |
| 150 | + $Result | Add-Member -NotePropertyName 'Description' -NotePropertyValue $Meta.Description -Force |
| 151 | + $Result | Add-Member -NotePropertyName 'ReturnType' -NotePropertyValue $Meta.ReturnType -Force |
| 152 | + $Result | Add-Member -NotePropertyName 'MarkdownTemplate' -NotePropertyValue $Meta.MarkdownTemplate -Force |
| 153 | + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $Meta.Enabled -Force |
| 154 | + if ([string]::IsNullOrWhiteSpace($Result.Name) -and $Meta.ScriptName) { |
| 155 | + $Result | Add-Member -NotePropertyName 'Name' -NotePropertyValue $Meta.ScriptName -Force |
| 156 | + } |
| 157 | + } else { |
| 158 | + # Result exists but the script no longer does (deleted). The data is stale — flag |
| 159 | + # it as not enabled so the column stays populated and truthful. |
| 160 | + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $false -Force |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + return $Results |
| 166 | +} |
0 commit comments