Public/CloudFormation/Set-ATCFNStackProtectionPolicy.ps1
function Set-ATCFNStackProtectionPolicy { <# .SYNOPSIS Set or remove stack policy to prevent replacement or deletion of resources .DESCRIPTION WARNING - This command modifies resources. Test properly in stacks that you don't mind breaking before running in a prod environment. WARNING - Setting policy on the objects within the nested stack does NOT prevent the nested stack being deleted by its parent. This is a fairly simple utility to protect/unprotect all resources within a stack such that you can prevent accidental deletions or replacements which would interrupt service. Policy for entire nested stacks is REPLACED by this script, so only use it if you want to set blanket policy Don't use it if you want finer-grained policies. If the stack being processed is a nested stack, policy is set in the parent stack to prevent delete/replace operations. Parent stack policy is additive, i.e. other policies are not replaced. Attempts to remove one of the nested stacks will result in an error during changeset calculation and thus prevent nested stack deletion. .PARAMETER Stack One or more stacks by name, or as stack objects (output of Get-CFNStack) This parameter accepts pipeline input .PARAMETER Action Action to perform for all resources within the given stacks .PARAMETER PassThru If set, ARNS of all stacks that were changed are emitted. .PARAMETER Force If set, do not abort if any of the stacks in scope are updating. Policy will be set on those which are not updating only. Probably not what you want, but you can re-run the command once all stacks are stable. .EXAMPLE Get-CFNStack | Where-Object { $_.StackName -like 'MyStack-MyNestedStack*' } | Set-ATCFNStackProtectionPolicy -Action Protect Protect all resources in all stacks with names beginning with MyStack-MyNestedStack .NOTES IAM permissions required to run this command - cloudformation:DescribeStacks - cloudformation:DescribeStackResources - cloudformation:GetStackPolicy - cloudformation:SetStackPolicy .INPUTS [string] - Stack Name [Amazon.CloudFormation.Model.Stack] - Stack object .OUTPUTS [string] ARNs of stacks that were successfully updated Or none, if -PassThru not specified. .LINK https://github.com/fireflycons/aws-toolbox/tree/master/docs/en-US/Set-ATCFNStackProtectionPolicy.md #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true, Mandatory = $true)] [object[]]$Stack, [Parameter(Mandatory = $true)] [ValidateSet('Protect', 'Unprotect')] [string]$Action, [switch]$Force, [switch]$PassThru ) begin { $ErrorActionPreference = 'Stop' #region Local Functions function New-PolicyObject { <# .SYNOPSIS Create a single policy stanza - Effect/Action/Principal/Resource #> param ( [ValidateSet('Allow', 'Deny')] [string]$Effect, [string]$Action, [string]$Resource ) New-Object PSObject -Property @{ Effect = $Effect Action = $Action Principal = '*' Resource = $Resource } } function New-NestedStackPolicy { <# .SYNOPSIS Create a replacement stack policy #> param ( [ValidateSet('Allow', 'Deny')] [string]$Effect, [string]$Resource = '*' ) $policy = New-Object PSObject -Property @{ Statement = @( New-PolicyObject -Effect Allow -Action 'Update:*' -Resource $Resource ) } if ($Effect -ieq 'Deny') { $policy.Statement += @( New-PolicyObject -Effect Deny -Action 'Update:Replace' -Resource $Resource New-PolicyObject -Effect Deny -Action 'Update:Delete' -Resource $Resource ) } $policy } #endregion # Map command line arg value to effect value $policyEffect = @{ Protect = 'Deny' Unprotect = 'Allow' } # Where to build list of policies to apply. $policyList = @{} # Stable stack states. Can't apply policy if a stack is not in one of these states $stableStates = @( 'CREATE_COMPLETE' 'ROLLBACK_COMPLETE' 'UPDATE_COMPLETE' 'UPDATE_ROLLBACK_COMPLETE' ) # Store parent stack states so we don't have to keep calling Get-CFNStack on the same stack $parentStackStates = @{} # Record any exception for later. $exception = $null } process { if (-not $exception) { try { foreach ($s in $Stack) { if ($s -is [string]) { # Stack by name $thisStack = Get-CFNStack -StackName $s } else { # Stack by object $thisStack = $s } $thisStackId = $thisStack.StackId $parentStackId = $thisStack.ParentId $thisStackName = $thisStack.StackName # Check stack is stable if ($thisStack.StackStatus.Value -eq 'DELETE_COMPLETE') { # If it's deleted, warn and ignore - continue to next stack Write-Warning "Stack $thisStackName has been deleted." continue } if (-not [string]::IsNullOrEmpty($parentStackId) -and -not ($Force -or $parentStackStates.ContainsKey($parentStackId))) { $parentStack = Get-CFNStack -StackName $parentStackId if ($stableStates -inotcontains $parentStack.StackStatus.Value) { throw "Cannot continue: Stack $($parentStack.StackName), parent of $thisStackName is currently $($parentStack.StackStatus.Value)" } $parentStackStates.Add($parentStackId, $parentStack.StackStatus.Value) } if ($stableStates -inotcontains $thisStack.StackStatus.Value -and -not $Force) { throw "Cannot continue: Stack $thisStackName is currently $($thisStack.StackStatus.Value)" } Write-Verbose "$($Action)ing stack $thisStackName" # Create replacement policy $stackPolicy = New-NestedStackPolicy -Effect $policyEffect[$Action] # Add to list for processing at the end $policyList.Add($thisStackId, $stackPolicy) if (-not [string]::IsNullOrEmpty($parentStackId)) { # Protect the nested stacks from deletion by the parent. # This gives a messy failure, but it is nevertheless a failure! # Error validating existing stack policy: Unknown logical id 'LogicalResourceId/MyNestedStack' in statement {} - stack policies can only be applied to logical ids referenced in the template # Get the current parent stack policy $parentPolicy = $( if ($policyList.ContainsKey($parentStackId)) { $policyList[$parentStackId] } else { Get-CFNStackPolicy -StackName $parentStackId | ConvertFrom-Json } ) # Get logical resource name for the nested stack $logicalResourceId = Get-CFNStackResourceSummary -StackName $parentStackId | Where-Object { $_.PhysicalResourceId -eq $thisStackId } | Select-Object -ExpandProperty LogicalResourceId | ForEach-Object { "LogicalResourceId/$_" } switch ($action) { 'Unprotect' { if ($null -eq $parentPolicy) { # Nothing to do - policy never created on the parent stack continue } # Filter out policy stanzas for this nested stack $parentPolicy.Statement = $parentPolicy.Statement | Where-Object { $_.Resource -ine $logicalResourceId } # Since policy cannot be completely removed, we need to add a blanket allow if (($parentPolicy.Statement | Measure-Object).Count -eq 0) { $parentPolicy.Statement = @( New-PolicyObject -Effect Allow -Action 'Update:*' -Resource '*' ) } } 'Protect' { if ($null -eq $parentPolicy) { # Create new policy with default allow all $parentPolicy = New-Object PSObject -Property @{ Statement = @( New-PolicyObject -Effect Allow -Action 'Update:*' -Resource '*' ) } } # Filter out policy stanzas for this nested stack $parentPolicy.Statement = $parentPolicy.Statement | Where-Object { $_.Resource -ine $logicalResourceId } $newStanzas = @( New-PolicyObject -Effect Deny -Action 'Update:Replace' -Resource $logicalResourceId New-PolicyObject -Effect Deny -Action 'Update:Delete' -Resource $logicalResourceId ) if (($parentPolicy.Statement | Measure-Object).Count -eq 0) { $parentPolicy.Statement = $newStanzas } else { [array]$parentPolicy.Statement += $newStanzas } } } $policyList[$parentStackId] = $parentPolicy } } } catch { # If we re-throw here and stuff is still coming through the pipeline # then the pipe may continue and an exception will be thrown at each iteration. $exception = $_ } } } end { # If we caught an exception during the pipeline processing, throw it now if ($exception) { throw $exception.Exception } # Apply the policy changes # We only get here if all stacks are OK, or some were updating and -Force was specified $policyList.Keys | ForEach-Object { $stackId = $_ $stackName = $( $stackId -match 'stack/([\w\-]+)/' | Out-Null $Matches.1 ) $stackPolicy = $policyList[$stackId] Write-Verbose "Applying policy to $stackName" try { Set-CFNStackPolicy -StackName $stackId -StackPolicyBody ($stackPolicy | ConvertTo-Json) -Force if ($PassThru) { # Emit stack ARN $stackId } } catch { if ($_.Exception.Message -imatch 'SetStackPolicy cannot be called when stack is in the (?<state>\w+) state') { Write-Warning "Stack $stackName ignored due to $($Matches.state) and -Force was present." } else { throw } } } } } |