Skip to content

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-MgGraph without -Environment against 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 -All switch handles paging. If you see HTTP 429, add a Start-Sleep between 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 releaseChannel has changed between module versions. If the value reports as Unknown, 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 -WhatIf switch 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