·5 min read·#aws#iam#security#terraform#opentofu#cicd

Cross-Account Access Patterns - Deployment Roles and Role Chaining

Cross-Account Access Patterns - Deployment Roles and Role Chaining architecture diagram

A multi-account setup requires cross-account access. CI/CD pipelines need to deploy to multiple accounts. Identity management needs to create roles in member accounts. DNS management needs to access zones in the DNS account.

We use three mechanisms to make this work safely: deployment roles, role chaining, and external ID protection.

The Role Assumption Model

Cross-account access in AWS works through role assumption:

aws-account-structure-part-8-cross-account/role-assumption-flow diagram
  1. Principal (user or role) in Account A wants to access Account B
  2. Account B has an IAM role with a trust policy allowing Account A
  3. Principal calls sts:AssumeRole to get temporary credentials
  4. Temporary credentials allow actions in Account B

The key is the trust policy - it controls who can assume the role.

Deployment Roles

We create specific deployment roles for each service/account combination:

IdentityManagementDeploymentRole

Lives in the management account. Used by CI/CD to manage Identity Center:

hcl
resource "aws_iam_role" "identity_management_deployment" {
  name = "IdentityManagementDeploymentRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      # GitHub Actions can assume this role
      {
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::123456789012:role/github-actions"
        }
        Action = "sts:AssumeRole"
      },
      # SSO users with the deployment permission set can also assume it
      {
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::123456789012:root"
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringLike = {
            "aws:userid": "AROA*:IdentityMgmtDeploymentAccess/*"
          }
        }
      }
    ]
  })
}

The trust policy allows two principals:

  • The GitHub Actions OIDC role
  • SSO users who have the IdentityMgmtDeploymentAccess permission set

Scoped Permissions

Instead of AdministratorAccess, we scope permissions to what the deployment actually needs:

hcl
resource "aws_iam_role_policy" "identity_management_deployment" {
  role = aws_iam_role.identity_management_deployment.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "sso:*",
          "sso-directory:*",
          "identitystore:*",
          "organizations:ListAccounts",
          "organizations:DescribeAccount",
          "organizations:DescribeOrganization"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "iam:GetRole",
          "iam:CreateRole",
          "iam:DeleteRole",
          "iam:AttachRolePolicy",
          "iam:DetachRolePolicy",
          "iam:PutRolePolicy",
          "iam:DeleteRolePolicy"
        ]
        Resource = "arn:aws:iam::*:role/*Deployment*"
      },
      {
        Effect = "Allow"
        Action = ["s3:*"]
        Resource = [
          "arn:aws:s3:::myorg-terraform-state-*",
          "arn:aws:s3:::myorg-terraform-state-*/*"
        ]
      }
    ]
  })
}

This role can manage Identity Center resources and Terraform state, but can't do arbitrary IAM changes or access unrelated services.

Cross-Account Deployment Roles

For member accounts, we create deployment roles that trust the management account:

DNSDeploymentRole

Lives in the DNS account:

hcl
resource "aws_iam_role" "dns_deployment" {
  provider = aws.dns_management
  name     = "DNSDeploymentRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::123456789012:role/github-actions"
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            "sts:ExternalId" = var.dns_deployment_external_id
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "dns_deployment" {
  provider = aws.dns_management
  role     = aws_iam_role.dns_deployment.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "route53:*"
        Resource = [
          "arn:aws:route53:::hostedzone/Z1234567890ABC",
          "arn:aws:route53:::hostedzone/Z0987654321XYZ"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "route53:ListHostedZones",
          "route53:GetChange"
        ]
        Resource = "*"
      }
    ]
  })
}

Key elements:

External ID requirement: The trust policy requires a role-specific external ID (sts:ExternalId = var.dns_deployment_external_id). This prevents confused deputy attacks.

Scoped zone access: The role can only modify specific hosted zones, not create new ones or access other zones.

Multi-provider deployment: Using provider = aws.dns_management deploys this role in the DNS account, not the management account.

External IDs Explained

External IDs protect against confused deputy attacks. Here's the scenario without external IDs:

  1. Attacker creates AWS account
  2. Attacker creates role trusting your GitHub Actions role ARN
  3. Attacker tricks your CI/CD into assuming their role
  4. Your credentials now work in attacker's account

With external IDs:

  1. Your role requires a specific external ID value
  2. Attacker doesn't know this value
  3. Attacker's trust policy can't require the same external ID
  4. Attack fails

External ID guidance:

RuleWhy
Use one external ID per trust relationshipPrevents accidental role crossover between systems
Keep values non-trivial and managed like configReduces guessing and copy/paste mistakes
Treat as shared control data, not a secretBoth caller and target account must know it

Role Chaining

Role chaining is when a role assumes another role. The CI/CD flow:

GitHub Actions → OIDC Role → Deployment Role → (actions)

The GitHub Actions workflow:

yaml
- name: Configure AWS credentials (OIDC)
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-actions
    aws-region: eu-west-2

- name: Assume DNS deployment role
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::111111111111:role/DNSDeploymentRole
    role-external-id: ${{ secrets.DNS_DEPLOYMENT_EXTERNAL_ID }}
    role-chaining: true
    aws-region: eu-west-2

- name: Deploy DNS changes
  run: |
    cd tf
    tofu init
    tofu apply -auto-approve

role-chaining: true: Tells the action to use existing credentials to assume the new role, rather than using OIDC again.

OrganizationAccountAccessRole

When you create an account through Organizations, AWS automatically creates OrganizationAccountAccessRole in the new account. This role:

  • Trusts the management account
  • Has AdministratorAccess
  • Allows initial management of the new account

We use this for initial setup, then create scoped deployment roles:

The identity management Terraform uses the org role's provider to create scoped deployment roles in each member account. Once those scoped roles exist, CI/CD switches to using them and the org role becomes a break-glass path only.

SSO to Deployment Role Pattern

SSO users can also assume deployment roles, useful for local development:

hcl
# Permission set grants ability to assume the role
resource "aws_ssoadmin_permission_set" "dns_deployment_access" {
  name             = "DNSDeploymentAccess"
  description      = "Permission to assume DNSDeploymentRole"
  instance_arn     = local.identity_center_instance_arn
  session_duration = "PT2H"
}

resource "aws_ssoadmin_permission_set_inline_policy" "dns_deployment_access" {
  instance_arn       = local.identity_center_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.dns_deployment_access.arn

  inline_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["sts:AssumeRole", "sts:TagSession"]
        Resource = "arn:aws:iam::111111111111:role/DNSDeploymentRole"
        Condition = {
          StringEquals = {
            "sts:ExternalId" = var.dns_deployment_external_id
          }
        }
      }
    ]
  })
}

The SSO user's experience:

  1. Log in to Identity Center
  2. Assume the DNSDeploymentAccess permission set
  3. Run aws sts assume-role with the external ID
  4. Use the deployment role credentials

Field note: we keep separate external IDs for DNS and website deployment paths. Reusing one value across multiple roles made policy reviews noisier and easier to misread.

cert-manager Service Account

For Kubernetes cert-manager to manage DNS challenges, we create a dedicated IAM user:

This pattern is for non-EKS clusters where IRSA is not available. In EKS, prefer IAM Roles for Service Accounts (IRSA) so pods assume roles directly via the cluster OIDC provider instead of using long-lived user credentials.

Quick decision rule:

  • Non-EKS Kubernetes: IAM user plus scoped assume-role can be a pragmatic bridge.
  • EKS: Use IRSA with a service account annotation and an IAM role trust policy for the cluster OIDC provider.

If you run the non-EKS bridge pattern, keep the IAM user tightly scoped and rotate its access key on a short, scheduled cadence.

hcl
resource "aws_iam_user" "certmanager" {
  provider = aws.dns_management
  name     = "certmanager-dns-challenge"
  path     = "/service-accounts/"
}

resource "aws_iam_role" "certmanager_dns" {
  provider = aws.dns_management
  name     = "CertManagerDNSRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_user.certmanager.arn
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy" "certmanager_dns" {
  provider = aws.dns_management
  role     = aws_iam_role.certmanager_dns.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "route53:GetChange"
        Resource = "arn:aws:route53:::change/*"
      },
      {
        Effect = "Allow"
        Action = [
          "route53:ChangeResourceRecordSets",
          "route53:ListResourceRecordSets"
        ]
        Resource = "arn:aws:route53:::hostedzone/Z1234567890ABC"
        Condition = {
          "ForAllValues:StringEquals" = {
            "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"]
          }
          "ForAllValues:StringLike" = {
            "route53:ChangeResourceRecordSetsRecordNames" = ["_acme-challenge.*"]
          }
        }
      }
    ]
  })
}

This is as scoped as possible:

  • Can only modify TXT records
  • Can only modify _acme-challenge.* subdomains
  • Can only access one specific hosted zone

Summary

Cross-account access should be:

  1. Explicit: Every cross-account relationship requires a trust policy
  2. Scoped: Roles have minimum necessary permissions
  3. Protected: External IDs prevent confused deputy attacks
  4. Auditable: CloudTrail logs every AssumeRole call

The pattern is consistent: create a role in the target account, scope it narrowly, protect it with external IDs, and grant specific principals the ability to assume it.

This is also where many AWS estates accumulate hidden risk. Trust policies, external IDs, and role chains tend to grow organically and drift over time. Periodic trust-graph reviews and policy tightening reduce exposure without slowing delivery.


← Back to all posts