AWS Security Specialty (SCS-C03) study materials
AWS Security Specialty (SCS-C03) study resources

Cross-Account AssumeRole Fails in Production Only

AWS Security Specialty (SCS-C03) · Put your knowledge to work in context.

All case studies

Cross-Account AssumeRole Fails in Production Only

Account A (111122223333) hosts a CodePipeline/CodeBuild deployment. CodeBuild runs as arn:aws:iam::111122223333:role/ci-build and deploys to staging (444455556666) and production (777788889999) by assuming a role named DeployRole in each target account. Production failures show AccessDenied on sts:AssumeRole, before any S3 or CloudFormation call. What you know: account A's SCPs allow the full CI service set including sts:; the production OU's SCP allowlists only sts:, s3:, and cloudformation:; DeployRole in production carries a permissions boundary allowing exactly those three services and an identity policy granting s3:* and cloudformation:* on *; production's DeployRole trust policy was created from an older template and includes a StringEquals condition on sts:ExternalId of 'ci-2024' and a Bool condition aws:MultiFactorAuthPresent: true. Staging's trust policy has only the ExternalId condition.

1. Rank the candidate causes and explain which is actually producing the failure, and why the OU's SCP and the permissions boundary are red herrings.

Read the worked answer

Actual cause: the aws:MultiFactorAuthPresent: true condition in production's trust policy. CodeBuild assumes DeployRole using its own role credentials with no MFA context, so the condition evaluates false and the trust policy denies sts:AssumeRole. Staging has no such condition, which is exactly why the same pipeline works there. The failure occurs at AssumeRole and never reaches S3 or CloudFormation - consistent with a trust-policy denial.

Why the OU SCP is not the cause: SCPs are evaluated against the calling principal's account, not the account that owns the resource. The caller is ci-build in account A, so account A's SCPs apply - and they permit sts:*. The production OU's allowlist constrains principals inside production, including DeployRole's own future API calls. It has no bearing on who may assume the role.

Why the permissions boundary is not the cause: a permissions boundary limits the maximum permissions of the role to which it is attached. It governs what DeployRole can do after it is assumed. It is not consulted when a different principal calls sts:AssumeRole on the role; that path is governed by the trust policy alone. Corollary worth internalising: if the deployment later fails on s3:PutObject, the boundary is then in play - but here it allows s3:*, so it still would not block.

A useful diagnostic habit: when AssumeRole fails, read the trust policy first and compare it against the other environment that works.

2. Write the corrected trust policy conditions and justify each one - including why ExternalId is kept when you could instead restrict by organization.

Read the worked answer

Remove the MFA condition. MFA conditions require a caller that has presented an MFA token in the same session; machine roles cannot, and CodeBuild has no mechanism to supply one. Keeping it guarantees this breaks again on the next template refresh.

Resulting conditions:

  • aws:PrincipalArn = arn:aws:iam::111122223333:role/ci-build as the Principal - not the account root. Scoping to the exact role means no other principal in account A, including a future administrator, can assume DeployRole. This is the single highest-value line in the policy.
  • sts:ExternalId = ci-2024 - kept. With a same-organization caller the confused-deputy risk is lower, but ExternalId still pins the trust to this pipeline configuration: a second codebase in account A that is later compromised still needs to know the value. It costs nothing and it survives the case where the CI account is shared with a vendor integration.
  • aws:PrincipalOrgID = o-... - add it. It is a hard backstop: if the role ARN is ever recreated in an account that leaves the organization, the ARN could be claimed by someone else, and the org condition makes that useless.
  • Optional aws:SourceVpce/aws:SourceAccount only if the pipeline runs from a known VPC endpoint; otherwise skip rather than add conditions you cannot verify.

Verify before rollout: aws sts assume-role from a CodeBuild test build in account A, and confirm a negative test - assume-role from a different role in account A must fail.

3. DeployRole currently has `s3:*` and `cloudformation:*` on `*`, and every pipeline in the organization shares it. What is the least-privilege redesign, and how do you prove it is actually restrictive?

Read the worked answer

Redesign:

  1. One role per environment per application, not one shared DeployRole. Production should not be assumable by a pipeline that only ever deploys staging.
  2. ABAC via session tags. Have ci-build call sts:AssumeRole with Tags=[{Key:app,Value:orders},{Key:env,Value:prod}], and write the target policies with conditions such as s3:prefix matching ${aws:PrincipalTag/app} and Resource scoped to arn:aws:s3:::deploy-${aws:PrincipalTag/app}-${aws:PrincipalTag/env}/*. This collapses dozens of near-identical policies into one and makes cross-app access structurally impossible.
  3. Bound resource scope to the deployment's own CloudFormation stacks and artifact prefixes; cloudformation:* on * lets the pipeline rewrite any stack in the account, including the one holding your logging infrastructure.
  4. Dangerous actions named explicitly as denies - iam:CreateUser, iam:AttachUserPolicy, iam:CreateAccessKey, cloudformation:UpdateStack on stacks not owned by this app, s3:PutBucketPolicy. Permissions boundaries are the right home for these: a boundary that allows a named safe set is a guardrail the pipeline team cannot edit.

Proving it:

  • IAM Access Analyzer policy generation from 30–90 days of CloudTrail activity gives you a data-driven policy to start from, not a guess.
  • Access Analyzer unused-permission findings after rollout to show what to delete next.
  • CI policy validation - run aws accessanalyzer validate-policy and a Checkov/cfn-guard check on every policy change, failing the build on Action: "*" or Resource: "*" in production policies.
  • Negative tests in the pipeline: a canary step that attempts a call outside the allowed set (iam:CreateUser) and fails the build if it succeeds.