Ash
Ash

Reputation: 410

Using the Cloudformation Join function with a CommaDelimitedList Parameter to build IAM ARNs

I have been trying to build out a Bucket Policy to allow actions on a centralised account in CloudFormation to IAM Roles in a series of other accounts that share the same pattern - I.E:

arn:aws:iam::111111111111:role/my-role

arn:aws:iam::222222222222:role/my-role

I have found the following example, which gets me close, but not quite close enough: https://stackoverflow.com/a/50060983/1736704

Below is an Example of code that works:

Parameters:
  MyAccounts:
    Type: CommaDelimitedList
    Default: '111111111111,222222222222'
  MyBucket:
    Type: String
    Default: my-bucket

Resources:
  MyBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref MyBucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: MyRoleAllow
            Effect: Allow
            Principal: 
              AWS: !Split
                - ','
                - !Sub
                  - 'arn:aws:iam::${inner}:role/my-role'
                  - inner: !Join
                    - ':role/my-role,arn:aws:iam::'
                    - Ref: 'MyAccounts'
            Action:
              - s3:PutObject              
            Resource: !Sub arn:aws:s3:::${MyBucket}/*

What I would like to be able to do is make the role name a parameter. When I attempt to do that, no matter how I structure the !Join function, I get errors.

If I modify the above code and have my-role as a string parameter called RoleName, and expand the !Join, it returns an error. The full modified code that doesn't work:

Parameters:
  MyAccounts:
    Type: CommaDelimitedList
    Default: '111111111111,222222222222'
  RoleName
    Type: String
    Default: my-role
  MyBucket:
    Type: String
    Default: my-bucket

Resources:
  MyBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref MyBucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: MyRoleAllow
            Effect: Allow
            Principal: 
              AWS: !Split
                - ','
                - !Sub
                  - 'arn:aws:iam::${inner}:role/my-role'
                  - inner: !Join
                    - ''
                    - - ':role/'
                      - !Ref 'RoleName'
                      - ',arn:aws:iam::'
                      - Ref: 'Accounts'
            Action:
              - s3:PutObject              
            Resource: !Sub arn:aws:s3:::${MyBucket}/*

This is the error message I am getting: Template error: every Fn::Join object requires two parameters, (1) a string delimiter and (2) a list of strings to be joined or a function that returns a list of strings (such as Fn::GetAZs) to be joined.

In the modified code, it is the Ref: 'Accounts' that is causing the issue, but I am confused why, because it works in the original code.

Edit:
The inputs I would like to use are:

Parameters:
  MyAccounts:
    Type: CommaDelimitedList
    Default: '111111111111,222222222222'
  RoleName
    Type: String
    Default: my-role
  MyBucket:
    Type: String
    Default: my-bucket

My expected output (a S3 Bucket Policy) would look like:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "MyRoleAllow",
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::111111111111:role/my-role",
                    "arn:aws:iam::222222222222:role/my-role"
                ]
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::my-bucket/*"
        }
    ]
}

Can anyone tell me if what I am trying to achieve is possible? If so, how can I modify my code to get it to work?

Thanks

Upvotes: 4

Views: 3735

Answers (2)

Lets assume I have:

Parameters:
  ChildAccounts:
    Type: CommaDelimitedList
    Default: ""
    Description: Comma delimited list of accounts.

If you can cleverly use !Join, !Sub, and !Split you can actually get that to work IF you don't need to sub in any other arguments:

  - Effect: Allow
    Action:
      - sts:AssumeRole
    Resource: !Split
      - ','
      - !Sub
        - arn:aws:iam::${MIDDLE_ACOUNTs}:role/ChildAccountRole
        - MIDDLE_ACOUNTs: !Join
          - ':role/ChildAccountRole,arn:aws:iam::'
          - !Ref ChildAccounts

However, sadly, in my case I needed to sub a different parameter called Environment into each role name and all went to hell with the above approach when I attempted to do that.

So after enough head banging I went with a different approach and decided to add a custom intrinsic function by using Cloudformation Macros / Transformations.

First I needed to deploy a macro. This macro will look in resources of templates for a key called Tr::ListSub whose value is a two/three element list (further explained in the example later).

The following CloudFormation will create the macro which we can use. You should deploy this template before using the created macro in a different template.

Resources:

  MacroLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: LoggingPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: '*'

  MacroLambda:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.handler
      Runtime: python3.8
      Role: !GetAtt MacroLambdaRole.Arn
      Code:
        ZipFile: |
          import json
          import logging
          from typing import Dict, Union, List
          LOGGER = logging.getLogger()
          LOGGER.setLevel(logging.INFO)
          def render_sub_map(sub_map: Dict, template_param_value: Dict):
              """
              ARGUMENTS:
              `sub_map` example:
                  {
                      "Tr::ListSub": [
                          "${Environment}-${ListSubItem}-${AWS::Account}"
                          ["VALUE1", "VALUE2", "VALUE3"],
                          {
                              "EXAMPLE": "test",
                          }
                      ]
                  }
              RETURNS
              [
                  {"Fn::Sub": ["${Environment}-VALUE1-${AWS::Account}", {"EXAMPLE": "test"}]},
                  {"Fn::Sub": ["${Environment}-VALUE2-${AWS::Account}", {"EXAMPLE": "test"}]},
                  {"Fn::Sub": ["${Environment}-VALUE3-${AWS::Account}", {"EXAMPLE": "test"}]}
              ]
              """
              sub_str = sub_map["Tr::ListSub"][0]
              sub_list = sub_map["Tr::ListSub"][1]
              if isinstance(sub_list, dict):
                  sub_list = template_param_value[sub_list["Ref"]]
              sub_dict = {}
              if len(sub_map["Tr::ListSub"]) == 3:
                  sub_dict = sub_map["Tr::ListSub"][2]
              results = []
              for item in sub_list:
                  new_sub_str = sub_str.replace("${ListSubItem}", item)
                  results.append({
                      "Fn::Sub": [
                          new_sub_str,
                          sub_dict
                      ]
                  })
              return results
          
          def iterate_resources(resources: Dict, template_param_value: Dict) -> None:
              """ Distructive to `fragment`. """
              def _recurse_search(_dict_or_list: Union[Dict, List]):
                  
                  if isinstance(_dict_or_list, dict):
                      for key, val in _dict_or_list.items():
                          if isinstance(val, dict):
                              if "Tr::ListSub" in val:
                                  _dict_or_list[key] = render_sub_map(val, template_param_value)
                              else:
                                  _recurse_search(val)
                          else:
                              _recurse_search(val)
                  elif isinstance(_dict_or_list, list):
                      for item in _dict_or_list:
                          _recurse_search(item)
              _recurse_search(resources)
          
          def handler(event, context):
              LOGGER.info(json.dumps(event, indent=2))
              iterate_resources(event["fragment"]["Resources"], event["templateParameterValues"])
              response = {
                  "status": "success",
                  "fragment": event["fragment"],
                  "requestId": event["requestId"]
              }
              LOGGER.info(json.dumps(response, indent=2))
              return response

  MacroExample:    
    Type: AWS::CloudFormation::Macro
    Properties:
      Name: ListSubTransform
      Description: Macro to create do a sub expression for each item in a list.
      FunctionName: !GetAtt MacroLambda.Arn

  MacroLambdaLogStream:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${MacroLambda}
      RetentionInDays: 3

So lets explain how this macro works. You can see some examples below.

The macro added is called Tr::ListSub.

  • The first argument is a string such as one you would provide for !Sub/Fn::Sub but without the prepended !Sub. The string can contain any variable that you could add to a normal !Sub such as ${AWS::Account}; but, to reference each item in the list as it iterates, use ${ListSubItem}.
  • The second argument is the list of items to iterate over. In my example below I !Ref a CommaDelimitedList parameter.
  • The third optional argument is a dictionary which is key value pairs which will be added to the generated !Sub expressions.

That description is kind of hard to follow so here are a couple examples for reference.

Lets say my CloudFormation has the following:

{
    "Tr::ListSub": [
        "${Environment}-${ListSubItem}-${AWS::Account}"
        ["VALUE1", "VALUE2", "VALUE3"],
        {
            "EXAMPLE": "test",
        }
    ]
}

This macro would then swap out that intrinsic with the following:

[
    {"Fn::Sub": ["${Environment}-VALUE1-${AWS::Account}", {"EXAMPLE": "test"}]},
    {"Fn::Sub": ["${Environment}-VALUE2-${AWS::Account}", {"EXAMPLE": "test"}]},
    {"Fn::Sub": ["${Environment}-VALUE3-${AWS::Account}", {"EXAMPLE": "test"}]}
]

Full Cloudformation Template Example:

Parameters:
  Environment:
    Type: String
  ChildAccounts:
    Type: CommaDelimitedList
    Default: ""
    Description: Comma delimited list of accounts.
Transform:
  - ListSubTransform
Resources:
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: !Sub ${AWS::StackName}-STS-Policy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - sts:AssumeRole
              Resource:
                Tr::ListSub:
                  - arn:aws:iam::${ListSubItem}:role/${Environment}-MetricMolePup
                  - !Ref ChildAccounts

This macro isn't bullet proof but does the job unless you try to nest it inside intrinsics in which case AWS will error out before the transform is called.

Upvotes: 2

Marcin
Marcin

Reputation: 238467

I think there is no obvious way of doing this. In the original Join:

              - inner: !Join
                - ':role/my-role,arn:aws:iam::'
                - Ref: 'MyAccounts'

the part ':role/my-role,arn:aws:iam::' is Join's delimiter. From the docs:

For the Fn::Join delimiter, you cannot use any functions. You must specify a string value.

In your new join you are using empty delimiter. You must use ':role/my-role,arn:aws:iam::' as delimiter, it can't be empty string ''.

Ideally you would use:

              - inner: !Join
                - !Sub ':role/${RoleName},arn:aws:iam::'
                - Ref: 'MyAccounts'

but this will also not work due to the delimiter not being a string.

I think the best workaround to this issue would be to use basic find-and-replace macro, especially if you want to use this pattern of role and accounts combo in multiple templates.

Upvotes: 0

Related Questions