Matt Karmazyn

# CloudFormation StackSets: Deploy GitHub OIDC Role

Use AWS CloudFormation StackSets to deploy the GithubActionsTerraformExecution role to all member accounts in a single operation.


# Why StackSets?


# Prerequisites


# Step 1: Create the CloudFormation Template

Create member-account-foundation-stackset.yaml:

AWSTemplateFormatVersion: '2010-09-09'
Description: GitHub Actions OIDC Terraform execution role and state bucket for member accounts

Parameters:
  ManagementAccountId:
    Type: String
    Description: Management account ID where OIDC provider lives
  
  GitHubOrg:
    Type: String
    Description: GitHub organization or username
    Default: ItsKarma
  
  GitHubRepo:
    Type: String
    Description: GitHub repository name (without org prefix)
    Default: infrastructure
  
  OrganizationName:
    Type: String
    Description: Organization or company name for bucket naming
    Default: itskarma

Resources:
Resources:
  # S3 Bucket for Terraform State (per member account)
  TerraformStateBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${OrganizationName}-${AWS::AccountId}-terraform-state'
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: DeleteOldVersions
            Status: Enabled
            NoncurrentVersionExpirationInDays: 90
      Tags:
        - Key: Name
          Value: Terraform State Bucket
        - Key: ManagedBy
          Value: CloudFormation-StackSet

  # Bucket policy to enforce encryption
  TerraformStateBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref TerraformStateBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: DenyUnencryptedObjectUploads
            Effect: Deny
            Principal: '*'
            Action: s3:PutObject
            Resource: !Sub '${TerraformStateBucket.Arn}/*'
            Condition:
              StringNotEquals:
                s3:x-amz-server-side-encryption: AES256
          - Sid: DenyInsecureTransport
            Effect: Deny
            Principal: '*'
            Action: s3:*
            Resource:
              - !GetAtt TerraformStateBucket.Arn
              - !Sub '${TerraformStateBucket.Arn}/*'
            Condition:
              Bool:
                aws:SecureTransport: false

  # GitHub Actions Role
  GithubActionsTerraformExecution:
    Type: AWS::IAM::Role
    Properties:
      RoleName: GithubActionsTerraformExecution
      Description: Allows GitHub Actions to run Terraform in this member account
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          # Allow GitHub OIDC to assume this role directly
          - Sid: AllowGitHubOIDC
            Effect: Allow
            Principal:
              Federated: !Sub 'arn:aws:iam::${ManagementAccountId}:oidc-provider/token.actions.githubusercontent.com'
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
              StringLike:
                token.actions.githubusercontent.com:sub:
                  - !Sub 'repo:${GitHubOrg}/${GitHubRepo}:ref:refs/heads/main'
                  - !Sub 'repo:${GitHubOrg}/${GitHubRepo}:pull_request'
          
          # Allow management account role to assume this role (optional chain)
          - Sid: AllowManagementAccountRole
            Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${ManagementAccountId}:role/GithubActionsOrgRole'
            Action: sts:AssumeRole
      
      Tags:
        - Key: ManagedBy
          Value: CloudFormation-StackSet
        - Key: Purpose
          Value: GitHub-Actions-Terraform

  # Policy for Terraform state backend access
  TerraformStateAccess:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: TerraformStateBackendAccess
      Roles:
        - !Ref GithubActionsTerraformExecution
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          # Access to local member account state bucket only
          - Sid: LocalStateAccess
            Effect: Allow
            Action:
              - s3:GetObject
              - s3:PutObject
              - s3:DeleteObject
              - s3:ListBucket
            Resource:
              - !GetAtt TerraformStateBucket.Arn
              - !Sub '${TerraformStateBucket.Arn}/*'

  # Terraform execution permissions (customize per account needs)
  TerraformExecutionPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: TerraformExecutionPermissions
      Roles:
        - !Ref GithubActionsTerraformExecution
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          # VPC and Networking
          - Sid: VPCManagement
            Effect: Allow
            Action:
              - ec2:*Vpc*
              - ec2:*Subnet*
              - ec2:*InternetGateway*
              - ec2:*NatGateway*
              - ec2:*RouteTable*
              - ec2:*SecurityGroup*
              - ec2:*NetworkAcl*
              - ec2:*ElasticIp*
              - ec2:Describe*
              - ec2:CreateTags
              - ec2:DeleteTags
            Resource: '*'
          
          # EKS and Container Services
          - Sid: EKSManagement
            Effect: Allow
            Action:
              - eks:*
              - ecr:*
              - ecs:*
            Resource: '*'
          
          # Load Balancing and Auto Scaling
          - Sid: LoadBalancingAndAutoScaling
            Effect: Allow
            Action:
              - elasticloadbalancing:*
              - autoscaling:*
            Resource: '*'
          
          # IAM (limited to service roles)
          - Sid: IAMServiceRoles
            Effect: Allow
            Action:
              - iam:GetRole
              - iam:GetRolePolicy
              - iam:ListAttachedRolePolicies
              - iam:ListRolePolicies
              - iam:CreateRole
              - iam:DeleteRole
              - iam:AttachRolePolicy
              - iam:DetachRolePolicy
              - iam:PutRolePolicy
              - iam:DeleteRolePolicy
              - iam:PassRole
              - iam:TagRole
              - iam:UntagRole
              - iam:CreatePolicy
              - iam:DeletePolicy
              - iam:CreatePolicyVersion
              - iam:DeletePolicyVersion
              - iam:GetPolicy
              - iam:GetPolicyVersion
              - iam:ListPolicyVersions
            Resource: '*'
            Condition:
              StringEquals:
                iam:PassedToService:
                  - eks.amazonaws.com
                  - ec2.amazonaws.com
                  - ecs.amazonaws.com
                  - lambda.amazonaws.com
          
          # S3 (for application buckets, not state)
          - Sid: S3Management
            Effect: Allow
            Action:
              - s3:*
            Resource: '*'
          
          # KMS for encryption
          - Sid: KMSManagement
            Effect: Allow
            Action:
              - kms:Create*
              - kms:Describe*
              - kms:Enable*
              - kms:List*
              - kms:Put*
              - kms:Update*
              - kms:Revoke*
              - kms:Disable*
              - kms:Get*
              - kms:Delete*
              - kms:TagResource
              - kms:UntagResource
              - kms:ScheduleKeyDeletion
              - kms:CancelKeyDeletion
            Resource: '*'
          
          # CloudWatch Logs
          - Sid: CloudWatchLogs
            Effect: Allow
            Action:
              - logs:*
            Resource: '*'
          
          # Secrets Manager
          - Sid: SecretsManager
            Effect: Allow
            Action:
              - secretsmanager:*
            Resource: '*'

Outputs:
  RoleArn:
    Description: ARN of the GitHub Actions Terraform execution role
    Value: !GetAtt GithubActionsTerraformExecution.Arn
    Export:
      Name: GithubActionsTerraformExecutionRoleArn
  
  RoleName:
    Description: Name of the role
    Value: !Ref GithubActionsTerraformExecution
  
  StateBucketName:
    Description: Name of the Terraform state bucket
    Value: !Ref TerraformStateBucket
    Export:
      Name: TerraformStateBucketName

# Step 2: Create the StackSet

Note: Ensure StackSets trusted access is enabled before proceeding. This was done in Step 1 of the GitHub OIDC Connector guide.

Quick check (should show ENABLED):

aws organizations list-aws-service-access-for-organization | grep stacksets
aws cloudformation describe-organizations-access --query Status --output text

Replace placeholders with your values:

# Set your variables
export MGMT_ACCOUNT_ID="123456789012"
export GITHUB_ORG="ItsKarma"
export GITHUB_REPO="infrastructure"
export ORG_NAME="itskarma"

# Create the StackSet
aws cloudformation create-stack-set \
  --stack-set-name member-account-foundation \
  --template-body file://member-account-foundation-stackset.yaml \
  --parameters \
      ParameterKey=ManagementAccountId,ParameterValue=$MGMT_ACCOUNT_ID \
      ParameterKey=GitHubOrg,ParameterValue=$GITHUB_ORG \
      ParameterKey=GitHubRepo,ParameterValue=$GITHUB_REPO \
      ParameterKey=OrganizationName,ParameterValue=$ORG_NAME \
  --capabilities CAPABILITY_NAMED_IAM \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false

Expected output:

{
    "StackSetId": "member-account-foundation:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

# Step 3: Deploy to All Member Accounts

Deploy the role to your entire organization by targeting the root:

# Get your organization root ID
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
echo "Organization Root ID: $ROOT_ID"

# Deploy to entire organization (all member accounts)
aws cloudformation create-stack-instances \
  --stack-set-name member-account-foundation \
  --deployment-targets OrganizationalUnitIds=$ROOT_ID \
  --regions us-east-2

This will deploy the GithubActionsTerraformExecution role to all member accounts in your organization, including any new accounts added in the future (because we enabled auto-deployment).

State bucket naming: Buckets will be named {OrganizationName}-{AccountId}-terraform-state (e.g., itskarma-123456789012-terraform-state). Using Account IDs ensures unique bucket names and works seamlessly with auto-deployment for new accounts.

Note: The management account is automatically excluded - StackSets only deploy to member accounts.


# Step 4: Monitor Deployment

# Check StackSet status
aws cloudformation describe-stack-set \
  --stack-set-name member-account-foundation

# List all stack instances
aws cloudformation list-stack-instances \
  --stack-set-name member-account-foundation

# Get operation status
aws cloudformation list-stack-set-operations \
  --stack-set-name member-account-foundation

Wait for Status: SUCCEEDED on all instances (typically 2-5 minutes).


# Step 5: Verify in Member Accounts

Switch to a member account (via AWS Console or aws sso login) and check:

# List roles in member account
aws iam list-roles --query 'Roles[?RoleName==`GithubActionsTerraformExecution`]'

# Get role details
aws iam get-role --role-name GithubActionsTerraformExecution

# Verify trust policy includes GitHub OIDC
aws iam get-role --role-name GithubActionsTerraformExecution \
  --query 'Role.AssumeRolePolicyDocument'

# Updating the Role

When you need to change permissions or trust policy:

# Update the YAML template
vim member-account-foundation-stackset.yaml

# Update the StackSet
aws cloudformation update-stack-set \
  --stack-set-name member-account-foundation \
  --template-body file://member-account-foundation-stackset.yaml \
  --parameters \
      ParameterKey=ManagementAccountId,UsePreviousValue=true \
      ParameterKey=GitHubOrg,UsePreviousValue=true \
      ParameterKey=GitHubRepo,UsePreviousValue=true \
      ParameterKey=OrganizationName,UsePreviousValue=true \
  --capabilities CAPABILITY_NAMED_IAM \
  --permission-model SERVICE_MANAGED

# Deploy the update to all instances
aws cloudformation create-stack-instances \
  --stack-set-name member-account-foundation \
  --deployment-targets OrganizationalUnitIds=ou-xxxx-yyyyyyyy \
  --regions us-east-2 \
  --operation-preferences MaxConcurrentCount=5

# New Member Accounts

If you used --auto-deployment Enabled=true, new accounts added to the OU automatically get the role within minutes.

If you already had an existing account before following this guide, you can manually add an account:

aws cloudformation create-stack-instances \
  --stack-set-name member-account-foundation \
  --accounts 444444444444 \
  --regions us-east-2

# Security Notes


# Troubleshooting

# StackSet creation fails with permissions error

Verify trusted access is enabled:

aws organizations list-aws-service-access-for-organization | grep stacksets
aws cloudformation describe-organizations-access --query Status --output text

If not enabled, enable both toggles:

aws organizations enable-aws-service-access \
  --service-principal member.org.stacksets.cloudformation.amazonaws.com
aws cloudformation activate-organizations-access

# Stack instance stuck in OUTDATED status

The account may not have the required execution role. Check:

aws cloudformation describe-stack-set-operation \
  --stack-set-name member-account-foundation \
  --operation-id <operation-id>

# GitHub Actions can't assume the role


# What We Accomplished

✅ Deployed GithubActionsTerraformExecution role to all member accounts
✅ Created per-account S3 state buckets with versioning and encryption
✅ Role has state backend access to its own account's bucket only (isolated state)
✅ Predictable bucket naming using account IDs (works with auto-deployment)
✅ Auto-deployment enabled for new accounts
✅ No per-account manual Terraform runs needed
✅ Centralized management via StackSet


← GitHub OIDC Connector Terraform State Setup →