aws / aws-cdk-rfcs

RFCs for the AWS CDK

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Stack Policy

Black742 opened this issue · comments

PR Champion
#

Description

Cloudformation has a feature stack policy which prevent updates for the resource mentioned as a json.
Can we make support this feature in cdk?

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html

Progress

  • Tracking Issue Created
  • RFC PR Created
  • Core Team Member Assigned
  • Initial Approval / Final Comment Period
  • Ready For Implementation
    • implementation issue 1
  • Resolved

For now what I do is the following (for protecting my UserPool) after calling myApp.synth() in cloudformation.ts:

    const cf = new CloudFormation();
    await cf.setStackPolicy({
        StackName: myApp.cognitoStack.stackName,
        StackPolicyBody: JSON.stringify({
            Statement: [
                {
                  Effect: 'Deny',
                  Principal: '*',
                  Action: 'Update:*',
                  Resource: '*',
                  Condition: {
                    StringEquals: {
                      ResourceType: ['AWS::Cognito::UserPool'],
                    },
                  },
                },
                {
                  Effect: 'Allow',
                  Principal: '*',
                  Action: 'Update:*',
                  Resource: '*',
                },
            ]}),
        }).promise();

@savvyintegrations Thanks for pointing us in the right direction. I think the above assumes default region and account. I think it can be improved by constructing the CF client the same way as cdk would.

const { SDK } = require('aws-cdk/lib/api/util/sdk');
const cf = new SDK({profile: yourProfile}.cloudFormation(yourAccount, yourRegion);

This way, the cf client will be loaded with the appropriate credentials.

Also, note that this policy application will be run on any cdk command since it's outside the cdk lifeycle.

@savvyintegrations, thank you for providing a workaround that is a really helpful addition!

I just wanted to update the issue to assure that this is still on our radar!

😸

any feedback on this?

It has been more than a year since this ticket opened, I took @savvyintegrations AWS call and threw it into an AwsCustomResource. Then deploy it as a second stack that depends on the "main" stack, this is so that the Policy stack can get the ARN from the main stack.

Contents of the PolicyStack (/lib/cdk-stack-policy.ts):

import * as cdk from '@aws-cdk/core';
import * as cr from '@aws-cdk/custom-resources';
import {RetentionDays} from "@aws-cdk/aws-logs";

export class CdkStackPolicy extends cdk.Stack
{
    constructor(scope: cdk.Construct, id: string, props: cdk.StackProps, forStackName: string, forStackId: string, policy: string)
    {
        super(scope, id, props);

        new cr.AwsCustomResource(this, "StackPolicy-"+forStackName, {
            timeout: cdk.Duration.minutes(2),
            logRetention: RetentionDays.ONE_WEEK,
            onCreate: {
                service: 'CloudFormation',
                action: 'setStackPolicy',
                parameters: {
                    StackName: forStackName,
                    StackPolicyBody: policy,
                },
                physicalResourceId: cr.PhysicalResourceId.of("StackPolicy-"+forStackName)
            },
            onUpdate: {
                service: 'CloudFormation',
                action: 'setStackPolicy',
                parameters: {
                    StackName: forStackName,
                    StackPolicyBody: policy,
                },
                physicalResourceId: cr.PhysicalResourceId.of("StackPolicy"+forStackName)
            },
            policy: cr.AwsCustomResourcePolicy.fromSdkCalls({resources: [forStackId]})
        });

    }
}

Then deployed as secondary stack next to the "main" stack (/bin/cdk.ts):

import * as cdk from '@aws-cdk/core';
import { CdkStack } from '../lib/cdk-stack';
import { CdkStackPolicy } from '../lib/cdk-stack-policy';

// Initialize the CDK App
const app = new cdk.App();

    ... ... ...

let cdkStack = new CdkStack(app, applicationName, {
                    stackName: applicationName,
                    env: {
                        account: config.awsAccountID,
                        region: config.awsProfileRegion
                    }
                }, config);



let cdkPolicyStack = new CdkStackPolicy(app, applicationName+"-stackpolicy", {
        stackName: applicationName+"-stackpolicy",
        env: {
            account: config.awsAccountID,
            region: config.awsProfileRegion
        }
    }, applicationName, cdkStack.stackId,
    JSON.stringify({
        "Statement": [
            {
                "Effect": "Deny",
                "Action": ["Update:Replace", "Update:Delete"],
                "Principal": "*",
                "Resource": "*",
                "Condition": {
                    "StringEquals": {
                        "ResourceType": ["AWS::DynamoDB::Table", "AWS::ApiGateway::RestApi"]
                    }
                }
            },
            {
                "Effect": "Allow",
                "Action": "Update:*",
                "Principal": "*",
                "Resource": "*"
            }
        ]
    }));
cdkPolicyStack.addDependency(cdkStack);

Is any work being done to allow --stack-policy-during-update-body option on cdk deploy? I'd rather have this then need to jump through hoops with synth, changeset, execute commands...

I'm wondering why after all this time, this hasn't been prioritized? Given the nature of the CDK and how easy it is for developers to make unintended changes to code that could cause a stateful resource to be replaced, it seems like allowing stack policies to be set would be a top priority (and seemingly not a ton of work).

based on the above comments I implemented the stack policy

const cf = new CloudFormation();
 cf.setStackPolicy({
   StackName: this.stackName,
   StackPolicyBody: JSON.stringify({
     Statement: [
       {
         "Effect" : "Deny",
         "Principal" : "*",
         "Action" : "Update:*",
         "Resource" : "*",
       },
     ],
   }),
 });

But when I deploy my stack and check the Cloud formation console I could not see the stack_policy. do I need to do something else along with this?
Sorry if this sounds stupid I'm new with this any help is appreciated

I was able to accomplish this using a lambda function in the same stack + EventBridge event rule:

export interface StackPolicyProps {
    policy?: string;
}
export class StackPolicyFunction extends Construct {
    constructor(scope: Construct, id: string, props?: StackPolicyProps) {
        super(scope, id);

        const { stackName, stackId } = Stack.of(scope);
        // define a lambda function triggered by stack create and update completed
        const fn = new NodejsFunction(this, "handler", {
            runtime: Runtime.NODEJS_16_X,
            handler: "index.handler",
            architecture: Architecture.ARM_64,
            environment: {
                STACK_NAME: stackName,
                POLICY: props?.policy || DEFAULT_POLICY,
            },
            initialPolicy: [
                new PolicyStatement({
                    effect: Effect.ALLOW,
                    actions: ["cloudformation:SetStackPolicy"],
                    resources: [stackId],
                }),
            ],
        });

        new Rule(this, "CloudFormationRule", {
            description: "Trigger a lambda function on CloudFormation status updates.",
            enabled: true,
            // eventBus: "default" // by default associates with the accounts default eventbus
            eventPattern: {
                source: ["aws.cloudformation"],
                detailType: ["CloudFormation Stack Status Change"],
                detail: {
                    // we cannot set the policy while the stack is creating or updating
                    "status-details": { status: ["CREATE_COMPLETE", "UPDATE_COMPLETE"] },
                },
            },
            targets: [new LambdaFunction(fn.currentVersion, {})],
        });
    }
}

// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html#stack-policy-reference
const DEFAULT_POLICY = JSON.stringify({
    Statement: [
        // deny any stack updates the attempt to delete or replace
        // resources of type AWS::DynamoDB::Table
        {
            Effect: "Deny",
            Action: ["Update:Replace", "Update:Delete"],
            Principal: "*",
            Resource: "*",
            Condition: {
                StringEquals: {
                    // Be default we prevent any dynamodb tables and s3 buckets from being replaced
                    ResourceType: ["AWS::DynamoDB::Table", "AWS::S3::Bucket"],
                },
            },
        },
        // allow updates to all other resources
        {
            Effect: "Allow",
            Action: "Update:*",
            Principal: "*",
            Resource: "*",
        },
    ],
});

Handler:

export interface CloudFormation {
    version: string;
    source: string;
    account: string;
    id: string;
    region: string;
    "detail-type": string;
    time: string;
    resources: string[];
    detail: Detail;
}

export interface Detail {
    "stack-id": string;
    "status-details": StatusDetails;
}

export interface StatusDetails {
    status: string;
    "status-reason": string;
}

const STACK_NAME = process.env.STACK_NAME as string;
const POLICY = process.env.POLICY as string;

export const handler = async (event: EventBridgeEvent<"CloudFormation", Detail>) => {
    console.log(JSON.stringify({ event }));
    // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html#cli-stack-status-codes
    const status = event["detail"]["status-details"]["status"];
    const stackArn = event["resources"];
    if (
        // sanity check to avoid failures in case the rule is changed
        (status === "CREATE_COMPLETE" || status === "UPDATE_COMPLETE") &&
        // only apply policy to stacks that create this resource
        stackArn.findIndex(arn => arn.includes(STACK_NAME)) != -1
    ) {
        console.log("Updating stack policy...");
        const client = new CloudFormationClient({});
        const command = new SetStackPolicyCommand({
            StackName: STACK_NAME,
            StackPolicyBody: POLICY,
        });
        const result = await client.send(command);
        console.log(JSON.stringify({ result }));
    }
};

I was able to accomplish this using a lambda function in the same stack + EventBridge event rule:

export interface StackPolicyProps {

    policy?: string;

}

export class StackPolicyFunction extends Construct {

    constructor(scope: Construct, id: string, props?: StackPolicyProps) {

        super(scope, id);



        const { stackName, stackId } = Stack.of(scope);

        // define a lambda function triggered by stack create and update completed

        const fn = new NodejsFunction(this, "handler", {

            runtime: Runtime.NODEJS_16_X,

            handler: "index.handler",

            architecture: Architecture.ARM_64,

            environment: {

                STACK_NAME: stackName,

                POLICY: props?.policy || DEFAULT_POLICY,

            },

            initialPolicy: [

                new PolicyStatement({

                    effect: Effect.ALLOW,

                    actions: ["cloudformation:SetStackPolicy"],

                    resources: [stackId],

                }),

            ],

        });



        new Rule(this, "CloudFormationRule", {

            description: "Trigger a lambda function on CloudFormation status updates.",

            enabled: true,

            // eventBus: "default" // by default associates with the accounts default eventbus

            eventPattern: {

                source: ["aws.cloudformation"],

                detailType: ["CloudFormation Stack Status Change"],

                detail: {

                    // we cannot set the policy while the stack is creating or updating

                    "status-details": { status: ["CREATE_COMPLETE", "UPDATE_COMPLETE"] },

                },

            },

            targets: [new LambdaFunction(fn.currentVersion, {})],

        });

    }

}



// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html#stack-policy-reference

const DEFAULT_POLICY = JSON.stringify({

    Statement: [

        // deny any stack updates the attempt to delete or replace

        // resources of type AWS::DynamoDB::Table

        {

            Effect: "Deny",

            Action: ["Update:Replace", "Update:Delete"],

            Principal: "*",

            Resource: "*",

            Condition: {

                StringEquals: {

                    // Be default we prevent any dynamodb tables and s3 buckets from being replaced

                    ResourceType: ["AWS::DynamoDB::Table", "AWS::S3::Bucket"],

                },

            },

        },

        // allow updates to all other resources

        {

            Effect: "Allow",

            Action: "Update:*",

            Principal: "*",

            Resource: "*",

        },

    ],

});

Handler:

export interface CloudFormation {

    version: string;

    source: string;

    account: string;

    id: string;

    region: string;

    "detail-type": string;

    time: string;

    resources: string[];

    detail: Detail;

}



export interface Detail {

    "stack-id": string;

    "status-details": StatusDetails;

}



export interface StatusDetails {

    status: string;

    "status-reason": string;

}



const STACK_NAME = process.env.STACK_NAME as string;

const POLICY = process.env.POLICY as string;



export const handler = async (event: EventBridgeEvent<"CloudFormation", Detail>) => {

    console.log(JSON.stringify({ event }));

    // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html#cli-stack-status-codes

    const status = event["detail"]["status-details"]["status"];

    const stackArn = event["resources"];

    if (

        // sanity check to avoid failures in case the rule is changed

        (status === "CREATE_COMPLETE" || status === "UPDATE_COMPLETE") &&

        // only apply policy to stacks that create this resource

        stackArn.findIndex(arn => arn.includes(STACK_NAME)) != -1

    ) {

        console.log("Updating stack policy...");

        const client = new CloudFormationClient({});

        const command = new SetStackPolicyCommand({

            StackName: STACK_NAME,

            StackPolicyBody: POLICY,

        });

        const result = await client.send(command);

        console.log(JSON.stringify({ result }));

    }

};

I solved this the exact same way but added the use of StackSets to enforce this globally across our org.

Still think it's something we should be able to configure within the CDK.

Kind of crazy we have to have such a huge workaround for something which is so core to Cloudformation and protecting resources!

It's sometimes very difficult to ascertain whether CFN is going to replace a resource or not with just a changeset ("conditional", thanks cfn), and stack policies are essential for any kind of automated deployments via CI/CD. First-party support would give a lot more credence to CDK being ready for production use.