Skip to content

Deploy IAM Roles across an AWS Organization as code

In environments with multiple AWS accounts, managing roles can be a challenge. AWS IAM offers us a robust solution for managing roles within each account, but when it comes to consistently implementing roles across all accounts in an organization, the task can become complex.

In this post, we will see how to automatically deploy IAM roles across all accounts in an AWS organization as code, using CloudFormation, Organizations, and Terraform.

architecture architecture

It is quite common to have tools that require specific IAM permissions in the accounts where we want to implement them, such as an internal tool that needs access to all our accounts. When it comes time to update these permissions, maintaining them can be quite a challenge. With the help of AWS Organizations and AWS CloudFormation StackSets, we can solve this problem.

To explain the steps, we will use a specific example. Our goal is to deploy the role security-cspm, with two policies:

  • SecurityAudit: AWS managed policy.
  • security-cspm-sechub-import: Customer managed policy, with the following permissions:
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "SecurityHubImport",
          "Effect": "Allow",
          "Action": [
            "securityhub:BatchImportFindings"
          ],
          "Resource": "*"
        }
      ]
    }
    

Requirements

We will need to have an AWS organization created, and our accounts must be members of this organization.

Once we have the organization, we will need to enable the Enable All Features option. If you manage your organization as code 👍, you can create the organization and enable all features using the aws_organizations_organization resource:

resource "aws_organizations_organization" "org" {
  ...
  feature_set = "ALL"
}

Enable Trusted Access

Next, we will need to enable the AWS CloudFormation StackSets integration with AWS Organizations.

For some reason unknown to us, this step requires manual action 😢. Enabling trusted access through Terraform or from the organizations menu is not sufficient.

Enable Trusted Access Issue

You need to go to the CloudFormation console > StackSets and click on Activate trusted access. Activate Trusted Access

If you used Terraform to create your organization, you will need to add the following service principal in the aws_service_access_principals argument of the aws_organizations_organization resource, e.g.:

resource "aws_organizations_organization" "org" {
  aws_service_access_principals = [
    ...
    "member.org.stacksets.cloudformation.amazonaws.com",
    ...
  ]

  feature_set = "ALL"
}

Warning

Remember that you must include the new service principal along with the existing ones. To verify which are already enabled, you can use the following command:

aws organizations list-aws-service-access-for-organization

Delegate CloudFormation StackSets

To avoid performing deployments from the management account of our organization, we will delegate the administration of StackSets to another account, from which we will create and manage the necessary CloudFormation resources.

Tip

Remember that we should try to minimize the use of the management account as much as possible. Well Architected Framework - Security Pillar SEC01-BP02

To delegate the service, you can use the following Terraform resource:

resource "aws_organizations_delegated_administrator" "stacksets" {
  account_id        = var.security_account_id
  service_principal = "member.org.stacksets.cloudformation.amazonaws.com"
}

Alternatively, you can use the following command if you do not manage the management account as code:

aws organizations register-delegated-administrator \
  --service-principal=member.org.stacksets.cloudformation.amazonaws.com \
  --account-id="securityAccountId"

This action only needs to be done once, and from a single region. You can verify that the delegation has been successfully applied with the following command:

aws organizations list-delegated-administrators \
  --service-principal=member.org.stacksets.cloudformation.amazonaws.com
Output
{
  "DelegatedAdministrators": [
    {
      "Id": "222222222222",
      "Arn": "arn:aws:organizations::111111111111:account/o-myorgid/222222222222",
      "Email": "[email protected]",
      "Name": "security-account",
      "Status": "ACTIVE",
      "JoinedMethod": "CREATED",
      "JoinedTimestamp": "2024-10-11T16:30:05.938000+02:00",
      "DelegationEnabledDate": "2024-10-11T16:48:07.033000+02:00"
    }
  ]
}

Deploy Cross-Account IAM Roles

Once we have the service integrated with AWS Organizations and delegated to our account, the first thing we need to do is define the resources in a CloudFormation template, to do this, we will create a role.yaml file, which we will later reference from Terraform. The following file declares the IAM role and policies that we want:

role.yaml
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  RoleName:
    Type: String
    Description: IAM Role Name
  RoleDescription:
    Type: String
    Description: IAM Role Description
    Default: ""
  PolicyName:
    Type: String
    Description: IAM Managed read and write policy name
  PolicyDescription:
    Type: String
    Description: IAM Managed Policy Description
    Default: ""
  IAMPath:
    Type: String
    Description: IAM Role and Policy Path
    Default: "/"
  TrustedAccount:
    Type: String
    Description: AWS trusted account ID
  TrustedRole:
    Type: String
    Description: Allowed IAM Role ARN
Resources:
  Role:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: !Ref RoleName
      Description: !Ref RoleDescription
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                !Ref TrustedAccount
            Action:
              - 'sts:AssumeRole'
            Condition:
              ArnLike:
                'aws:PrincipalArn': !Ref TrustedRole
      Path: !Ref IAMPath
      ManagedPolicyArns:
        - !Ref RolePolicy
        - arn:aws:iam::aws:policy/SecurityAudit
  RolePolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Ref PolicyName
      Description: !Ref PolicyDescription
      Path: !Ref IAMPath
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: SecurityHubImport
            Effect: Allow
            Action:
              - securityhub:BatchImportFindings
            Resource: '*'

Next, we will need to define the CloudFormation StackSet, using the file we just created as the template:

data "aws_caller_identity" "this" {}

resource "aws_cloudformation_stack_set" "cspm_role" {
  name             = "security-cspm-role"
  description      = "Deploy Security CSPM Role across all organization accounts"
  permission_model = "SERVICE_MANAGED" // (1)!
  call_as          = "DELEGATED_ADMIN" // (2)!

  auto_deployment {
    enabled = true // (3)!
  }

  capabilities = [
    "CAPABILITY_NAMED_IAM", // (4)!
  ]

  template_body = file("${path.root}/role.yaml") // (5)!
  parameters = { // (6)!
    RoleName          = "security-cspm"
    RoleDescription   = "Audit and Import findings to SecurityHub"
    PolicyName        = "security-cspm-sechub-import"
    PolicyDescription = "Allow import SecurityHub findings"
    IAMPath           = "/security/cspm/"
    TrustedAccount    = data.aws_caller_identity.this.id // (7)!
    TrustedRole       = "arn:aws:iam::${data.aws_caller_identity.this.id}:role/my-source-role"
  }
}
  1. Allows deploying CloudFormation resources in all accounts with a role managed by AWS Organizations.
  2. Specifies that the operation is performed from the delegated account.
  3. Enables automatic deployment of the role in new accounts added to the organization.
  4. Required when the template contains IAM resources managed by us.
  5. The template we created earlier.
  6. Defines the parameters specified in our role.yaml template.
  7. Uses the data source to automatically pass the account from which this code is executed.

And finally, we define the StackSet instances that will create the resource in the accounts:

data "aws_organizations_organization" "this" {}

resource "aws_cloudformation_stack_instances" "cspm_role" {
  stack_set_name = aws_cloudformation_stack_set.cspm_role.name
  call_as        = "DELEGATED_ADMIN"

  deployment_targets {
    organizational_unit_ids = [ data.aws_organizations_organization.this.roots[0].id ] // (1)!
  }
}
  1. We use the data source to automatically pass the organization ID.
Tip

It is recommended to use the operation_preferences arguments to improve efficiency when creating stacks in organizations with many accounts. This will allow us to parallelize account operations. For example:

resource "aws_cloudformation_stack_set" "cspm_role" {
  ...
  operation_preferences {
    max_concurrent_percentage    = 50
    failure_tolerance_percentage = 50
  }
  ...
}

resource "aws_cloudformation_stack_instances" "cspm_role" {
  ...
  operation_preferences {
    concurrency_mode          = "SOFT_FAILURE_TOLERANCE"
    max_concurrent_percentage = 50
  }
  ...
}

Once we have the code, we can run a terraform plan & terraform apply, and we're done! 🥳

As a summary, we leave you the following diagram with the architecture we have created: architecture architecture

Terraform Module

If you want to simplify this whole process, you can create a Terraform module like the one we have published. This way, you can reuse all the code by simply changing the template and parameters.

The module is available in the Terraform registry, so you can use it in your code as follows:

data "aws_caller_identity" "this" {}
data "aws_organizations_organization" "this" {}

module "cspm_role" {
  source = "unicrons/organization-iam-role/aws"

  stack_set_name        = "security-cspm-role"
  stack_set_description = "Deploy Security CSPM Role across all organization accounts"
  template_path         = "${path.root}/role.yaml"

  template_parameters = {
    RoleName          = "security-cspm"
    RoleDescription   = "Audit and Import findings to SecurityHub"
    PolicyName        = "security-cspm-sechub-import"
    PolicyDescription = "Allow import SecurityHub findings"
    IAMPath           = "/security/cspm/"
    TrustedAccount    = data.aws_caller_identity.this.id
    TrustedRole       = "arn:aws:iam::${data.aws_caller_identity.this.id}:role/my-source-role"
  }

  organizational_unit_ids = [ data.aws_organizations_organization.this.roots[0].id ]
}

To see all available configuration options, take a look at the input parameters.


And that's all folks! If you have any questions or comments, feel free to reach out to us.

Saludos, and may the force be with you.