Conditional IAM Roles

Giving Consuming Teams A Choice

Generally IAM roles are all-or-nothing deals, you either accept or reject the policies in their entirety. Here we look at leveraging CloudFormation conditions to build a configurable IAM role, that results in a more configurable and testable method for controlling role scope.

Where Might This Be Useful

When controlling IAM roles within our sphere of control we follow the principle of least privilege, deploying exactly what is required to perform our tasks, which is relatively simple. When we tread over into our sphere of influence it becomes far harder.

Take the example of wanting to enforce compliance standards across a large estate, as you build up trust with the team they may allow you to selectively enable automatic controls to remediate non-compliant resources. These can be things like automatically shutting down public S3 buckets, ensuring VPC flow logs are on, or deleting default VPCs. But as a delivery team with existing resources, I need to be able to slow bleed such actions into my environments, but how can I do that autonomously and with control?

Potentially the disparate permissions could be split into many roles deployed individually, but that starts to confer an ever increasing maintenance burden, and significantly complicates matters for the compliance team. From their perspective I want to deploy one standard resource into all environments, and let the delivery team autonomously decide what is enabled.

Onwards To A Solution

CloudFormation Conditions

One of the lesser known CloudFormation options, Conditions allow us to selectively deploy resources. Let’s look at a simple example:

Parameters:
  MasterAccount:
    Type: String
  BlockPublicRDP:
    Type: String

Conditions:
  ShouldBlockPublicRDP: !Equals [!Ref BlockPublicRDP, "Enabled"]

Resources:
  CrossAccountRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: CrossAccountRole
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              AWS:
                - !Sub "arn:aws:iam::${MasterAccount}:root"
            Action:
              - "sts:AssumeRole"

  BlockPublicRDPPolicy:
    Condition: ShouldBlockPublicRDP
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: BlockPublicRDP
      Roles:
        - !Ref CrossAccountRole
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - "ec2:RevokeSecurityGroupEgress"
              - "ec2:RevokeSecurityGroupIngress"
            Resource: "*"
  • We have a parameter BlockPublicRDP which gets compared against “Enabled”
  • If true, we deploy the BlockPublicRDPPolicy resource

Tuning Up The Parameters

The above code although simple, is somewhat naive. We can improve it already by applying some constraints to the parameters.

Parameters:
  MasterAccount:
    AllowedPattern: ^\d{12}$
    Type: String
  BlockPublicRDP:
    AllowedValues: ["Enabled", "Disabled"]
    ConstraintDescription: Must be Enabled or Disabled
    Default: Disabled
    Type: String
  • The regex on master account locks it down to valid AWS account Ids
  • By adding a Disabled default we make our policies opt-in for safety
  • By setting allowed values we will explicitly only handle Enabled or Disabled

Scalably Passing Parameters

Anyone that’s passed parameters over the CLI will understand how unwieldly it becomes. The scalable way to pass parameters is with a json file committed into source control. To that end let’s look at the files are formatted:

[
    {
        "ParameterKey": "MasterAccount",
        "ParameterValue": "111122223333"
    },
    {
        "ParameterKey": "BlockPublicRDP",
        "ParameterValue": "Enabled"
    }
]

Now we can deploy the template with commands of the form:

aws cloudformation create/update-stack --stack-name ConditionalRole --template-body file://role.yaml --parameters file://parameters.json --capabilities CAPABILITY_NAMED_IAM

CloudFormation Conditions for IAM Conditions

One of the lesser used be more interesting parts of IAM policies are the condition statements that can be applied. Taking our above example a step further, what if we wanted to lock down what security groups we could edit by tag. In the policy we need to extend the statement to include a condition stanza so it looks like:

BlockPublicRDPPolicy:
  Condition: ShouldBlockPublicRDP
  Type: AWS::IAM::ManagedPolicy
  Properties:
    ManagedPolicyName: BlockPublicRDP
    Roles:
      - !Ref CrossAccountRole
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Action:
            - "ec2:RevokeSecurityGroupEgress"
            - "ec2:RevokeSecurityGroupIngress"
          Resource: "*"
          Condition:
            StringEquals:
              "ec2:ResourceTag/Owner": !Ref SecurityGroupTagValue

Now we can pass in an extra parameter to lock down the policy, but we need to maintain the ability to open it up to acting on all security groups. So let’s add another condition to control out condition…

[
    {
        "ParameterKey": "MasterAccount",
        "ParameterValue": "111122223333"
    },
    {
        "ParameterKey": "BlockPublicRDP",
        "ParameterValue": "Enabled"
    },
    {
        "ParameterKey": "SecurityTagRestriction",
        "ParameterValue": "Enabled"
    },
    {
        "ParameterKey": "SecurityGroupTagValue",
        "ParameterValue": "JoshArmi"
    }
]
Parameters:
  MasterAccount:
    AllowedPattern: ^\d{12}$
    Type: String
  BlockPublicRDP:
    AllowedValues: ["Enabled", "Disabled"]
    ConstraintDescription: Must be Enabled or Disabled
    Default: Disabled
    Type: String
  SecurityTagRestriction:
    AllowedValues: ["Enabled", "Disabled"]
    ConstraintDescription: Must be Enabled or Disabled
    Default: Disabled
    Type: String
  SecurityGroupTagValue:
    Type: String
    Default: Disabled

Conditions:
  ShouldBlockPublicRDP: !Equals [!Ref BlockPublicRDP, "Enabled"]
  RestrictedByTags: !Equals [!Ref SecurityTagRestriction, "Enabled"]

Resources:
  CrossAccountRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: CrossAccountRole
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              AWS:
                - !Sub "arn:aws:iam::${MasterAccount}:root"
            Action:
              - "sts:AssumeRole"

  BlockPublicRDPPolicy:
    Condition: ShouldBlockPublicRDP
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: BlockPublicRDP
      Roles:
        - !Ref CrossAccountRole
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - "ec2:RevokeSecurityGroupEgress"
              - "ec2:RevokeSecurityGroupIngress"
            Resource: "*"
            Condition: !If
              - RestrictedByTags
              - StringEquals:
                  ec2:ResourceTag/Owner: !Ref SecurityGroupTagValue
              - !Ref AWS::NoValue

Now if we set SecurityTagRestriction to Enabled then the condition is applied. If it is set to Disabled then by the magic of AWS::NoValue the Condition property disappears and it’s like it never existed at all.

Making The Configuration Consumable

So we’ve given the delivery teams a capbility to select known IAM configurations by only touching the parameter json file, which is a significant win, rather than hand editing the yaml with potentially disastrous and unforseen consequences, we’ve locked down the possible number of states of the role and given something which is inherently more testable.

But once again donning the hat of the compliance team how do we determine what configuration is in flight in a given account, there is one missing permission from the role which enables us to understand the configuration:

ListPolicies:
  Type: AWS::IAM::ManagedPolicy
  Properties:
    ManagedPolicyName: ListRolePolicies
    Roles:
      - !Ref CrossAccountRole
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Action:
            - "iam:ListAttachedRolePolicies"
          Resource: !GetAtt CrossAccountRole.Arn

With that permission in place we can list the policies attached to the role, allowing us to determine the state of play in any given account.

Assuming the role and running aws iam list-attached-role-policies --role-name CrossAccountRole returns:

{
    "AttachedPolicies": [
        {
            "PolicyName": "BlockPublicRDP",
            "PolicyArn": "arn:aws:iam::285525127666:policy/BlockPublicRDP"
        },
        {
            "PolicyName": "ListRolePolicies",
            "PolicyArn": "arn:aws:iam::285525127666:policy/ListRolePolicies"
        }
    ]
}

Click Here For Full Source

Conclusion

This feels like a good, low investment solution for managing IAM at scale that is consumed by other delivery teams, they can opt-in to IAM on their terms, which also gives them the option of opt-out in the case of an issue.

By making the configuration done via parameters in the json file, we can provide an interface that reduces the burden on the consuming teams whilst also giving the compliance team a more manageable surface to test compared to hand-editing templates.

Although CloudFormation being CloudFormation, I did revisit and old bug-bear which still seems to be an issue as explained below:

Configuring The Tag Key

The eagle eyed amongst you will see that potentially you might want to configure the key of the tag as well as the value, unfortunately it does not appear that computed keys are allowed in CloudFormation. So consider this another addition to my #awswishlist