Backstory

Typically, we developers, approach the world with an overly optimistic approach to solving certain problems, especially when it comes to security within our apps. “Oh, we’ll just use XYZ to make it secure” we’ll say. But this isn’t always true.

An interesting challenge popped up recently around the ‘best’ way of integrating an external build system with AWS; where ‘best’ was how to minimise the risk exposed by long lived credentials stored within an external system. THe fun part of this challenge was when I got to put on my pessimist’s hat to consider all the nefarious ways these credentials could be abused.

The silver lining in this situation, and something that is sadly missing from the external tools I typically use, was the ability to configure more than the usual access key and secret key. In addition, I could also specify the following:

  • role arn
  • role session name; and
  • external id

With these five values I pieced together a little demo CloudFormation template. It is such a nice pattern for these situations that I wanted to share it with the world! 😄

Interesting Bits

Rather than a long, boring, and unwieldy template, I’ll just highlight the interesting bits. The full template can be viewed in this GitHub repository.

The first snippet explicitly Denies all actions except for the explicitly Allowed sts:AssumeRole. This is further restricted to only assume the specific IAM Role defined later in the template.

  UserPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: 'ExplicitlyDenyAllActionsExceptAssumeRole'
            Effect: Deny
            NotAction: ['sts:AssumeRole']
            Resource: '*'
          - Sid: 'ExplicitlyAllowAssumeRole'
            Effect: Allow
            Action: ['sts:AssumeRole']
            Resource: !GetAtt Role.Arn

The next restriction is applied in the role’s AssumeRolePolicyDocument statement. The role can only be assumed by the specific IAM User defined in the template. Additionally, there is also a reference to a Permission Boundary. This restricts what the role can do. For example, you can use it to prevent privilege escalation, i.e., the role cannot by abused to create another role with full administration permissions. IAM Conditions are also used to ensure that a specific RoleSessionName and ExternalId are passed when assuming the role.

  Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt User.Arn
            Action:
              - 'sts:AssumeRole'
            Condition:
              StringLike:
                'sts:RoleSessionName':
                  !Ref ParamRoleSessionName
                'sts:ExternalId':
                  !Ref ParamExternalId
      PermissionsBoundary: !Ref PermissionBoundary

The example permission boundary below explicitly denies all actions except for S3 Bucket actions. Keep in mind that all permissions are implicitly denied; so, the explicit deny needs to be paired with an appropriate explicit allow for S3.

  PermissionBoundary:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      Description: String
      ManagedPolicyName: !Sub '${ParamBaseName}PermissionBoundaryPolicy'
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Deny
            NotAction: 's3:*'
            Resource: '*'
          - Effect: Allow
            NotAction:
              - 's3:*'
            Resource: '*'

Finally, there is the policy for the IAM Role. In this policy I explicitly deny all S3 actions that do not relate to buckets. I then add the explicit Allow for allowed bucket actions. I also try and slip in an extra iam:ListRoles permission. This will not be allowed due to the explicit deny in the preceding permissions boundary.

  RolePolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Deny
            NotAction: 's3:*Bucket*'
            Resource: '*'
          - Effect: Allow
            NotAction:
              - 's3:List*Bucket*'
              - 's3:Get*Bucket*'
              - 's3:Describe*Bucket*'
              - 'iam:ListRoles'
            Resource: '*'

Tests

As we are all good little developers who practice Test Driven Development, here is how to verify that the protection mechanisms work (the full scripts are included in the GitHub repository):

The following four assume-role calls all fail as none of them pass a valid role-session-name and external-id pair:

aws sts assume-role --role-arn "${ROLE_ARN}" --role-session-name "${MY_PARAM_ROLE_SESSION_NAME}"
aws sts assume-role --role-arn "${ROLE_ARN}" --role-session-name "${MY_PARAM_ROLE_SESSION_NAME}_" --external-id  "${MY_PARAM_EXTERNAL_ID}_"
aws sts assume-role --role-arn "${ROLE_ARN}" --role-session-name "${MY_PARAM_ROLE_SESSION_NAME}_" --external-id  "${MY_PARAM_EXTERNAL_ID}"
aws sts assume-role --role-arn "${ROLE_ARN}" --role-session-name "${MY_PARAM_ROLE_SESSION_NAME}" --external-id  "${MY_PARAM_EXTERNAL_ID}_"

The error returned will look like this:

An error occurred (AccessDenied) when calling the AssumeRole operation:
User: arn:aws:iam::111111111111:user/${MY_PARAM_BASE_NAME}
is not authorized to perform: sts:AssumeRole on resource:
arn:aws:iam::111111111111:role/${MY_PARAM_BASE_NAME}Role

Once the valid parameters are passed, we can then verify the permission boundary like so:

aws sts assume-role --role-arn "${ROLE_ARN}" --role-session-name "${MY_PARAM_ROLE_SESSION_NAME}" --external-id "${MY_PARAM_EXTERNAL_ID}"

# Test: Allowed, lists all the S3 buckets in the account
aws s3api list-buckets

# Test: Attempts to create a new bucket, which is allowed by the permission boundary but denied by the IAM Role policy
aws s3api create-bucket --bucket "this-should-fail"
# Response: An error occurred (IllegalLocationConstraintException) when calling the CreateBucket operation: The unspecified location constraint is incompatible for the region specific endpoint this request was sent to.

# Test: Attempts to list IAM Roles; this is denied by the permission boundary even though it is allowed by the IAM Role policy
aws iam list-roles
# Response: An error occurred (AccessDenied) when calling the ListRoles operation: User: arn:aws:sts::111111111111:assumed-role/${MY_PARAM_BASE_NAME}role/${MY_PARAM_BASE_NAME} is not authorized to perform: iam:ListRoles on resource: arn:aws:iam::111111111111:role/ with an explicit deny

Conclusion

In case you missed it, the full template and testing script can be found in the GitHub repository.

I hope you found this pattern useful! If you did please, or think it could be improved, let me know! All feedback welcome!