Public/Get-AACChangeHistory.ps1
|
function Get-AACChangeHistory { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS What changed in Azure, who changed it, when and from where - with the properties before and after, how to undo it, and the alerts and health events that followed - a timeline for incident response, change control and audits. .DESCRIPTION Reads, for the window (-Hours, or -StartTime and -EndTime): Activity Log every subscription's administrative operations (writes, deletes, actions), one change per correlation ID: the caller, client IP, operation and how it ended Resource changes Resource Graph's property-level changes (up to 14 days back): each property's value before and after Incidents alerts fired and resources Resource Health reports unavailable or degraded Each change (AAC.ChangeRecord) has: a risk High for deletes, access (role assignments, locks, key vault access policies), network security, Azure Policy, keys read and diagnostic settings removed; Medium for other network changes, SKU or size changes and restarts; Low otherwise - and at least High when an incident followed it an origin Manual (a person), Automation (an app, identity or pipeline) or Azure - manual changes are the ones made outside infrastructure as code a revert how to undo it: set the properties back (their before values), delete what was created, recreate what was deleted - and how much effort that is incidents alerts on the resource or its resource group, and Resource Health events on it, within -CorrelationMinutes (120) after it: possibly caused by it The view is a timeline, newest first; the HTML report adds charts by hour, caller and kind, and the incidents. Read-only; Reader (or Monitoring Reader) on the subscriptions. The Activity Log keeps 90 days; Resource Graph's changes, 14. .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER ResourceGroupName Only changes in these resource groups (wildcards work). .PARAMETER ResourceType Only changes to these resource types, e.g. 'microsoft.network/*'. .PARAMETER Caller Only changes made by these callers - a UPN or an application ID (wildcards work). .PARAMETER Hours How far back to look, in hours (1 to 2160 - 90 days; 24 by default). .PARAMETER StartTime The start of the window, instead of -Hours. .PARAMETER EndTime The end of the window (now by default). .PARAMETER CorrelationMinutes How long after a change an incident counts as possibly caused by it (5 to 1440; 120 by default). .PARAMETER IncludeFailed Keep the operations that failed (left out by default: they changed nothing). .PARAMETER CsvPath Write the changes to this CSV file. .PARAMETER HtmlPath Write an interactive HTML report. .PARAMETER PdfPath Write a PDF report. .PARAMETER Title The reports' title. .PARAMETER PassThru Show the view and also return the changes. .PARAMETER NoDisplay Return the changes without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Get-AACChangeHistory Everything changed in the last 24 hours, newest first. .EXAMPLE Get-AACChangeHistory -ResourceGroupName 'rg-app-prod' -StartTime '2026-10-08 22:00' -EndTime '2026-10-09 02:00' What changed around an outage last night. .EXAMPLE Get-AACChangeHistory -Hours 168 -NoDisplay | Where-Object Origin -EQ 'Manual' | Export-Csv .\ManualChanges.csv -NoTypeInformation A week of changes made by hand - for the change advisory board. .OUTPUTS AAC.ChangeRecord #> [CmdletBinding(DefaultParameterSetName = 'Hours')] [OutputType('AAC.ChangeRecord')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [string[]] $ManagementGroupId, [SupportsWildcards()] [string[]] $ResourceGroupName, [SupportsWildcards()] [string[]] $ResourceType, [SupportsWildcards()] [string[]] $Caller, [Parameter(ParameterSetName = 'Hours')] [ValidateRange(1, 2160)] [int] $Hours = 24, [Parameter(Mandatory, ParameterSetName = 'Window')] [datetime] $StartTime, [Parameter(ParameterSetName = 'Window')] [datetime] $EndTime = [datetime]::Now, [ValidateRange(5, 1440)] [int] $CorrelationMinutes = 120, [switch] $IncludeFailed, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Azure change history', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $to = if ($PSCmdlet.ParameterSetName -eq 'Window') { $EndTime.ToUniversalTime() } else { [datetime]::UtcNow } $from = if ($PSCmdlet.ParameterSetName -eq 'Window') { $StartTime.ToUniversalTime() } else { $to.AddHours(-$Hours) } if ($from -ge $to) { throw "-StartTime ($StartTime) must be before -EndTime ($EndTime)." } if ($from -lt [datetime]::UtcNow.AddDays(-90)) { Write-Warning 'The Activity Log keeps 90 days: changes before that are gone.' } $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); From = $from; To = $to ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); ResourceType = @($ResourceType | Where-Object { $_ }); Caller = @($Caller | Where-Object { $_ }) CorrelationMinutes = $CorrelationMinutes; IncludeFailed = [bool]$IncludeFailed } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Change history' -Color 'deepskyblue3_1' } $state = Invoke-AACProgress -ScriptBlock { Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions' $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)" $iso = { param([datetime] $When) $When.ToString('yyyy-MM-ddTHH:mm:ssZ', [cultureinfo]::InvariantCulture) } # --- The Activity Log, per subscription ------------------------------------------------------------------------ $select = 'caller,eventTimestamp,status,operationName,resourceId,resourceGroupName,correlationId,category,authorization,httpRequest,eventDataId,resourceType' $uris = @{} foreach ($id in $scope.Ids) { $filter = "eventTimestamp ge '$(& $iso $request.From)' and eventTimestamp le '$(& $iso $request.To)'" if ($request.ResourceGroupName.Count -eq 1 -and $request.ResourceGroupName[0] -notmatch '[*?]') { $filter += " and resourceGroupName eq '$($request.ResourceGroupName[0])'" } $uris[$id] = "/subscriptions/$id/providers/Microsoft.Insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$([System.Uri]::EscapeDataString($filter))&`$select=$select" } Update-AACProgress -Id 'activity' -Total ([Math]::Max(1, $uris.Count)) -Description "Reading the Activity Log of $($uris.Count) subscription(s)" $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($LogDone, $LogTotal) Update-AACProgress -Id 'activity' -Increment 1 } $events = [System.Collections.Generic.List[object]]::new() $notices = [System.Collections.Generic.List[string]]::new() foreach ($id in $uris.Keys) { $answer = $answers[$uris[$id]] if (-not $answer -or $answer.Error) { $notices.Add("The Activity Log of $($scope.Names[$id.ToLowerInvariant()]) couldn't be read: $(if ($answer) { $answer.Error } else { 'no answer' })"); continue } foreach ($e in @($answer.Items)) { if ($e) { $events.Add($e) } } } Update-AACProgress -Id 'activity' -Complete -Description ('Read {0:N0} Activity Log event(s)' -f $events.Count) # --- Property changes and incidents, from Resource Graph ------------------------------------------------------------ $changeFrom = if ($request.From -lt [datetime]::UtcNow.AddDays(-14)) { [datetime]::UtcNow.AddDays(-14) } else { $request.From } $between = "between (datetime($(& $iso $changeFrom)) .. datetime($(& $iso $request.To.AddMinutes($request.CorrelationMinutes))))" $queries = [ordered]@{ changes = "resourcechanges | extend p = properties | extend at = todatetime(p.changeAttributes.timestamp) | where at between (datetime($(& $iso $changeFrom)) .. datetime($(& $iso $request.To))) | project id, at, changeType = tostring(p.changeType), resourceId = tolower(tostring(p.targetResourceId)), resourceType = tostring(p.targetResourceType), changedBy = tostring(p.changeAttributes.changedBy), clientType = tostring(p.changeAttributes.clientType), correlationId = tostring(p.changeAttributes.correlationId), changes = p.changes" alerts = "alertsmanagementresources | where type =~ 'microsoft.alertsmanagement/alerts' | extend e = properties.essentials | extend fired = todatetime(e.startDateTime) | where fired $between | project id, fired, name, severity = tostring(e.severity), state = tostring(e.monitorCondition), target = tolower(tostring(e.targetResource)), targetGroup = tolower(tostring(e.targetResourceGroup)), signal = tostring(e.signalType), description = tostring(e.description)" health = "healthresources | where type =~ 'microsoft.resourcehealth/availabilitystatuses' | where tostring(properties.availabilityState) in~ ('Unavailable', 'Degraded') | project id, resourceId = tolower(tostring(properties.targetResourceId)), state = tostring(properties.availabilityState), summary = tostring(properties.summary), since = tostring(properties.occuredTime)" } Update-AACProgress -Id 'graph' -Total $queries.Count -Description 'Reading the property changes, alerts and Resource Health' $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @($queries.Keys) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'graph' -Increment 1 -Description "Read the $Name ($Done of $Total)" } foreach ($key in $read.Errors.Keys) { if ($read.Errors[$key]) { $notices.Add("The $(@{ changes = 'property changes'; alerts = 'alerts'; health = 'Resource Health statuses' }[$key]) couldn't be read: $($read.Errors[$key])") } } Update-AACProgress -Id 'graph' -Complete -Description ('Read {0:N0} property change(s), {1:N0} alert(s), {2:N0} unhealthy resource(s)' -f @($read.Rows['changes']).Count, @($read.Rows['alerts']).Count, @($read.Rows['health']).Count) Update-AACProgress -Id 'history' -Indeterminate -Description 'Building the timeline' $result = ConvertTo-AACChangeHistory -ActivityEvent $events.ToArray() -Change @($read.Rows['changes'] | Where-Object { $_ }) -Alert @($read.Rows['alerts'] | Where-Object { $_ }) -Health @($read.Rows['health'] | Where-Object { $_ }) ` -SubscriptionName $scope.Names -CorrelationMinutes $request.CorrelationMinutes -ResourceGroupName $request.ResourceGroupName -ResourceType $request.ResourceType -Caller $request.Caller -IncludeFailed:$request.IncludeFailed Update-AACProgress -Id 'history' -Complete -Description ('{0:N0} change(s) by {1:N0} caller(s): {2:N0} high risk, {3:N0} manual, {4:N0} followed by an incident' -f $result.Stats.Changes, $result.Stats.Callers, $result.Stats.High, $result.Stats.Manual, $result.Stats.Linked) @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() } } $result = $state.Result $changes = @($result.Changes) $s = $result.Stats $rank = Get-AACSeverityRank $kindTones = @{ Create = 'good'; Update = 'info'; Delete = 'bad'; Action = 'violet' } $local = { param([datetime] $When) $When.ToLocalTime() } $hourly = @($changes | Group-Object -Property { (& $local $_.Time).ToString('yyyy-MM-dd HH:00') } | Sort-Object Name | ForEach-Object { @{ Label = $_.Name.Substring(5); Value = $_.Count } }) $report = @{ Subtitle = "Change history, $((& $local $from).ToString('d MMM HH:mm')) to $((& $local $to).ToString('d MMM yyyy HH:mm'))" Facts = [ordered]@{ Scope = $state.Scope.Label; Window = "$((& $local $from).ToString('d MMM HH:mm')) - $((& $local $to).ToString('d MMM yyyy HH:mm'))"; 'Incidents within' = "$CorrelationMinutes minutes of a change" } Status = $(if ($s.Linked) { 'Failed' } elseif ($s.High -or $s.Deletes) { 'Warning' } else { 'Success' }) Headline = $(if ($s.Changes) { "$($s.Changes) change(s) by $($s.Callers) caller(s): $($s.Deletes) delete(s), $($s.High) high risk, $($s.Manual) made by hand$(if ($s.Linked) { " - $($s.Linked) followed by an alert or health event" })" } else { 'No changes in this window.' }) Tiles = @( @{ Value = '{0:N0}' -f $s.Changes; Label = 'changes'; Tone = 'info'; Table = 'changes' } @{ Value = '{0:N0}' -f $s.High; Label = 'high risk'; Tone = $(if ($s.High) { 'bad' } else { 'good' }); Table = 'changes'; Filters = @{ Severity = 'High' } } @{ Value = '{0:N0}' -f $s.Deletes; Label = 'deletes'; Tone = $(if ($s.Deletes) { 'warn' } else { 'good' }); Table = 'changes'; Filters = @{ Category = 'Delete' } } @{ Value = '{0:N0}' -f $s.Manual; Label = 'made by hand'; Tone = $(if ($s.Manual) { 'warn' } else { 'good' }); Table = 'changes'; Filters = @{ Origin = 'Manual' } } @{ Value = '{0:N0}' -f $s.Linked; Label = 'followed by an incident'; Tone = $(if ($s.Linked) { 'bad' } else { 'good' }) } @{ Value = '{0:N0}' -f $s.Callers; Label = 'callers'; Tone = 'violet' } ) Notices = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) Charts = @( @{ Title = 'Changes by hour'; Items = $hourly; Wide = $true; Tone = 'info' } @{ Title = 'Changes by kind'; Kind = 'donut'; CenterLabel = 'changes'; Items = @($changes | Group-Object Category | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $kindTones[$_.Name]; Filter = $_.Name } }); Table = 'changes'; Column = 'Category' } @{ Title = 'Changes by caller'; Items = @($changes | Group-Object Caller | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(Azure)' }); Value = $_.Count; Filter = $_.Name } }); Table = 'changes'; Column = 'Caller'; Tone = 'violet'; Console = $true } ) Tables = @( @{ Id = 'changes'; Title = 'Timeline'; Section = 'Changes'; Rows = $changes; Noun = 'changes'; GroupBy = @('Category', 'Caller', 'ResourceGroup', 'Severity', 'Origin'); ConsoleLimit = 30 Empty = 'No changes in this window.'; EmptyStatus = 'Info' Columns = @( @{ Key = 'Time'; Label = 'When'; Type = 'datetime'; Console = $true; Pdf = $true } @{ Key = 'Severity'; Label = 'Risk'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Category'; Label = 'Change'; Type = 'badge'; Tones = $kindTones; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true; Console = $true } @{ Key = 'Caller'; Label = 'Caller'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Origin'; Label = 'Origin'; Type = 'badge'; Tones = @{ Manual = 'warn'; Automation = 'good'; Azure = 'neutral' }; Facet = $true } @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = @{ Succeeded = 'good'; Failed = 'bad'; Accepted = 'info'; Started = 'info' }; Facet = $true } @{ Key = 'Detail'; Label = 'Before -> after'; Type = 'wide'; Console = $true; Pdf = $true } @{ Key = 'Impact'; Label = 'Followed by'; Type = 'wide'; Pdf = $true } @{ Key = 'Revert'; Label = 'How to undo it'; Type = 'wide' } @{ Key = 'Effort'; Label = 'Undo effort'; Type = 'badge'; Tones = @{ Low = 'good'; Medium = 'warn'; High = 'bad' }; Facet = $true } @{ Key = 'Operation'; Label = 'Operation'; Type = 'mono' } @{ Key = 'ResourceType'; Label = 'Resource type'; Type = 'mono'; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } @{ Key = 'ClientIp'; Label = 'Client IP'; Type = 'mono' } @{ Key = 'CorrelationId'; Label = 'Correlation ID'; Type = 'mono' } ) } @{ Id = 'incidents'; Title = 'Alerts and health events'; Section = 'Incidents'; Rows = $result.Incidents; Noun = 'incidents'; ConsoleLimit = 10 Columns = @( @{ Key = 'Time'; Label = 'When'; Type = 'datetime'; Console = $true } @{ Key = 'Kind'; Label = 'Kind'; Facet = $true; Console = $true } @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = @{ Sev0 = 'bad'; Sev1 = 'bad'; Sev2 = 'warn'; Sev3 = 'info'; Sev4 = 'neutral' }; Facet = $true; Console = $true } @{ Key = 'Name'; Label = 'Name'; Console = $true } @{ Key = 'Resource'; Label = 'Resource'; Console = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'State'; Label = 'State'; Facet = $true } @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide' } ) } ) Hint = '-Hours, or -StartTime and -EndTime; -ResourceGroupName, -ResourceType, -Caller narrow it; -NoDisplay returns the changes.' } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $changes -Noun 'change' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $changes -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |