Public/Invoke-CredentialRotation.ps1
|
function Invoke-CredentialRotation { <# .SYNOPSIS Rotates the credentials that are due on the machines you give it. .DESCRIPTION The caller states the machines. This function does not search for them, does not read a tag, and has no opinion about which machines belong in scope. That belongs to whatever is orchestrating - a runbook, a pipeline, or you at a prompt - and keeping it out of here is what lets the same code run against one machine from a workstation and against a fleet on a schedule. Every machine handed in is rotated. Add -OnlyIfDue and the expiry date gets a vote instead, which is what a scheduled pass wants. Whether you passed one name or two hundred objects has nothing to do with it. Rotation after use is not handled here either. Register-CredentialAccess pulls the expiry date of a credential somebody read forward; this function then sees it as ordinary ageing. One signal, one code path - and the orchestrator decides how often to look. .PARAMETER VMName Rotate this machine. A convenience over -VM for the common case of one name; it behaves identically otherwise. .PARAMETER ResourceGroupName Narrows -VMName when the same name exists more than once in the subscription. Without it, an ambiguous name is an error rather than a guess. .PARAMETER VM Machines to process, as objects from Get-AzVM. What an orchestrator passes after it has selected them. .PARAMETER OnlyIfDue Rotate only what is missing, half-rotated or near expiry, instead of rotating everything handed in. How you name the machines says nothing about this - a scheduled pass sets it, a person at a prompt usually does not. .PARAMETER ThresholdDays How close to expiry counts as due. Only consulted with -OnlyIfDue. .PARAMETER SecretNameTemplate How secret names are built from {vm}, {user} and {kind}. Change it to fit a vault that already has a naming convention; keep it the same for the life of a secret. .EXAMPLE Invoke-CredentialRotation -VaultName kv-creds -VMName jump-01 -WhatIf Shows what would happen to one machine, from your own workstation, without deploying anything. Always the first thing to run. .EXAMPLE Invoke-CredentialRotation -VaultName kv-creds -VMName jump-01 Rotates that machine now. The secret is created in the vault if it does not exist yet, so this is also how a machine is onboarded by hand. .EXAMPLE $vms = Get-AzVM | Where-Object { $_.Tags.CredentialRotation -eq 'enabled' } Invoke-CredentialRotation -VaultName kv-creds -VM $vms -OnlyIfDue What an orchestrator does: select the machines however you like, hand them over, and ask for only the ones that are due. The tag here is the caller's policy, not the module's. .EXAMPLE Invoke-CredentialRotation -VaultName kv-creds -VM $vms The same machines, all rotated, due or not. Naming machines and deciding whether the expiry date gets a vote are two separate questions, so they are two separate parameters. .OUTPUTS PSCustomObject summarising the run. Records holds one entry per credential touched, in the shape CredentialRotation_CL expects, for whoever wants to ship them. #> # -WhatIf is supported and propagated, but the decision is made where the change is: # Update-VMCredential calls ShouldProcess per credential. Confirming once up here # instead would collapse a dry run into a single line and throw away the per-credential # WhatIf records, which are the reason anybody runs one. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '')] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Named')] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$VaultName, [Parameter(Mandatory, ParameterSetName = 'Named')] [ValidateNotNullOrEmpty()][string]$VMName, [Parameter(ParameterSetName = 'Named')][string]$ResourceGroupName, [Parameter(Mandatory, ParameterSetName = 'Machines')] [ValidateNotNullOrEmpty()][object[]]$VM, [switch]$OnlyIfDue, # Only consulted with -OnlyIfDue. [ValidateRange(0, 3650)][int]$ThresholdDays = 14, [ValidateRange(1, 3650)][int]$ValidityDays = 90, [switch]$SkipSshKeys, [switch]$RemovePriorSshKeys, [switch]$ResetSshConfiguration, [ValidateNotNullOrEmpty()][string]$SecretNameTemplate = '{vm}-{user}-{kind}', [string]$TriggeredBy ) $startTime = Get-Date # Only ever decides where the machines come from, never what happens to them. $named = $PSCmdlet.ParameterSetName -eq 'Named' $records = [System.Collections.Generic.List[object]]::new() $stats = [ordered]@{ Candidates = 0 Rotated = 0 Skipped = 0 Failed = 0 } Write-RotationLog -Message '=== Credential rotation started ===' -Level Info if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { throw 'No Azure context. Connect with Connect-AzAccount (or -Identity in Automation) first.' } # --- the machines -------------------------------------------------------- # Named or handed in, but always stated by the caller. Nothing here searches. $machines = if ($named) { @(Resolve-TargetVM -Name $VMName -ResourceGroupName $ResourceGroupName) } else { @($VM) } $scope = if ($OnlyIfDue) { "only what is due within $ThresholdDays d" } else { 'everything handed in' } Write-RotationLog -Message "Vault: $VaultName | machines: $($machines.Count) | $scope | validity: $ValidityDays d" -Level Info # A threshold with nothing to apply to is the kind of parameter that looks like it # worked. Say so rather than ignoring it quietly. if ($PSBoundParameters.ContainsKey('ThresholdDays') -and -not $OnlyIfDue) { Write-RotationLog -Message '-ThresholdDays was given without -OnlyIfDue, so it has no effect: every machine handed in is being rotated.' -Level Warning } # --- what to rotate ------------------------------------------------------ try { $candidates = Get-RotationCandidate -VaultName $VaultName -VM $machines ` -OnlyIfDue:$OnlyIfDue -ThresholdDays $ThresholdDays -SkipSshKeys:$SkipSshKeys ` -SecretNameTemplate $SecretNameTemplate } catch { Write-RotationLog -Message "Could not work out what is due: $($_.Exception.Message)" -Level Error throw } $stats.Candidates = @($candidates).Count Write-RotationLog -Message "$(@($candidates).Count) credential(s) to process" -Level Info # --- rotate -------------------------------------------------------------- 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 ` -SecretNameTemplate $SecretNameTemplate ` -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++ } } $duration = (Get-Date) - $startTime Write-RotationLog -Message '=== Summary ===' -Level Info Write-RotationLog -Message "Duration: $($duration.ToString('hh\:mm\:ss'))" -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 Records = $records.ToArray() } } |