Public/CloudFormation/Set-ATCFNStackProtectionPolicy.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
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
                }
            }
        }
    }
}