Debugging AccessDenied in multi-account setups, and designing guardrails, requires knowing exactly which layer said no.
For each request AWS gathers every applicable policy and decides:
- Any explicit Deny in any of them ends it: denied.
- SCPs (on the principal's account) and RCPs (on the resource's account) from AWS Organizations must allow the action. They never grant anything; they cap what is possible, and don't apply to the management account.
- The resource-based policy and the identity-based policy grant access. Within one account, an Allow in either is usually enough. Across accounts, both must allow: the caller's identity policy and the resource's policy.
- A permissions boundary on the user or role caps its identity-based permissions; an action outside it is implicitly denied.
- Session policies passed when assuming a role or federating cap that session further.
Some resources are stricter. A KMS key policy must allow access (directly or by delegating to the account), otherwise IAM policies have no effect. A role's trust policy must allow the assume call.
Useful condition keys for guardrails: aws:PrincipalOrgID (only principals in my organization), aws:ResourceOrgID, and aws:SourceArn / aws:SourceAccount on policies that grant to service principals, which prevents a service from being tricked into acting on your resource for someone else (the confused deputy).
Beware NotAction with Allow (it grants everything else, including future actions) and NotPrincipal. The error message often names the policy type that denied the request. Test with the IAM policy simulator, which covers identity policies, boundaries and SCPs but not every resource policy, so confirm with a real call.
Example
# Which actions would this role be allowed to perform?
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/app \
--action-names s3:GetObject s3:DeleteObject \
--resource-arns arn:aws:s3:::acme-data/reports/q1.csv \
--query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text
# s3:GetObject allowed
# s3:DeleteObject explicitDeny
# A bucket policy guardrail: only principals from our organization
cat > org-only.json <<'EOF'
{"Version": "2012-10-17",
"Statement": [{"Sid": "OrgOnly", "Effect": "Deny", "Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::acme-data", "arn:aws:s3:::acme-data/*"],
"Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-a1b2c3d4e5"},
"Bool": {"aws:PrincipalIsAWSService": "false"}}}]}
EOF
aws s3api put-bucket-policy --bucket acme-data --policy file://org-only.jsonRemember
- Explicit deny anywhere wins; SCPs, RCPs, boundaries and session policies only cap
- Cross-account access needs both the identity policy and the resource policy
- KMS key policies and role trust policies must allow on their own
- Use aws:PrincipalOrgID and aws:SourceArn for data-perimeter guardrails
Watch out for
- Expecting an SCP Allow to grant permissions; it only defines the maximum.