Public/Invoke-CredentialRotation.ps1
|
function Invoke-CredentialRotation { <# .SYNOPSIS Reconciles VM credentials against their Key Vault expiry dates. .DESCRIPTION One pass over the estate: 1. ask Log Analytics which secrets a human read, and pull those expiry dates forward (optional, requires the observability module) 2. find every credential that is missing, expiring or half-rotated 3. rotate it 4. write a record of what happened There is no event subscription and no queue. The run is the retry: anything that fails or is skipped - a stopped VM, a throttled call, an unhealthy guest agent - is simply picked up next time. That is what makes the whole thing small enough to reason about. Latency is the trade. A credential read at 09:00 with a six-hourly schedule and an eight-hour grace period is replaced some time before 23:00, not within minutes. For credentials that would otherwise sit unchanged for months, that is not a meaningful difference. If it is for you, see docs/decisions/0002-reconciliation-loop-over-events.md, which describes what an event-driven version would need. .PARAMETER SubscriptionId Subscriptions to process. Defaults to the current context only - deliberately narrow, so an unscoped run cannot reach further than intended. .EXAMPLE Invoke-CredentialRotation -VaultName kv-creds -WhatIf Reports what would be rotated without touching anything. Always the first run. .OUTPUTS PSCustomObject summarising the run, with the individual records attached. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$VaultName, [string[]]$SubscriptionId, [ValidateRange(0, 3650)][int]$ThresholdDays = 14, [ValidateRange(1, 3650)][int]$ValidityDays = 90, [string]$EnableTagName = 'CredentialRotation', [string]$EnableTagValue = 'enabled', [string]$HoldTagName = 'CredentialRotationHold', [switch]$SkipSshKeys, [switch]$RemovePriorSshKeys, [switch]$ResetSshConfiguration, # Access-triggered rotation. Without a workspace, only expiry drives rotation. [string]$WorkspaceId, [ValidateRange(0, 168)][int]$GracePeriodHours = 8, [ValidateRange(1, 720)][int]$AccessLookbackHours = 24, [string[]]$ExcludeObjectId = @(), # Structured audit records. Without these, the job output is the only trail. [string]$DataCollectionEndpoint, [string]$DataCollectionRuleId, [string]$StreamName = 'Custom-CredentialRotation_CL', [string]$TriggeredBy ) $startTime = Get-Date $records = [System.Collections.Generic.List[object]]::new() $stats = [ordered]@{ Candidates = 0 Rotated = 0 Skipped = 0 Failed = 0 AccessMarked = 0 } Write-RotationLog -Message '=== Credential rotation started ===' -Level Info Write-RotationLog -Message "Vault: $VaultName | threshold: $ThresholdDays d | validity: $ValidityDays d | access-driven: $([bool]$WorkspaceId)" -Level Info if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { throw 'No Azure context. Connect with Connect-AzAccount -Identity before calling this function.' } # --- 1. access-driven expiry updates ------------------------------------ if ($WorkspaceId) { try { $marked = Register-CredentialAccess -VaultName $VaultName -WorkspaceId $WorkspaceId ` -GracePeriodHours $GracePeriodHours -LookbackHours $AccessLookbackHours ` -ExcludeObjectId $ExcludeObjectId -HoldTagName $HoldTagName $stats.AccessMarked = @($marked).Count } catch { # A workspace problem must not stop expiry-driven rotation. Write-RotationLog -Message "Access scan failed, continuing with expiry-driven rotation only: $($_.Exception.Message)" -Level Error -Scope 'access' $stats.Failed++ } } # --- 2 & 3. find and rotate --------------------------------------------- $subscriptions = if ($SubscriptionId) { $SubscriptionId } else { @((Get-AzContext).Subscription.Id) } foreach ($sub in $subscriptions) { Write-RotationLog -Message "--- Subscription $sub ---" -Level Info try { $null = Set-AzContext -SubscriptionId $sub -ErrorAction Stop } catch { Write-RotationLog -Message "Cannot switch to subscription ${sub}: $($_.Exception.Message)" -Level Error $stats.Failed++ continue } try { $candidates = Get-RotationCandidate -VaultName $VaultName -ThresholdDays $ThresholdDays ` -EnableTagName $EnableTagName -EnableTagValue $EnableTagValue ` -HoldTagName $HoldTagName -SkipSshKeys:$SkipSshKeys } catch { Write-RotationLog -Message "Discovery failed in subscription ${sub}: $($_.Exception.Message)" -Level Error $stats.Failed++ continue } $stats.Candidates += @($candidates).Count Write-RotationLog -Message "$(@($candidates).Count) credential(s) to process" -Level Info foreach ($candidate in $candidates) { try { $record = Update-VMCredential -VaultName $VaultName -VM $candidate.VM ` -CredentialType $candidate.CredentialType -ValidityDays $ValidityDays ` -TriggerReason $candidate.Reason -TriggeredBy $TriggeredBy ` -RemovePriorSshKeys:$RemovePriorSshKeys ` -ResetSshConfiguration:$ResetSshConfiguration ` -WhatIf:$WhatIfPreference -Confirm:$false $records.Add($record) switch ($record.Result) { 'Rotated' { $stats.Rotated++ } 'Skipped' { $stats.Skipped++ } 'Failed' { $stats.Failed++ } 'WhatIf' { $stats.Skipped++ } } } catch { Write-RotationLog -Message "Unhandled error on $($candidate.VM.Name) ($($candidate.CredentialType)): $($_.Exception.Message)" -Level Error -Scope $candidate.VM.Name $stats.Failed++ } } } # --- 4. audit records ---------------------------------------------------- if ($DataCollectionEndpoint -and $DataCollectionRuleId -and $records.Count -gt 0) { foreach ($record in $records) { Write-RotationRecord -Record $record ` -DataCollectionEndpoint $DataCollectionEndpoint ` -DataCollectionRuleId $DataCollectionRuleId ` -StreamName $StreamName -Confirm:$false } } $duration = (Get-Date) - $startTime Write-RotationLog -Message '=== Summary ===' -Level Info Write-RotationLog -Message "Duration: $($duration.ToString('hh\:mm\:ss'))" -Level Info Write-RotationLog -Message "Expiry pulled forward after access: $($stats.AccessMarked)" -Level Info Write-RotationLog -Message "Candidates: $($stats.Candidates) | rotated: $($stats.Rotated) | skipped: $($stats.Skipped) | failed: $($stats.Failed)" ` -Level $(if ($stats.Failed -gt 0) { 'Warning' } else { 'Success' }) return [pscustomobject]@{ StartedAt = $startTime.ToUniversalTime() Duration = $duration Candidates = $stats.Candidates Rotated = $stats.Rotated Skipped = $stats.Skipped Failed = $stats.Failed AccessMarked = $stats.AccessMarked Records = $records.ToArray() } } |