Skip to content
All posts

Audit assume-role chains before you audit bucket policies

Why the interesting IAM risk is three hops upstream of the bucket.

June 21, 2026 by Yash 9 min read #AWS#IAM#CloudSecurity#CloudPosture#FinOps#CloudMigration

When a cloud security review focuses on S3 bucket policies, it is looking at the right object and the wrong question. Bucket policies control which principals can access the bucket directly. The more common real-world exposure is a role that can reach the bucket not because the bucket policy explicitly allows it, but because a chain of sts:AssumeRole calls leads there through two or three intermediate roles that were each created at different times by different teams with different threat models.

A bucket policy audit shows you a clean document. The assume-role chain audit shows you that your GitHub Actions runner can reach production S3 in four hops, none of which are individually suspicious.

This is a posture problem, not a runtime threat. The fix is enumeration, documentation, and pruning — not a SOC tool or an EDR agent.


What an assume-role chain is

An IAM role is an identity. Any principal (a user, a service, another role, an AWS service) that has been granted sts:AssumeRole permission on that role can temporarily become it — picking up its policies and acting as it for the duration of the session token.

A chain forms when role A can assume role B, and role B can assume role C, and role C has s3:GetObject on a sensitive bucket. No single role in the chain needs to have the full permission; the permission emerges from the traversal.

GitHub Actions runner
  → assumes: github-actions-role (Account A)
  → github-actions-role has sts:AssumeRole on: deploy-role (Account B)
  → deploy-role has sts:AssumeRole on: data-access-role (Account B)
  → data-access-role has s3:GetObject on: s3://prod-customer-data/*

The bucket policy on prod-customer-data might correctly restrict direct access to a named principal list that does not include github-actions-role. The chain bypasses this entirely. The effective permission is real; the bucket policy shows nothing wrong.


The three failure modes that create problematic chains

Failure mode 1: CI/CD roles that grew over time. A GitHub Actions or GitLab CI service role is created for deployment. Someone needs it to read a parameter from SSM. Someone else needs it to write to an S3 staging bucket. A third request needs it to assume a cross-account role for a one-time migration task. Nobody removes permissions after the task. The role accumulates permissions across quarters until its effective access set spans multiple accounts and data tiers.

Failure mode 2: EKS service account to IAM role bindings. Kubernetes workloads on EKS can use IAM Roles for Service Accounts (IRSA). A pod running as Kubernetes service account data-processor in namespace etl gets an annotation that maps it to arn:aws:iam::123456789012:role/eks-data-processor-role. This is the right mechanism. The failure mode is when that IAM role has sts:AssumeRole on a second role — often a legacy role with broader permissions — because a developer needed cross-account access during development and never cleaned it up. The pod inherits the chain.

Failure mode 3: Cross-account trust relationships with no expiry. Role B in a shared-services account was originally created to allow Account A’s CI/CD to deploy to Account B. Later, Account C was added with the same pattern. Account D followed. The trust policy on Account B’s role now has four principals in different accounts. Two of those accounts have been decommissioned; the roles in those accounts still exist because nobody ran the cleanup. If those accounts get recycled and the role ARNs get reused — an AWS account recycling scenario that is rare but not impossible in large organisations — the trust relationship reactivates.


Enumerating the chains: IAM Access Analyzer output and AWS CLI

AWS IAM Access Analyzer runs at the account or organisation level and finds resources (including roles) accessible from outside the account or organisation boundary. It does not enumerate full chains, but it surfaces external-principal-to-role trust relationships — which is where cross-account chains begin.

Enable Access Analyzer at the organisation level (one analyzer per region per organisation root):

# Create an organisation-level analyzer (run from management account)
aws accessanalyzer create-analyzer \
  --analyzer-name org-wide-analyzer \
  --type ORGANIZATION \
  --region us-east-1

# List all findings (external access = principal outside the org boundary)
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:accessanalyzer:us-east-1:MGMT_ACCOUNT_ID:analyzer/org-wide-analyzer \
  --filter '{"resourceType": {"eq": ["AWS::IAM::Role"]}}' \
  --output json \
  | jq -r '.findings[] | [.resource, .principal.AWS, .status] | @tsv'

Findings with status ACTIVE and a .principal.AWS that is an external account ID warrant immediate review. An ARCHIVED finding means someone acknowledged it — not that it was fixed.

For enumerating chains that stay within account or organisation boundaries — which Access Analyzer does not surface — the relevant query is on the IAM role trust policies and permission boundaries directly:

# List all roles and their trust policy documents
aws iam list-roles --output json \
  | jq -r '.Roles[] | [.RoleName, .Arn, (.AssumeRolePolicyDocument | tojson)] | @tsv' \
  > role-trust-inventory.tsv

# From that inventory, find roles that can be assumed by other roles (not users or services)
# These are the nodes in potential chains
grep 'arn:aws:iam.*:role/' role-trust-inventory.tsv \
  | awk -F'\t' '{print $1, $3}' \
  | python3 -c "
import sys, json
for line in sys.stdin:
    parts = line.strip().split(' ', 1)
    name = parts[0]
    doc = json.loads(parts[1])
    for stmt in doc.get('Statement', []):
        p = stmt.get('Principal', {})
        aws_p = p.get('AWS', []) if isinstance(p, dict) else []
        if isinstance(aws_p, str): aws_p = [aws_p]
        for arn in aws_p:
            if ':role/' in arn:
                print(f'{arn} -> {name}')
"

The output is a directed edge list: source-role -> target-role. A role that appears as a target repeatedly — many sources can assume it — is a high-priority review candidate. A role that is both a target and a source (it gets assumed, and it can assume others) is a chain node.


A worked example: the four-hop path

Consider a mid-size SaaS company running EKS in Account A (application) and storing customer export files in Account B (data). Here is a chain that forms organically over 18 months of normal development:

HopFromToHow the trust was created
1EKS pod (IRSA)eks-exporter-role (Account A)Data team created during feature launch
2eks-exporter-rolecross-account-data-role (Account B)Added when data team needed cross-account write access for a migration
3cross-account-data-rolelegacy-s3-admin-role (Account B)Legacy role inherited when Account B was originally set up; broad S3 access
4legacy-s3-admin-roles3:* on prod-customer-exportsS3 policy attached to the legacy role

The bucket policy on prod-customer-exports grants access only to cross-account-data-role. A bucket policy audit finds this appropriate. The actual exposure is that any EKS pod with the eks-exporter service account annotation — which includes pods in the batch namespace that process inbound files, not outbound — can traverse the chain to the legacy-s3-admin-role and reach s3:* on production customer data.

The remediation steps, in order:

  1. Remove sts:AssumeRole on legacy-s3-admin-role from cross-account-data-role. Replace with a scoped-down role that has only the specific bucket prefix actions needed (s3:PutObject on prod-customer-exports/outbound/*).
  2. Add a permission boundary to cross-account-data-role that explicitly denies sts:AssumeRole to any role not in an approved allowlist.
  3. Audit the IRSA annotations in the EKS namespace to confirm only the correct pods carry the eks-exporter-role binding.

None of these steps require any runtime tooling. They are all IAM policy changes that can be reviewed in a PR, applied via Terraform or CloudFormation, and verified with the same CLI commands used in the enumeration.


Permission boundaries as a chain-breaking control

A permission boundary is an IAM policy attached to a role that defines the maximum permissions the role can hold — it cannot override explicit denies in the boundary even if an attached policy grants more. For containing chain traversal, a permission boundary with an explicit Deny on sts:AssumeRole for any role outside an approved set is the most reliable preventive control.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowScopedAccess",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::prod-customer-exports/outbound/*"
    },
    {
      "Sid": "DenyChainTraversal",
      "Effect": "Deny",
      "Action": "sts:AssumeRole",
      "Resource": "*"
    }
  ]
}

Attach this as the permission boundary to cross-account-data-role. Even if the role’s own policy or a future policy update grants sts:AssumeRole, the boundary deny takes precedence. The role is end-stopped.

This pattern — boundary denying sts:AssumeRole on any leaf role — is one of the more defensible posture controls for teams that do not have the staffing to audit every future policy change in the account.


The audit sequence that works in practice

The sequence that consistently surfaces the material chains in a cloud posture review:

  1. Run IAM Access Analyzer at organisation scope. Triage all ACTIVE findings on IAM roles with external principals.
  2. Export the role trust policy inventory with the CLI snippet above. Build the edge list. Identify roles that appear as both source and target (chain nodes).
  3. For each chain node, enumerate the attached policies and inline policies. Identify any that include sts:AssumeRole, s3:*, kms:*, or secretsmanager:* — the high-value permission set that makes chain traversal dangerous.
  4. For EKS workloads, export the IRSA annotations from every namespace: kubectl get serviceaccounts -A -o json | jq -r '.items[] | select(.metadata.annotations["eks.amazonaws.com/role-arn"] != null) | [.metadata.namespace, .metadata.name, .metadata.annotations["eks.amazonaws.com/role-arn"]] | @tsv'. Cross-reference the annotated roles against your chain node list.
  5. Apply permission boundaries to all chain-node roles as a first-pass containment step, before reworking any individual policies.

This is a posture engagement — the deliverable is a documented chain inventory, a prioritised remediation list, and Terraform-ready policy changes. Runtime detection, incident response, and SOC operations are out of scope for this type of review.


Cloud security posture work — IAM rationalisation, role chain audits, encryption posture, and S3 access reviews — is part of the cloud services practice at Replatform. If your AWS account has grown over three or more years without a systematic IAM review, the chain inventory is usually the highest-value first step.

Related posts