Access Denied on a Tagged Cross-Account Role
A build account (222233334444) runs a CI pipeline that authenticates through an OIDC provider and assumes DataPipelineProcessor in the data account (33334444555). The role's identity policy allows s3:PutObject on the payments prefix of the data lake. A permissions boundary on the role permits only s3:PutObject, s3:GetObject and kms:Decrypt on named ARNs. The data account sits in an OU whose SCP denies s3:* when aws:PrincipalTag/team does not equal payments. The pipeline passes session tags from OIDC claims; the tag mapping in the assume-role configuration uses the claim name Team. CloudTrail shows the AssumeRoleWithWebIdentity succeeded, and the subsequent PutObject is attributed to the correct session name, but the API returns AccessDenied. The engineer's proposed fix is to widen the permissions boundary.
1. Walk the policy evaluation order for this call and identify the layer that is actually failing. What evidence in the setup points to it?
Read the worked answer
Evaluation order for a principal in the data account calling S3 in the data account: SCP → identity policy and permissions boundary (intersection) → resource policy → session policy. Any explicit Deny at any layer wins, and an SCP that does not allow the action blocks it before the boundary is even consulted.
The evidence points at the SCP:
- The AssumeRole succeeded, so the trust policy and the OIDC provider are fine.
- CloudTrail attributes the call to the right session, so the session exists and is passing credentials.
- The identity policy and the boundary both list s3:PutObject, so their intersection is non-empty.
What remains is the SCP condition on aws:PrincipalTag/team. The tag-mapping configuration supplies the claim under the key Team. Tag keys are case-sensitive, so aws:PrincipalTag/team is absent from the request context and the StringNotEquals condition on a missing key evaluates true - the Deny fires. The identical-looking name is the entire bug.
This is why "it works in the sandbox account" and "it fails in production" can both be true: the sandbox is outside the OU carrying the SCP.
2. Why will widening the permissions boundary not help, and what are the correct fixes ranked by durability?
Read the worked answer
A permissions boundary only ever limits. It cannot grant, so widening it changes nothing when the block is an SCP Deny - the request never reaches a layer where the boundary matters. This is the single most common misdiagnosis in multi-account IAM, and the fastest way to disprove it is to read the errorMessage in the CloudTrail event: SCP denials carry a message that names the organization policy.
Fixes, best first:
- Fix the tag mapping so the session tag key is
teamin the casing the SCP, ABAC policies and any downstream conditions all expect. Set the convention once, write it into the OIDC claim-mapping template, and test it. - Make the condition fail closed.
StringNotEqualson a missing key denies; a missing tag should produce an explicit, recognizable denial rather than a generic one. Consider aNullcheck alongside it so "tag absent" is distinguishable in logs from "tag wrong". - Emit a startup assertion in the pipeline: after AssumeRole, call
sts:GetCallerIdentityand inspect the assumed-role session's tags, failing the job immediately with a clear message ifteamis absent. Catching it in the pipeline costs seconds; catching it in a production data write costs an incident. - Only then consider whether the SCP condition itself should be scoped more narrowly.
Do not "fix" this by removing the session-tag requirement from the SCP - that condition is the control that makes ABAC work across 60 accounts.
3. How do you test the fix without putting the production OU at risk, and what tool limitation must you account for?
Read the worked answer
Test in a scratch account that you move into the same OU, or a child OU carrying an identical copy of the SCP. SCPs are inherited and evaluated on the account's membership, so an account with the same SCP in place reproduces the failure faithfully; an account outside the OU cannot.
The limitation: the IAM policy simulator does not evaluate service control policies. Using it to "prove" the fix gives you a green result that says nothing about the layer that was actually denying the request. The same caution applies to any test harness that only checks identity policies and boundaries.
A workable test sequence:
- Put the scratch account in the OU, run the pipeline, and confirm the same AccessDenied - reproduce before you fix.
- Fix the tag mapping, rerun, and capture the CloudTrail event to confirm the context now carries
team=payments. - Add a negative test: run with a deliberately wrong tag value and confirm you still get denied, so you have not simply removed the control.
- Promote to production, then verify with a real write and with a CloudTrail lookup of that write's
requestParametersand session context.
Finally, record the case-sensitivity convention in the platform's IAM standards document. This failure recurs every time a new OIDC integration is onboarded by someone who has not met it yet.