Public/Invoke-InforcerAssessment.ps1
|
<# .SYNOPSIS Runs an assessment against one or more tenants via the Inforcer API. .DESCRIPTION Triggers an assessment run and returns detailed results including per-check scores, passes, violations, warnings, and framework metadata. Supports single-tenant, multi-tenant (all tenants), and subset modes. Use -MultiTenant to run against all tenants, or pass multiple values to -TenantId. When -OutputPath is specified with multi-tenant, generates an HTML matrix report. .PARAMETER TenantId Tenant(s) to run the assessment against. Accepts numeric ID, GUID, or friendly name. Pass multiple values for subset multi-tenant mode. Optional when -MultiTenant is used (defaults to all tenants). .PARAMETER AssessmentId The assessment to run. Accepts an assessment ID string or a friendly assessment name. .PARAMETER MultiTenant Switch. Runs the assessment against all tenants (or subset if -TenantId also specified). Requires -OutputPath with HTML extension for the matrix report. .PARAMETER OutputPath Optional. File path to export results. Auto-detects format from file extension. Single tenant: HTML (.html) report or CSV (.csv). Multi-tenant: HTML (.html) matrix report or CSV (.csv) with tenant column. Returns System.IO.FileInfo when specified. .PARAMETER OutputType PowerShellObject (default) or JsonObject. JSON uses Depth 100. .EXAMPLE Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" Runs the Copilot Readiness assessment against tenant 144. .EXAMPLE Invoke-InforcerAssessment -TenantId "Contoso" -AssessmentId "Copilot Readiness" -OutputPath ./report.html Generates an interactive HTML assessment report. .EXAMPLE Invoke-InforcerAssessment -AssessmentId "Copilot Readiness" -MultiTenant -OutputPath ./matrix.html Runs the assessment against all tenants and generates a matrix comparison report. .EXAMPLE Invoke-InforcerAssessment -TenantId "Contoso","Fabrikam","Woodgrove" -AssessmentId "Copilot Readiness" -OutputPath ./matrix.html Runs the assessment against specific tenants and generates a matrix report. .EXAMPLE Invoke-InforcerAssessment -TenantId 144 -AssessmentId "Copilot Readiness" -OutputPath ./report.csv Exports assessment results to CSV. .OUTPUTS PSObject, String, or System.IO.FileInfo .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#invoke-inforcerassessment .LINK Connect-Inforcer .LINK Get-InforcerAssessment #> function Invoke-InforcerAssessment { [CmdletBinding()] [OutputType([PSObject], [string], [System.IO.FileInfo])] param( [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [Alias('ClientTenantId')] [object[]]$TenantId, [Parameter(Mandatory = $true)] [string]$AssessmentId, [Parameter(Mandatory = $false)] [switch]$MultiTenant, [Parameter(Mandatory = $false)] [string]$OutputPath, [Parameter(Mandatory = $false)] [ValidateSet('PowerShellObject', 'JsonObject')] [string]$OutputType = 'PowerShellObject' ) if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' -ErrorId 'NotConnected' -Category ConnectionError return } # Validate: single-tenant requires -TenantId $isMultiTenant = $MultiTenant.IsPresent -or ($null -ne $TenantId -and @($TenantId).Count -gt 1) if (-not $isMultiTenant -and ($null -eq $TenantId -or @($TenantId).Count -eq 0)) { Write-Error -Message 'TenantId is required for single-tenant mode. Use -MultiTenant to run against all tenants.' -ErrorId 'MissingTenantId' -Category InvalidArgument return } # Fetch tenant list (needed for name resolution and multi-tenant) $tenantData = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject) # Resolve assessment $assessmentData = $null try { $assessmentData = @(Invoke-InforcerApiRequest -Endpoint '/beta/assessments' -Method GET -OutputType PowerShellObject) $resolvedAssessmentId = Resolve-InforcerAssessmentId -AssessmentId $AssessmentId -AssessmentData $assessmentData } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidAssessmentId' -Category InvalidArgument return } # Resolve assessment friendly name $assessmentDisplayName = $AssessmentId foreach ($a in $assessmentData) { if ($a -is [PSObject]) { $idProp = $a.PSObject.Properties['id'] if ($idProp -and $idProp.Value -eq $resolvedAssessmentId) { $np = $a.PSObject.Properties['name'] if ($np -and -not [string]::IsNullOrWhiteSpace($np.Value)) { $assessmentDisplayName = $np.Value } break } } } # Helper: resolve tenant friendly name from tenant data $resolveTenantName = { param([int]$cid, [array]$data) foreach ($t in $data) { if ($t -is [PSObject]) { $cidProp = $t.PSObject.Properties['clientTenantId'] if ($cidProp -and [int]$cidProp.Value -eq $cid) { $fn = $t.PSObject.Properties['tenantFriendlyName'] $dn = $t.PSObject.Properties['tenantDnsName'] if ($fn -and -not [string]::IsNullOrWhiteSpace($fn.Value) -and $fn.Value -ne 'Tenant') { return $fn.Value } if ($dn -and -not [string]::IsNullOrWhiteSpace($dn.Value)) { return $dn.Value } return "$cid" } } } return "$cid" } # Helper: process raw API results into enriched check objects $placeholderNames = @('[Multiple Objects Evaluated]', '[unknown id]', '[unknown name]') $processResults = { param([array]$results) $checks = [System.Collections.Generic.List[object]]::new() foreach ($r in $results) { if (-not ($r -is [PSObject])) { continue } $fp = $r.PSObject.Properties['findings'] $isCompliant = $false; $findingsMessage = '' if ($fp -and $fp.Value -is [PSObject]) { $cp = $fp.Value.PSObject.Properties['compliant'] if ($cp) { $isCompliant = $cp.Value -eq $true } $mp = $fp.Value.PSObject.Properties['message'] if ($mp) { $findingsMessage = $mp.Value } } $statusText = if ($isCompliant) { 'Pass' } else { 'Fail' } $allPasses = [System.Collections.Generic.List[string]]::new() $allViolations = [System.Collections.Generic.List[string]]::new() $allWarnings = [System.Collections.Generic.List[string]]::new() $objectsEvaluated = 0; $scoresArray = @() if ($fp -and $fp.Value -is [PSObject]) { $scoresProp = $fp.Value.PSObject.Properties['scores'] if ($scoresProp -and $scoresProp.Value -is [array]) { $objectsEvaluated = $scoresProp.Value.Count $scoresArray = $scoresProp.Value foreach ($s in $scoresProp.Value) { if (-not ($s -is [PSObject])) { continue } $objName = '' $onp = $s.PSObject.Properties['objectName'] if ($onp -and $onp.Value -and $onp.Value -notin $placeholderNames) { $objName = $onp.Value } $prefix = if ($objName) { "$objName — " } else { '' } $pp = $s.PSObject.Properties['passes'] if ($pp -and $pp.Value -is [array]) { foreach ($p in $pp.Value) { if ($p) { [void]$allPasses.Add("${prefix}$p") } } } $vp = $s.PSObject.Properties['violations'] if ($vp -and $vp.Value -is [array]) { foreach ($v in $vp.Value) { if ($v) { [void]$allViolations.Add("${prefix}$v") } } } $wp = $s.PSObject.Properties['warnings'] if ($wp -and $wp.Value -is [array]) { foreach ($w in $wp.Value) { if ($w) { [void]$allWarnings.Add("${prefix}$w") } } } } } } $r | Add-Member -NotePropertyName 'Status' -NotePropertyValue $statusText -Force $r | Add-Member -NotePropertyName 'ObjectsEvaluated' -NotePropertyValue $objectsEvaluated -Force $r | Add-Member -NotePropertyName 'FindingsMessage' -NotePropertyValue $findingsMessage -Force $r | Add-Member -NotePropertyName 'Scores' -NotePropertyValue $scoresArray -Force $r | Add-Member -NotePropertyName 'Violations' -NotePropertyValue @($allViolations) -Force $r | Add-Member -NotePropertyName 'Warnings' -NotePropertyValue @($allWarnings) -Force $r | Add-Member -NotePropertyName 'Passes' -NotePropertyValue @($allPasses) -Force $r.PSObject.TypeNames.Insert(0, 'InforcerCommunity.AssessmentCheck') [void]$checks.Add($r) } return ,$checks } # ── MULTI-TENANT MODE ── if ($isMultiTenant) { # Build list of tenants to run against $tenantsToRun = [System.Collections.Generic.List[object]]::new() if ($null -ne $TenantId -and @($TenantId).Count -gt 0) { # Resolve each specified tenant foreach ($tid in @($TenantId)) { try { $cid = Resolve-InforcerTenantId -TenantId $tid -TenantData $tenantData $tname = & $resolveTenantName $cid $tenantData [void]$tenantsToRun.Add(@{ Id = $cid; Name = $tname }) } catch { Write-Warning "Skipping tenant '$tid': $($_.Exception.Message)" } } } else { # All tenants foreach ($t in $tenantData) { if ($t -is [PSObject]) { $cidProp = $t.PSObject.Properties['clientTenantId'] if ($cidProp) { $cid = [int]$cidProp.Value $tname = & $resolveTenantName $cid $tenantData [void]$tenantsToRun.Add(@{ Id = $cid; Name = $tname }) } } } } if ($tenantsToRun.Count -eq 0) { Write-Error -Message 'No tenants found to run assessment against.' -ErrorId 'NoTenants' -Category ObjectNotFound return } # Format elapsed time as human-readable $fmtTime = { param([double]$totalSeconds) if ($totalSeconds -ge 60) { $mins = [math]::Floor($totalSeconds / 60) $secs = [math]::Round($totalSeconds % 60) return "${mins}m ${secs}s" } return "$([math]::Round($totalSeconds, 1))s" } Write-Host "" Write-Host "Multi-tenant assessment: '$assessmentDisplayName' across $($tenantsToRun.Count) tenant(s)" -ForegroundColor Cyan Write-Host "" # Run assessment for each tenant $totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $allTenantResults = [System.Collections.Generic.List[object]]::new() $tenantIndex = 0 foreach ($tenant in $tenantsToRun) { $tenantIndex++ Write-Host "[$tenantIndex/$($tenantsToRun.Count)] " -NoNewline $response = Invoke-InforcerAssessmentRun ` -ClientTenantId $tenant.Id ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenant.Name ` -AssessmentDisplayName $assessmentDisplayName if ($null -eq $response) { Write-Warning " No results for $($tenant.Name) — skipping." continue } $resultsProp = $response.PSObject.Properties['results'] $results = if ($resultsProp -and $resultsProp.Value -is [array]) { $resultsProp.Value } else { @() } $checks = & $processResults $results # Compute score $passed = 0; foreach ($c in $checks) { if ($c.Status -eq 'Pass') { $passed++ } } $tScore = if ($checks.Count -gt 0) { [math]::Round(($passed / $checks.Count) * 100, 1) } else { 0 } [void]$allTenantResults.Add(@{ TenantId = $tenant.Id TenantName = $tenant.Name Checks = @($checks) Score = $tScore Passed = $passed Failed = $checks.Count - $passed TotalChecks = $checks.Count }) } $totalStopwatch.Stop() $totalTimeStr = & $fmtTime $totalStopwatch.Elapsed.TotalSeconds Write-Host "" Write-Host "All assessments complete. $($allTenantResults.Count) tenant(s) processed in $totalTimeStr." -ForegroundColor Green # Summary per tenant foreach ($tr in $allTenantResults) { $color = if ($tr.Score -ge 90) { 'Green' } elseif ($tr.Score -ge 70) { 'Yellow' } else { 'Red' } Write-Host " $($tr.TenantName) — $($tr.Score)% ($($tr.Passed)/$($tr.TotalChecks))" -ForegroundColor $color } Write-Host "" # JsonObject output for automation if ($OutputType -eq 'JsonObject') { $jsonOutput = [System.Collections.Generic.List[object]]::new() foreach ($tr in $allTenantResults) { [void]$jsonOutput.Add([PSCustomObject]@{ TenantName = $tr.TenantName TenantId = $tr.TenantId ComplianceScore = $tr.Score TotalChecks = $tr.TotalChecks Passed = $tr.Passed Failed = $tr.Failed Checks = @($tr.Checks | ForEach-Object { [PSCustomObject]@{ Name = $_.name Category = $_.category SubCategory = $_.subCategory Importance = $_.importance Status = $_.Status ObjectsEvaluated = $_.ObjectsEvaluated FindingsMessage = $_.FindingsMessage Violations = $_.Violations Warnings = $_.Warnings Passes = $_.Passes } }) }) } ConvertTo-Json -InputObject @($jsonOutput) -Depth 100 return } # Export if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $resolvedPath = [System.IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)) $ext = [System.IO.Path]::GetExtension($resolvedPath).ToLower() if ($ext -eq '.html' -or $ext -eq '.htm') { $html = ConvertTo-InforcerAssessmentMatrixHtml ` -AssessmentName $assessmentDisplayName ` -TenantResults @($allTenantResults) $html | Set-Content -Path $resolvedPath -Encoding UTF8 Write-Host "Matrix HTML report saved to: $resolvedPath" } elseif ($ext -eq '.csv') { $csvRows = [System.Collections.Generic.List[object]]::new() foreach ($tr in $allTenantResults) { foreach ($c in $tr.Checks) { [void]$csvRows.Add([PSCustomObject]@{ Tenant = $tr.TenantName TenantId = $tr.TenantId Status = $c.Status Name = $c.name Category = $c.category SubCategory = $c.subCategory Importance = $c.importance ObjectsEvaluated = $c.ObjectsEvaluated FindingsMessage = $c.FindingsMessage Violations = ($c.Violations -join '; ') Warnings = ($c.Warnings -join '; ') Passes = ($c.Passes -join '; ') }) } } $csvRows | Export-Csv -Path $resolvedPath -NoTypeInformation -Encoding utf8NoBOM Write-Host "CSV report saved to: $resolvedPath" } else { Write-Error -Message "Unsupported file extension '$ext'. Use .html or .csv." -ErrorId 'UnsupportedFormat' -Category InvalidArgument return } [System.IO.FileInfo]::new($resolvedPath) return } # Pipeline output: emit checks with TenantName added foreach ($tr in $allTenantResults) { foreach ($c in $tr.Checks) { $c | Add-Member -NotePropertyName 'TenantName' -NotePropertyValue $tr.TenantName -Force $c | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue $tr.TenantId -Force $c } } return } # ── SINGLE-TENANT MODE ── $singleTenantId = @($TenantId)[0] try { $clientTenantId = Resolve-InforcerTenantId -TenantId $singleTenantId -TenantData $tenantData } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } $tenantDisplayName = & $resolveTenantName $clientTenantId $tenantData $response = Invoke-InforcerAssessmentRun ` -ClientTenantId $clientTenantId ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenantDisplayName ` -AssessmentDisplayName $assessmentDisplayName if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { $response | ConvertTo-Json -Depth 100 return } # Compute summary $resultsProp = $response.PSObject.Properties['results'] $results = if ($resultsProp -and $resultsProp.Value -is [array]) { $resultsProp.Value } else { @() } $processedChecks = & $processResults $results $totalChecks = $processedChecks.Count $compliantCount = 0; foreach ($c in $processedChecks) { if ($c.Status -eq 'Pass') { $compliantCount++ } } $nonCompliantCount = $totalChecks - $compliantCount $score = if ($totalChecks -gt 0) { [math]::Round(($compliantCount / $totalChecks) * 100, 1) } else { 0 } Write-Host "" Write-Host " $assessmentDisplayName — ${score}% compliant ($compliantCount/$totalChecks checks passed)" -ForegroundColor $(if ($score -eq 100) { 'Green' } elseif ($score -ge 75) { 'Yellow' } else { 'Red' }) Write-Host "" # Export or emit if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $resolvedPath = [System.IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)) $ext = [System.IO.Path]::GetExtension($resolvedPath).ToLower() if ($ext -eq '.html' -or $ext -eq '.htm') { $html = ConvertTo-InforcerAssessmentHtml ` -AssessmentName $assessmentDisplayName ` -TenantName $tenantDisplayName ` -Checks @($processedChecks) ` -Score $score ` -TotalChecks $totalChecks ` -Passed $compliantCount ` -Failed $nonCompliantCount $html | Set-Content -Path $resolvedPath -Encoding UTF8 Write-Host "HTML report saved to: $resolvedPath" } elseif ($ext -eq '.csv') { $csvRows = foreach ($c in $processedChecks) { [PSCustomObject]@{ Status = $c.Status Name = $c.name Category = $c.category SubCategory = $c.subCategory Importance = $c.importance ObjectsEvaluated = $c.ObjectsEvaluated FindingsMessage = $c.FindingsMessage Violations = ($c.Violations -join '; ') Warnings = ($c.Warnings -join '; ') Passes = ($c.Passes -join '; ') } } $csvRows | Export-Csv -Path $resolvedPath -NoTypeInformation -Encoding utf8NoBOM Write-Host "CSV report saved to: $resolvedPath" } else { Write-Error -Message "Unsupported file extension '$ext'. Use .html or .csv." -ErrorId 'UnsupportedFormat' -Category InvalidArgument return } [System.IO.FileInfo]::new($resolvedPath) return } foreach ($c in $processedChecks) { $c } } |