Reputation: 410
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
Reputation: 7887
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
.
!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}
.!Ref
a CommaDelimitedList
parameter.!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
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