Control 2.10: Patch Management and System Updates — PowerShell Setup
Read the FSI PowerShell baseline first
Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. The patterns below assume the baseline has been read; module versions shown are illustrative and must be confirmed against your CAB-approved baseline.
Automation companion to Control 2.10: Patch Management and System Updates.
Audience: Power Platform Admins and AI Administrators executing controls in production tenants subject to FINRA / SEC / GLBA / OCC / Fed SR 11-7 / CFTC oversight.
Prerequisites
| Requirement | Notes |
|---|---|
| PowerShell 7.2 LTS or 7.4 | Required for Microsoft.Graph modules used here |
| Microsoft.Graph (pinned) | Replace <version> below with the version approved by your Change Advisory Board (CAB) |
| Az.ResourceGraph (pinned) | Used to enumerate environments under Service Health alert scope |
| Microsoft.PowerApps.Administration.PowerShell (pinned) | Windows PowerShell 5.1 only — required to read environment release channel settings |
| Graph permissions | ServiceMessage.Read.All (delegated or application) — required for Message Center reads. Application permission requires Entra Global Admin consent. |
| Sovereign cloud | Confirm the correct Connect-MgGraph -Environment value before first run. See the baseline section 3. |
Canonical install pattern
# Pin every module to a CAB-approved version. DO NOT use -Force without -RequiredVersion.
Install-Module -Name Microsoft.Graph `
-RequiredVersion '<version>' `
-Repository PSGallery `
-Scope CurrentUser `
-AllowClobber `
-AcceptLicense
Install-Module -Name Az.ResourceGraph `
-RequiredVersion '<version>' `
-Repository PSGallery `
-Scope CurrentUser
# Desktop edition only — required for Power Apps admin cmdlets
# Run from Windows PowerShell 5.1 host, NOT PowerShell 7
Install-Module -Name Microsoft.PowerApps.Administration.PowerShell `
-RequiredVersion '<version>' `
-Repository PSGallery `
-Scope CurrentUser
Sovereign-Cloud Authentication Helper
Reused by every script in this playbook. Save as Connect-FsiTenant.ps1 and dot-source.
function Connect-FsiTenant {
[CmdletBinding()]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud = 'Commercial',
[string[]]$Scopes = @('ServiceMessage.Read.All')
)
$envMap = @{
Commercial = 'Global'
GCC = 'USGov'
GCCHigh = 'USGovDoD'
DoD = 'USGovDoD'
}
Connect-MgGraph -Environment $envMap[$Cloud] -Scopes $Scopes -NoWelcome
Write-Verbose "Connected to Microsoft Graph in environment: $($envMap[$Cloud])"
}
False-clean warning: Running
Connect-MgGraphwithout-Environmentagainst a GCC High tenant authenticates against commercial endpoints, returns zero messages, and produces false-clean evidence. The helper above prevents this.
Evidence Emission Helper
Implements the SHA-256 manifest pattern from baseline section 5. Save as Write-FsiEvidence.ps1 and dot-source in every script below.
function Write-FsiEvidence {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Object,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$EvidencePath,
[string]$ScriptVersion = '1.0.0'
)
if (-not (Test-Path $EvidencePath)) {
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
}
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$jsonPath = Join-Path $EvidencePath "$Name-$ts.json"
$Object | ConvertTo-Json -Depth 20 | Set-Content -Path $jsonPath -Encoding UTF8
$hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
$manifestPath = Join-Path $EvidencePath 'manifest.json'
$manifest = @()
if (Test-Path $manifestPath) {
$manifest = @(Get-Content $manifestPath -Raw | ConvertFrom-Json)
}
$manifest += [PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = $hash
bytes = (Get-Item $jsonPath).Length
generated_utc = $ts
script_version = $ScriptVersion
control_id = '2.10'
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
return $jsonPath
}
Script 1: Pull Message Center Posts (Read-Only)
Uses Microsoft Graph Service Communications API. Filters server-side to reduce throttling risk on large tenants.
<#
.SYNOPSIS
Retrieves recent Message Center posts for AI-relevant services and emits SHA-256-hashed evidence.
.PARAMETER Days
Lookback window in days. Default 30.
.PARAMETER Cloud
Sovereign-cloud designation. Defaults to Commercial.
.PARAMETER EvidencePath
Folder for hashed JSON evidence and manifest. Created if missing.
.EXAMPLE
.\Get-MessageCenterPosts.ps1 -Days 14 -Cloud GCCHigh -EvidencePath .\evidence\2.10
#>
[CmdletBinding()]
param(
[int]$Days = 30,
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud = 'Commercial',
[string]$EvidencePath = ".\evidence\2.10"
)
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\Connect-FsiTenant.ps1"
. "$PSScriptRoot\Write-FsiEvidence.ps1"
Connect-FsiTenant -Cloud $Cloud -Scopes 'ServiceMessage.Read.All'
# AI-relevant Microsoft 365 services. The Services array on each post is free-text;
# normalize by checking against an allow-list rather than relying on $filter for membership.
$relevantServices = @(
'Microsoft Copilot',
'Microsoft Copilot Studio',
'Power Platform',
'Power Automate',
'Power Apps',
'Microsoft Dataverse',
'SharePoint Online',
'Microsoft Purview'
)
$startUtc = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('o')
# Server-side filter on lastModifiedDateTime; client-side filter on Services membership.
$filter = "lastModifiedDateTime ge $startUtc"
$messages = Get-MgServiceAnnouncementMessage -Filter $filter -All
$relevant = $messages | Where-Object {
$_.Services -and ($_.Services | Where-Object { $relevantServices -contains $_ })
}
Write-Host ("[INFO] Total messages in window: {0}; relevant to AI estate: {1}" -f $messages.Count, $relevant.Count)
$snapshot = $relevant | ForEach-Object {
[PSCustomObject]@{
MessageId = $_.Id
Title = $_.Title
Services = ($_.Services -join '; ')
Category = $_.Category
Severity = $_.Severity
IsMajorChange = $_.IsMajorChange
StartDateTime = $_.StartDateTime
EndDateTime = $_.EndDateTime
ActionRequiredBy = $_.ActionRequiredByDateTime
LastModifiedDateTime = $_.LastModifiedDateTime
Tags = ($_.Tags -join '; ')
WebUrl = $_.Details |
Where-Object { $_.Name -eq 'ExternalLink' } |
Select-Object -ExpandProperty Value -ErrorAction SilentlyContinue
}
}
$evidenceFile = Write-FsiEvidence -Object $snapshot -Name 'message-center-posts' -EvidencePath $EvidencePath
Write-Host "[PASS] Evidence written: $evidenceFile" -ForegroundColor Green
Disconnect-MgGraph | Out-Null
Throttling note: The Service Communications API returns up to 100 messages per page and is subject to Microsoft Graph throttling. The
-Allswitch handles paging. If you see HTTP 429, add aStart-Sleepbetween pages or schedule the job off-peak.
Script 2: Read Environment Release Channel Settings
Confirms each Power Platform environment's release channel matches the policy from Part 3 of the Portal Walkthrough.
PowerShell edition guard: The Power Apps Admin module is Desktop-only (Windows PowerShell 5.1). Run this script from a Windows PowerShell 5.1 host, not PowerShell 7. The script enforces this at the top.
<#
.SYNOPSIS
Reports release channel and managed-environment status for all environments.
Read-only; emits SHA-256-hashed evidence.
.PARAMETER Endpoint
Power Platform endpoint per sovereign cloud (prod | usgov | usgovhigh | dod).
.PARAMETER EvidencePath
Folder for evidence output.
.EXAMPLE
.\Get-EnvironmentReleaseChannels.ps1 -Endpoint usgovhigh -EvidencePath .\evidence\2.10
#>
[CmdletBinding()]
param(
[ValidateSet('prod','usgov','usgovhigh','dod')]
[string]$Endpoint = 'prod',
[string]$EvidencePath = ".\evidence\2.10"
)
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -ne 'Desktop') {
throw "Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1 (Desktop edition). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Re-run from a 5.1 host."
}
. "$PSScriptRoot\Write-FsiEvidence.ps1"
Add-PowerAppsAccount -Endpoint $Endpoint | Out-Null
$envs = Get-AdminPowerAppEnvironment
$report = $envs | ForEach-Object {
# ReleaseChannel is exposed under the environment Settings; property name has varied
# across module versions. Probe the common shapes and fall back to "Unknown".
$channel = $null
if ($_.Internal.properties.releaseChannel) {
$channel = $_.Internal.properties.releaseChannel
} elseif ($_.Internal.properties.environmentSku) {
# Some module versions surface only via environment description
$channel = '(read via PPAC UI)'
} else {
$channel = 'Unknown'
}
[PSCustomObject]@{
EnvironmentName = $_.EnvironmentName
DisplayName = $_.DisplayName
EnvironmentType = $_.EnvironmentType
IsManaged = $_.Internal.properties.governanceConfiguration.protectionLevel -eq 'Standard'
DataverseState = $_.CommonDataServiceDatabaseProvisioningState
ReleaseChannel = $channel
Region = $_.Location
CreatedTime = $_.CreatedTime
}
}
$report | Format-Table -AutoSize
$evidenceFile = Write-FsiEvidence -Object $report -Name 'environment-release-channels' -EvidencePath $EvidencePath
Write-Host "[PASS] Evidence written: $evidenceFile" -ForegroundColor Green
Module compatibility note: The exact JSON path for
releaseChannelhas changed between module versions. If the value reports asUnknown, treat the PPAC UI as authoritative and document the module version mismatch in your validation evidence.
Script 3: Validate Service Health Alert Coverage
Read-only audit that the alert rules from Part 2 of the Portal Walkthrough exist and are enabled for the right resource providers.
<#
.SYNOPSIS
Validates Azure Service Health alert rule coverage for AI-platform resource providers.
.PARAMETER SubscriptionIds
Subscriptions to audit.
.PARAMETER EvidencePath
Folder for evidence output.
.EXAMPLE
.\Test-ServiceHealthAlertCoverage.ps1 -SubscriptionIds @('00000000-0000-0000-0000-000000000000') -EvidencePath .\evidence\2.10
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string[]]$SubscriptionIds,
[string]$EvidencePath = ".\evidence\2.10"
)
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\Write-FsiEvidence.ps1"
if (-not (Get-AzContext)) {
Connect-AzAccount -ErrorAction Stop | Out-Null
}
$expectedProviders = @(
'Microsoft.PowerPlatform',
'Microsoft.PowerApps',
'Microsoft.Flow',
'Microsoft.KeyVault',
'Microsoft.Insights'
)
$findings = foreach ($subId in $SubscriptionIds) {
Set-AzContext -SubscriptionId $subId | Out-Null
$alerts = Get-AzActivityLogAlert | Where-Object {
$_.ConditionAllOf.Field -contains 'category' -and
($_.ConditionAllOf | Where-Object { $_.Field -eq 'category' -and $_.Equals -eq 'ServiceHealth' })
}
$coveredProviders = @()
foreach ($alert in $alerts) {
$providerCondition = $alert.ConditionAllOf | Where-Object { $_.Field -eq 'properties.impactedServices[*].ServiceName' }
if ($providerCondition) {
$coveredProviders += $providerCondition.Equals
}
}
foreach ($provider in $expectedProviders) {
[PSCustomObject]@{
SubscriptionId = $subId
ResourceProvider = $provider
HasAlertRule = ($coveredProviders -contains $provider) -or ($alerts.Count -gt 0)
AlertRuleCount = $alerts.Count
Status = if ($coveredProviders -contains $provider -or $alerts.Count -gt 0) { 'PASS' } else { 'FAIL' }
}
}
}
$findings | Format-Table -AutoSize
$evidenceFile = Write-FsiEvidence -Object $findings -Name 'service-health-alert-coverage' -EvidencePath $EvidencePath
Write-Host "[PASS] Evidence written: $evidenceFile" -ForegroundColor Green
Script 4: Compose the Patch History Log
Combines outputs from Scripts 1, 2, and 3 into a single CSV suitable for examiner review and SharePoint upload to FSI-Patch-Evidence.
<#
.SYNOPSIS
Joins Message Center posts, environment channel state, and alert coverage into one
examiner-ready CSV with a SHA-256 manifest entry.
.PARAMETER EvidencePath
Folder containing previously emitted JSON evidence.
.EXAMPLE
.\Export-PatchHistory.ps1 -EvidencePath .\evidence\2.10
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$EvidencePath
)
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$mcLatest = Get-ChildItem $EvidencePath -Filter 'message-center-posts-*.json' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $mcLatest) { throw "No message-center-posts-*.json found in $EvidencePath. Run Script 1 first." }
$posts = Get-Content $mcLatest.FullName -Raw | ConvertFrom-Json
$report = $posts | ForEach-Object {
[PSCustomObject]@{
MessageId = $_.MessageId
Title = $_.Title
Services = $_.Services
Category = $_.Category
IsMajorChange = $_.IsMajorChange
ActionRequiredBy = $_.ActionRequiredBy
AssessmentOwner = '' # Filled in by AI Governance Lead during triage
AssessmentResult = '' # Pass | Fail | N/A
ValidationRunId = '' # Link to validation environment test run
DeploymentDate = '' # Production deployment timestamp
ApproverUPN = '' # Person principal name of CAB approver
EvidenceLink = '' # SharePoint URL of validation artifacts
Status = 'Open'
}
}
$csv = Join-Path $EvidencePath ("patch-history-{0}.csv" -f (Get-Date -Format 'yyyyMMdd'))
$report | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8
# Hash the CSV and append to manifest
$hash = (Get-FileHash -Path $csv -Algorithm SHA256).Hash
$manifestPath = Join-Path $EvidencePath 'manifest.json'
$manifest = if (Test-Path $manifestPath) { @(Get-Content $manifestPath -Raw | ConvertFrom-Json) } else { @() }
$manifest += [PSCustomObject]@{
file = (Split-Path $csv -Leaf)
sha256 = $hash
bytes = (Get-Item $csv).Length
generated_utc = (Get-Date -Format 'yyyyMMddTHHmmssZ')
script_version = '1.0.0'
control_id = '2.10'
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
Write-Host "[PASS] Patch history exported: $csv" -ForegroundColor Green
Validation Script (End-to-End)
<#
.SYNOPSIS
Runs all read-only validation checks for Control 2.10 and exits non-zero on failure.
Safe to schedule; does not mutate tenant state.
.EXAMPLE
.\Validate-Control-2.10.ps1 -Cloud Commercial -EvidencePath .\evidence\2.10 -SubscriptionIds @('...')
#>
[CmdletBinding()]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud = 'Commercial',
[string]$EvidencePath = ".\evidence\2.10",
[Parameter(Mandatory)] [string[]]$SubscriptionIds
)
$ErrorActionPreference = 'Stop'
$failed = $false
Write-Host "=== Control 2.10 Validation ===" -ForegroundColor Cyan
try {
& "$PSScriptRoot\Get-MessageCenterPosts.ps1" -Days 30 -Cloud $Cloud -EvidencePath $EvidencePath
} catch {
Write-Host "[FAIL] Message Center pull: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
}
try {
& "$PSScriptRoot\Test-ServiceHealthAlertCoverage.ps1" -SubscriptionIds $SubscriptionIds -EvidencePath $EvidencePath
} catch {
Write-Host "[FAIL] Service Health coverage: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
}
# Environment release channel check requires Windows PowerShell 5.1; skip with INFO if running PS7+.
if ($PSVersionTable.PSEdition -eq 'Desktop') {
try {
$endpoint = @{Commercial='prod'; GCC='usgov'; GCCHigh='usgovhigh'; DoD='dod'}[$Cloud]
& "$PSScriptRoot\Get-EnvironmentReleaseChannels.ps1" -Endpoint $endpoint -EvidencePath $EvidencePath
} catch {
Write-Host "[FAIL] Environment release channel read: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
}
} else {
Write-Host "[INFO] Skipping environment release channel check (requires Windows PowerShell 5.1)" -ForegroundColor Yellow
}
if ($failed) {
Write-Host "=== Validation FAILED ===" -ForegroundColor Red
exit 1
}
Write-Host "=== Validation PASS ===" -ForegroundColor Green
Scheduled-Run Recommendations
| Script | Schedule | Run as |
|---|---|---|
Get-MessageCenterPosts.ps1 |
Daily 06:00 local | Service principal with ServiceMessage.Read.All (application permission, Global Admin consent) |
Test-ServiceHealthAlertCoverage.ps1 |
Weekly | Service principal with Reader on each subscription |
Get-EnvironmentReleaseChannels.ps1 |
Weekly, Windows PowerShell 5.1 runner | Power Platform Admin service account (no MFA-blocked) |
Validate-Control-2.10.ps1 |
Monthly + before each wave window | Same as above |
Mutation safety note: None of the scripts in this playbook mutate tenant state. There is no
-WhatIfswitch because there is nothing to confirm. If you extend these scripts to change settings (e.g., flip a release channel programmatically once that capability stabilizes), wrap mutations in[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]and follow baseline section 4.
Back to Control 2.10 | Portal Walkthrough | Verification Testing | Troubleshooting
Updated: April 2026 | Version: v1.4.0