Enabling AWS Budget for multiple accounts within AWS Organization

Updated: Aug 30

Problem statement

Using a multi-account AWS environment was mentioned many times in previous posts as the best practice for companies which helps apply flexible security controls, simplifies billing and in general enables you to move faster and build differentiated products and services. There are many different approaches for organizing accounts, depending on the company's type, size and purpose.


In this post we will look at “Sandbox” accounts (sometimes called “Playground”) which are actively used by many customers in order to allocate a separate account for every employee, who may need it in the research and development process, easily manage permissions, monitor costs and eventually decommission it without any impact for others. When you have dozens of accounts and you often create a sandbox for new employees, it's a good idea to use AWS Budgets for monitoring costs of every such AWS account. AWS Budgets is the simplest way to monitor your AWS spend and be alerted when you exceed or are forecasted to exceed your desired spending limit. The procedure of automated creation of new AWS accounts is described here and even present on the AWS Control Tower service page in your management console now. But we still have an open item regarding automated budget creation for newly created accounts. In this post we will cover this need.


Solution overview and design

The high level diagram of the solution is following:

AWS CloudTrail catches all events including creation of new accounts (in our case via AWS Control Tower). Next we can create a rule in Amazon EventBridge for the exact event pattern:

{
  "detail-type": ["AWS Service Event via CloudTrail"],
  "source": ["aws.controltower"],
  "detail": {
    "serviceEventDetails": {
      "createManagedAccountStatus": {
        "state": ["SUCCEEDED"]
      }
    },
    "eventName": ["CreateManagedAccount"]
  }
}

There are two different types of events possible here. The first one is creating an AWS in general in the current AWS organization. The second one is enrollment of AWS into the Control Tower with the state “Succeeded” which is exactly what we need.


Once we get such an event, we trigger a Lambda function which parses a payload from Amazon EventBridge and creates a budget for the new AWS account. As usual, Lambda writes logs into AWS CloudWatch Log group.


The event sample, received by the Lambda functions is below:

{
   "version":"0",
   "id":"3956e0bb-****-****-****-6af75f8e7d8c",
   "detail-type":"AWS Service Event via CloudTrail",
   "source":"aws.controltower",
   "account":"50********80",
   "time":"2022-01-19T19:17:55Z",
   "region":"eu-west-1",
   "resources":[

   ],
   "detail":{
      "eventVersion":"1.08",
      "userIdentity":{
         "accountId":"50********80",
         "invokedBy":"AWS Internal"
      },
      "eventTime":"2022-01-19T19:17:55Z",
      "eventSource":"controltower.amazonaws.com",
      "eventName":"CreateManagedAccount",
      "awsRegion":"eu-west-1",
      "sourceIPAddress":"AWS Internal",
      "userAgent":"AWS Internal",
      "requestParameters":"None",
      "responseElements":"None",
      "eventID":"ceddf497-****-****-****-16703dc31e56",
      "readOnly":false,
      "eventType":"AwsServiceEvent",
      "managementEvent":true,
      "recipientAccountId":"50********80",
      "serviceEventDetails":{
         "createManagedAccountStatus":{
            "organizationalUnit":{
               "organizationalUnitName":"Playground",
               "organizationalUnitId":"ou-fcft-********"
            },
            "account":{
               "accountName":"AIT-playground-test-budget2",
               "accountId":"52********01"
            },
            "state":"SUCCEEDED",
            "message":"AWS Control Tower successfully created an enrolled account.",
            "requestedTimestamp":"2022-01-19T18:56:39+0000",
            "completedTimestamp":"2022-01-19T19:17:55+0000"
         }
      },
      "eventCategory":"Management"
   }
}

The CloudFormation template of the whole solution including code of the Lambda function (Python boto3) is below:

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  LambdaName:
    Description: 'Name of Lambda function'
    Type: String
    Default: ''
  TargetOU:
    Description: 'Name of OU, where new accounts will be created'
    Type: String
    Default: ''
  BudgetThreshold:
    Description: 'Budget threshold USD'
    Type: String
    Default: ''

Resources:
  LambdaCreateBudget:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub"${LambdaName}"
      Handler: index.lambda_handler
      Role: !GetAttLambdaExecutionRole.Arn
      Description: "Lambda function is executed by event of adding new account into target OU and creates a budget"
      MemorySize: 128
      Timeout: 900
      Runtime: python3.8
      Environment: 
        Variables: 
            target_ou: !RefTargetOU
            budget_threshold: !RefBudgetThreshold
      Code:
        ZipFile: !Sub|
            import boto3
            import time
            import os

            targetOU = os.environ['target_ou']
            budget_threshold = os.environ['budget_threshold']
            current_account = boto3.client('sts').get_caller_identity().get('Account')
            now = (str(int(time.time())))

            def lambda_handler(event, context):
                new_account_id = event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["account"]["accountId"]
                new_account_name = event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["account"]["accountName"]
                new_account_state = event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["state"]
                new_account_ou = event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["organizationalUnit"]["organizationalUnitName"]

                if new_account_state == "SUCCEEDED" and new_account_ou == targetOU:
                    create_budget(new_account_id, new_account_name)
                    return
                else:
                    return

            def create_budget(new_account_id,new_account_name):
                try:
                    client_budget=boto3.client('budgets')
                    newBudget=client_budget.create_budget(
                        AccountId=current_account,
                        Budget={
                            'BudgetName': new_account_id+"-"+new_account_name,
                            'BudgetLimit': {
                                'Amount': budget_threshold,
                                'Unit': "USD"
                            },
                            'CostFilters': {
                                'LinkedAccount': [
                                    new_account_id,
                                ]
                            },
                            'CostTypes': {
                                'IncludeTax': True,
                                'IncludeSubscription': True,
                                'UseBlended': False,
                                'IncludeRefund': False,
                                'IncludeCredit': False,
                                'IncludeUpfront': True,
                                'IncludeRecurring': True,
                                'IncludeOtherSubscription': True,
                                'IncludeSupport': True,
                                'IncludeDiscount': True,
                                'UseAmortized': False
                            },
                            'TimeUnit': 'MONTHLY',
                            'TimePeriod': {
                                'Start': now,
                                },
                            'BudgetType': 'COST',
                            },
                    )
                    print("Budgethasbeencreatedforaccount" + new_account_id + "" + new_account_name)
                except Exception as e: 
                    print(e)

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: Create-budget-LambdaExecutionRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
            - sts:AssumeRole
      Path: "/"
      Policies:
      - PolicyName: lambda-write-logs
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Sid: AllowWriteLogs
            Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource: arn:aws:logs:*:*:*
      - PolicyName: create-budget-policy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Sid: AllowCreateBudget
            Effect: Allow
            Action:
              - budgets:ModifyBudget
            Resource: "*"

  EventRule: 
    Type: AWS::Events::Rule
    Properties: 
      Description: "EventRule"
      EventPattern:
        source:
          -aws.controltower
        detail-type: