Skip to content

Commit c911c5d

Browse files
authored
Merge pull request #932 from alesieber/feat/use-current-role-to-delete-stack
feat(cloudformation): Add UseCurrentRoleToDeleteStack setting
2 parents 4a5a004 + 58c3ffb commit c911c5d

3 files changed

Lines changed: 677 additions & 38 deletions

File tree

docs/resources/cloud-formation-stack.md

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,59 @@ The string value is always what is used in the output of the log format when a r
3737

3838
- `DisableDeletionProtection`
3939
- `CreateRoleToDeleteStack`
40+
- `UseCurrentRoleToDeleteStack`
4041

4142

4243
### DisableDeletionProtection
4344

44-
!!! note
45-
There is currently no description for this setting. Often times settings are fairly self-explanatory. However, we
46-
are working on adding descriptions for all settings.
45+
When enabled, aws-nuke will automatically disable termination protection on a CloudFormation stack before
46+
attempting to delete it. Without this setting, stacks with termination protection enabled will fail to delete.
4747

48-
```text
49-
DisableDeletionProtection
48+
```yaml
49+
CloudFormationStack:
50+
DisableDeletionProtection: "true"
5051
```
5152
5253
5354
### CreateRoleToDeleteStack
5455
55-
!!! note
56-
There is currently no description for this setting. Often times settings are fairly self-explanatory. However, we
57-
are working on adding descriptions for all settings.
56+
When enabled, aws-nuke will create a temporary IAM role to delete a stack whose original execution role no longer
57+
exists or cannot be assumed. The temporary role is tagged with `Managed: aws-nuke` and is cleaned up after deletion.
5858

59-
```text
60-
CreateRoleToDeleteStack
59+
```yaml
60+
CloudFormationStack:
61+
CreateRoleToDeleteStack: "true"
62+
```
63+
64+
65+
### UseCurrentRoleToDeleteStack
66+
67+
When enabled, aws-nuke overrides the stack's associated IAM role with the caller's current role during deletion.
68+
The caller's role ARN is resolved via STS `GetCallerIdentity` and passed as the `RoleARN` parameter on `DeleteStack`
69+
calls. This applies to both normal deletion and `DELETE_FAILED` retry paths.
70+
71+
This is useful when SCPs deny actions from the stack's original creation role (e.g. CDK `cfn-exec-role`) during
72+
account cleanup.
73+
74+
```yaml
75+
CloudFormationStack:
76+
UseCurrentRoleToDeleteStack: "true"
6177
```
6278

79+
!!! warning "Security Consideration"
80+
Enabling this setting may broaden the permissions available during stack deletion. The role running aws-nuke
81+
typically has broader permissions than the stack's original execution role. Be aware that stack deletion
82+
operations (such as deleting resources within the stack) will execute with the caller's role permissions
83+
rather than the more constrained original stack role.
84+
85+
!!! note "Assumed Role Requirement"
86+
This setting only takes effect when aws-nuke is authenticated via an IAM assumed role. If aws-nuke is running
87+
as an IAM user or using any other authentication method that is not an assumed role, this setting is effectively
88+
a no-op and stack deletion falls back to normal behavior (using the stack's original role or no role).
89+
90+
!!! note "IAM Path Prefix Limitation"
91+
If the assumed role has an IAM path prefix (e.g. `arn:aws:iam::123456789012:role/my-path/MyRole`), the STS
92+
assumed-role ARN omits the path component. The reconstructed role ARN will not include the path, which may
93+
result in an incorrect ARN. This is uncommon in typical CDK or CloudFormation use cases.
94+
95+

resources/cloudformation-stack.go

Lines changed: 122 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010
"github.com/gotidy/ptr"
1111
"github.com/sirupsen/logrus"
1212

13-
"github.com/aws/aws-sdk-go/aws" //nolint:staticcheck
14-
"github.com/aws/aws-sdk-go/aws/awserr" //nolint:staticcheck
15-
"github.com/aws/aws-sdk-go/service/cloudformation" //nolint:staticcheck
16-
"github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
13+
"github.com/aws/aws-sdk-go/aws" //nolint:staticcheck
14+
"github.com/aws/aws-sdk-go/aws/awserr" //nolint:staticcheck
15+
"github.com/aws/aws-sdk-go/service/cloudformation" //nolint:staticcheck
16+
"github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" //nolint:staticcheck
17+
"github.com/aws/aws-sdk-go/service/sts" //nolint:staticcheck
18+
"github.com/aws/aws-sdk-go/service/sts/stsiface" //nolint:staticcheck
1719

1820
"github.com/aws/aws-sdk-go-v2/service/iam"
1921
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
@@ -40,17 +42,26 @@ func init() {
4042
Settings: []string{
4143
"DisableDeletionProtection",
4244
"CreateRoleToDeleteStack",
45+
"UseCurrentRoleToDeleteStack",
4346
},
4447
})
4548
}
4649

50+
// iamRoleAPI is the subset of the IAM v2 client used by CloudFormationStack for role
51+
// create/delete operations. Defined as an interface to enable test mocking.
52+
type iamRoleAPI interface {
53+
CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error)
54+
DeleteRole(ctx context.Context, params *iam.DeleteRoleInput, optFns ...func(*iam.Options)) (*iam.DeleteRoleOutput, error)
55+
}
56+
4757
type CloudFormationStackLister struct{}
4858

4959
func (l *CloudFormationStackLister) List(_ context.Context, o interface{}) ([]resource.Resource, error) {
5060
opts := o.(*nuke.ListerOpts)
5161

5262
svc := cloudformation.New(opts.Session)
5363
iamSvc := iam.NewFromConfig(*opts.Config)
64+
stsSvc := sts.New(opts.Session)
5465

5566
params := &cloudformation.DescribeStacksInput{}
5667
resources := make([]resource.Resource, 0)
@@ -63,6 +74,7 @@ func (l *CloudFormationStackLister) List(_ context.Context, o interface{}) ([]re
6374
for _, stack := range resp.Stacks {
6475
newResource := &CloudFormationStack{
6576
svc: svc,
77+
stsSvc: stsSvc,
6678
iamSvc: iamSvc,
6779
logger: opts.Logger,
6880
maxDeleteAttempts: CloudformationMaxDeleteAttempt,
@@ -94,21 +106,24 @@ func (l *CloudFormationStackLister) List(_ context.Context, o interface{}) ([]re
94106
}
95107

96108
type CloudFormationStack struct {
97-
svc cloudformationiface.CloudFormationAPI
98-
iamSvc *iam.Client
99-
settings *settings.Setting
100-
logger *logrus.Entry
101-
Name *string
102-
Status *string
103-
CreationTime *time.Time
104-
LastUpdatedTime *time.Time
105-
Tags []*cloudformation.Tag
106-
description *string
107-
parentID *string
108-
roleARN *string
109-
maxDeleteAttempts int
110-
roleCreated bool
111-
roleName string
109+
svc cloudformationiface.CloudFormationAPI
110+
stsSvc stsiface.STSAPI
111+
iamSvc iamRoleAPI
112+
settings *settings.Setting
113+
logger *logrus.Entry
114+
Name *string
115+
Status *string
116+
CreationTime *time.Time
117+
LastUpdatedTime *time.Time
118+
Tags []*cloudformation.Tag
119+
description *string
120+
parentID *string
121+
roleARN *string
122+
callerRoleARN *string
123+
callerRoleResolved bool
124+
maxDeleteAttempts int
125+
roleCreated bool
126+
roleName string
112127
}
113128

114129
func (r *CloudFormationStack) Filter() error {
@@ -150,10 +165,14 @@ func (r *CloudFormationStack) createRole(ctx context.Context) error {
150165
},
151166
})
152167

168+
if err != nil {
169+
return err
170+
}
171+
153172
r.roleCreated = true
154173
r.roleName = roleParts[len(roleParts)-1]
155174

156-
return err
175+
return nil
157176
}
158177

159178
func (r *CloudFormationStack) removeRole(ctx context.Context) error {
@@ -167,6 +186,69 @@ func (r *CloudFormationStack) removeRole(ctx context.Context) error {
167186
return err
168187
}
169188

189+
// resolveCallerRoleARN lazily resolves the caller's IAM role ARN from STS on first call.
190+
// This is only invoked when UseCurrentRoleToDeleteStack is enabled, avoiding unnecessary
191+
// STS API calls for users who don't use this setting.
192+
// Note: IAM roles with path prefixes (e.g. /my-path/MyRole) cannot be fully reconstructed
193+
// from the STS assumed-role ARN because STS omits the path component.
194+
func (r *CloudFormationStack) resolveCallerRoleARN() *string {
195+
if r.callerRoleResolved {
196+
return r.callerRoleARN
197+
}
198+
r.callerRoleResolved = true
199+
200+
identity, err := r.stsSvc.GetCallerIdentity(&sts.GetCallerIdentityInput{})
201+
if err != nil {
202+
r.logger.Warnf("CloudFormationStack stackName=%s failed to resolve caller role ARN: %s", *r.Name, err.Error())
203+
return nil
204+
}
205+
if identity.Arn == nil {
206+
r.logger.Warnf("CloudFormationStack stackName=%s GetCallerIdentity returned nil ARN", *r.Name)
207+
return nil
208+
}
209+
210+
// Convert assumed-role ARN (arn:<partition>:sts::<ACCT>:assumed-role/ROLE/SESSION)
211+
// to role ARN (arn:<partition>:iam::<ACCT>:role/ROLE)
212+
arnStr := *identity.Arn
213+
if !strings.Contains(arnStr, ":assumed-role/") {
214+
r.logger.Warnf("CloudFormationStack stackName=%s caller ARN is not an assumed-role ARN (%s), cannot resolve role ARN", *r.Name, arnStr)
215+
return nil
216+
}
217+
218+
parts := strings.Split(arnStr, ":")
219+
if len(parts) < 6 {
220+
r.logger.Warnf("CloudFormationStack stackName=%s caller ARN has unexpected format (%s)", *r.Name, arnStr)
221+
return nil
222+
}
223+
224+
partition := parts[1]
225+
accountID := parts[4]
226+
rolePart := strings.Split(parts[5], "/")
227+
if len(rolePart) < 2 {
228+
r.logger.Warnf("CloudFormationStack stackName=%s caller ARN resource segment has unexpected format (%s)", *r.Name, parts[5])
229+
return nil
230+
}
231+
232+
roleARN := fmt.Sprintf("arn:%s:iam::%s:role/%s", partition, accountID, rolePart[1])
233+
r.callerRoleARN = &roleARN
234+
return r.callerRoleARN
235+
}
236+
237+
// applyRoleOverride sets the RoleARN on a DeleteStackInput if UseCurrentRoleToDeleteStack is enabled.
238+
func (r *CloudFormationStack) applyRoleOverride(input *cloudformation.DeleteStackInput) {
239+
if !r.settings.GetBool("UseCurrentRoleToDeleteStack") {
240+
return
241+
}
242+
callerRole := r.resolveCallerRoleARN()
243+
if callerRole != nil {
244+
r.logger.Infof("CloudFormationStack stackName=%s UseCurrentRoleToDeleteStack: overriding RoleARN with %s", *r.Name, *callerRole)
245+
input.RoleARN = callerRole
246+
} else {
247+
r.logger.Warnf("CloudFormationStack stackName=%s UseCurrentRoleToDeleteStack enabled "+
248+
"but callerRoleARN could not be resolved, falling back to default role behavior", *r.Name)
249+
}
250+
}
251+
170252
func (r *CloudFormationStack) removeWithAttempts(ctx context.Context, attempt int) error {
171253
if err := r.doRemove(); err != nil {
172254
r.logger.Errorf("CloudFormationStack stackName=%s attempt=%d maxAttempts=%d delete failed: %s",
@@ -270,7 +352,8 @@ func (r *CloudFormationStack) doRemove() error { //nolint:gocyclo
270352
StackName: r.Name,
271353
})
272354
} else if *stack.StackStatus == cloudformation.StackStatusDeleteFailed {
273-
r.logger.Infof("CloudFormationStack stackName=%s delete failed. Attempting to retain and delete stack", *r.Name)
355+
r.logger.Infof("CloudFormationStack stackName=%s delete failed (reason=%s). Attempting to retain and delete stack",
356+
*r.Name, ptr.ToString(stack.StackStatusReason))
274357
// This means the CFS has undetectable resources.
275358
// In order to move on with nuking, we retain them in the deletion.
276359
retainableResources, err := r.svc.ListStackResources(&cloudformation.ListStackResourcesInput{
@@ -282,16 +365,22 @@ func (r *CloudFormationStack) doRemove() error { //nolint:gocyclo
282365

283366
retain := make([]*string, 0)
284367

285-
for _, r := range retainableResources.StackResourceSummaries {
286-
if *r.ResourceStatus != cloudformation.ResourceStatusDeleteComplete {
287-
retain = append(retain, r.LogicalResourceId)
368+
for _, res := range retainableResources.StackResourceSummaries {
369+
if *res.ResourceStatus != cloudformation.ResourceStatusDeleteComplete {
370+
retain = append(retain, res.LogicalResourceId)
371+
r.logger.Infof("CloudFormationStack stackName=%s retaining resource %s (type=%s, status=%s, reason=%s)",
372+
*r.Name, ptr.ToString(res.LogicalResourceId), ptr.ToString(res.ResourceType),
373+
ptr.ToString(res.ResourceStatus), ptr.ToString(res.ResourceStatusReason))
288374
}
289375
}
290376

291-
if _, err = r.svc.DeleteStack(&cloudformation.DeleteStackInput{
377+
deleteInput := &cloudformation.DeleteStackInput{
292378
StackName: r.Name,
293379
RetainResources: retain,
294-
}); err != nil {
380+
}
381+
r.applyRoleOverride(deleteInput)
382+
383+
if _, err = r.svc.DeleteStack(deleteInput); err != nil {
295384
return err
296385
}
297386

@@ -301,9 +390,14 @@ func (r *CloudFormationStack) doRemove() error { //nolint:gocyclo
301390
} else {
302391
if err := r.waitForStackToStabilize(*stack.StackStatus); err != nil {
303392
return err
304-
} else if _, err := r.svc.DeleteStack(&cloudformation.DeleteStackInput{
393+
}
394+
395+
deleteInput := &cloudformation.DeleteStackInput{
305396
StackName: r.Name,
306-
}); err != nil {
397+
}
398+
r.applyRoleOverride(deleteInput)
399+
400+
if _, err := r.svc.DeleteStack(deleteInput); err != nil {
307401
return err
308402
} else if err := r.svc.WaitUntilStackDeleteComplete(&cloudformation.DescribeStacksInput{
309403
StackName: r.Name,

0 commit comments

Comments
 (0)