DEV Community

Cover image for AWS IAM Access Analyzer Internal Access: Effective Permissions, SCPs, RCPs, and an Unexpected Finding
Terry Quispe Paniagua
Terry Quispe Paniagua

Posted on

AWS IAM Access Analyzer Internal Access: Effective Permissions, SCPs, RCPs, and an Unexpected Finding

Real-world demo: effective permissions, SCPs, RCPs, and a comparison that did not go as expected


In the previous part, I explained the problem: in environments with multiple accounts, roles, and layered policies, figuring out who actually has access to a resource is not something you can answer by opening the console and reviewing a single IAM policy.

In this post, I go straight to the lab. I configured a scenario with two accounts inside the same AWS Organization, an S3 bucket in one account, several IAM roles in another, and multiple control layers affecting access: IAM, a bucket policy, an SCP, an RCP, and a permissions boundary attached to the administrator role.

The goal was not only to verify whether an action worked, but also to compare what was configured, what IAM Access Analyzer Internal Access reported, and what actually happened when the APIs were called.


⚠️ Cost note before you begin

IAM Access Analyzer External Access has no additional charge, but Internal Access Analyzer is a paid capability. AWS charges $9.00 USD per monitored resource, per analyzer, per Region, per month. This lab monitors only one S3 bucket, so the estimated cost was $9.00 USD/month while the analyzer remained active. If you enable it across multiple production resources, the cost scales linearly: 10 resources = $90 USD/month, and 38 resources across 5 accounts = $342 USD/month, as shown in the example on the official AWS pricing page.


Lab: validating cross-account access within the same AWS Organization using IAM Access Analyzer Internal Access

In this lab, I validated how IAM Access Analyzer Internal Access represents access from roles in one account to an S3 bucket located in another account within the same organization.

The scenario started with two roles:

  • RoleReadOnly

  • RoleAdmin

I then added two control roles to compare whether the way actions were declared affected the results:

  • RoleDiagnosticExplicitNoBoundary

  • RoleAdminWildcardNoBoundary

I also added three controls:

  • An SCP to block s3:DeleteObject and s3:DeleteObjectVersion

  • An RCP to block s3:PutObject

  • A permissions boundary attached to RoleAdmin

Architecture

AWS Organization
└── Lab OU
    ├── Account A — resource owner
    │   └── S3 bucket: demo-internal-aa-2026
    │
    └── Account B — principal owner
        ├── RoleReadOnly
        ├── RoleAdmin
        ├── RoleDiagnosticExplicitNoBoundary
        └── RoleAdminWildcardNoBoundary
Enter fullscreen mode Exit fullscreen mode

Account A contains the S3 bucket, while Account B contains the roles that attempt to access it. Both accounts are inside the same OU, and both the SCP and the RCP were attached to that OU. The SCP limits the actions available to principals in the accounts under the OU, while the RCP limits access to resources in those accounts. In this lab, the ARN defined in the RCP restricts the deny to the bucket in Account A. The permissions boundary is attached directly to RoleAdmin.

Architecture of the lab with two AWS accounts inside the same OU

The image above represents the first phase of the lab, when I was working only with RoleReadOnly and RoleAdmin. The other two roles were added later to make the comparison more controlled.

Objective

The lab validates seven things:

  1. Which roles inside the organization appear as having access to the bucket.

  2. Which actions the finding reports for each role.

  3. How an SCP blocks an action even when IAM and the bucket policy allow it.

  4. How an RCP blocks an action from the resource side.

  5. How a permissions boundary limits the maximum permissions of a role.

  6. What changes when actions are declared explicitly instead of using s3:*.

  7. Whether the finding matches the actual API execution results.

Role Configured permissions Boundary
RoleReadOnly ListBucket and GetObject No
RoleAdmin s3:* Yes: limited to ListBucket, GetObject, PutObject, and DeleteObject
RoleAdminWildcardNoBoundary s3:* No
RoleDiagnosticExplicitNoBoundary ListBucket, GetBucketPolicy, GetObject, PutObject, and DeleteObject No

The SCP blocks s3:DeleteObject and s3:DeleteObjectVersion.

The RCP blocks s3:PutObject on the lab bucket.


Step 1: Create the roles in Account B

From CloudShell in Account B, define the environment variables:

export ACCOUNT_B_ID="<ACCOUNT_B_ID>"
export BUCKET_NAME="demo-internal-aa-2026" # example
Enter fullscreen mode Exit fullscreen mode

Validate the current identity:

aws sts get-caller-identity
Enter fullscreen mode Exit fullscreen mode

Next, create the trust policy that allows the roles to be assumed from within Account B:

cat > trust-account-b.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAssumeRoleFromAccountB",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
Enter fullscreen mode Exit fullscreen mode

Create the four roles:

for ROLE in \
  RoleReadOnly \
  RoleAdmin \
  RoleDiagnosticExplicitNoBoundary \
  RoleAdminWildcardNoBoundary
do
  aws iam create-role \
    --role-name "$ROLE" \
    --assume-role-policy-document file://trust-account-b.json
done
Enter fullscreen mode Exit fullscreen mode

Step 2: Assign IAM permissions to the roles

RoleReadOnly — can only list and read:

cat > role-readonly-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::$BUCKET_NAME"
    },
    {
      "Sid": "ReadObjects",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name RoleReadOnly \
  --policy-name DemoBucketReadOnlyAccess \
  --policy-document file://role-readonly-policy.json
Enter fullscreen mode Exit fullscreen mode

RoleAdmin — allows s3:* on the bucket and its objects:

cat > role-admin-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AdminBucketAccess",
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::$BUCKET_NAME",
        "arn:aws:s3:::$BUCKET_NAME/*"
      ]
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name RoleAdmin \
  --policy-name DemoBucketAdminAccess \
  --policy-document file://role-admin-policy.json
Enter fullscreen mode Exit fullscreen mode

RoleDiagnosticExplicitNoBoundary

This role explicitly declares the actions I wanted to compare:

cat > role-diagnostic-explicit-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DiagnosticBucketActions",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketPolicy"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME"
    },
    {
      "Sid": "DiagnosticObjectActions",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name RoleDiagnosticExplicitNoBoundary \
  --policy-name DiagnosticExplicitNoBoundaryAccess \
  --policy-document file://role-diagnostic-explicit-policy.json
Enter fullscreen mode Exit fullscreen mode

RoleAdminWildcardNoBoundary

This role keeps the wildcard and has no boundary:

cat > role-admin-wildcard-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AdminWildcardBucketAccess",
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::$BUCKET_NAME",
        "arn:aws:s3:::$BUCKET_NAME/*"
      ]
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name RoleAdminWildcardNoBoundary \
  --policy-name AdminWildcardNoBoundaryAccess \
  --policy-document file://role-admin-wildcard-policy.json
Enter fullscreen mode Exit fullscreen mode

Step 2.1: Add a permissions boundary to RoleAdmin

In addition to its inline policy with s3:*, RoleAdmin has a permissions boundary.

The purpose was not to replace the SCP or the RCP. I wanted to add a layer that explicitly defined the maximum set of actions available to the role.

The boundary allows:

  • s3:ListBucket

  • s3:GetObject

  • s3:PutObject

  • s3:DeleteObject

For example, it does not allow:

  • s3:GetBucketPolicy

  • s3:GetBucketAcl

  • s3:GetBucketVersioning

cat > role-admin-boundary.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::$BUCKET_NAME"
    },
    {
      "Sid": "PowerUserObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF

aws iam create-policy \
  --policy-name boundary-adminuser \
  --policy-document file://role-admin-boundary.json

aws iam put-role-permissions-boundary \
  --role-name RoleAdmin \
  --permissions-boundary \
    "arn:aws:iam::$ACCOUNT_B_ID:policy/boundary-adminuser"
Enter fullscreen mode Exit fullscreen mode

Validation:

aws iam get-role \
  --role-name RoleAdmin \
  --query 'Role.PermissionsBoundary' \
  --output json
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure the bucket policy in Account A

From CloudShell in Account A:

export ACCOUNT_B_ID="<ACCOUNT_B_ID>"
export BUCKET_NAME="demo-internal-aa-2026"
Enter fullscreen mode Exit fullscreen mode

The bucket policy grants each role the same actions used in its identity policy:

cat > bucket-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadOnlyListBucket",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleReadOnly"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::$BUCKET_NAME"
    },
    {
      "Sid": "AllowReadOnlyGetObject",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleReadOnly"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    },
    {
      "Sid": "AllowAdminFullBucketAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleAdmin"
      },
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::$BUCKET_NAME",
        "arn:aws:s3:::$BUCKET_NAME/*"
      ]
    },
    {
      "Sid": "AllowAdminWildcardNoBoundaryFullBucketAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleAdminWildcardNoBoundary"
      },
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::$BUCKET_NAME",
        "arn:aws:s3:::$BUCKET_NAME/*"
      ]
    },
    {
      "Sid": "AllowDiagnosticExplicitNoBoundaryBucketAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleDiagnosticExplicitNoBoundary"
      },
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketPolicy"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME"
    },
    {
      "Sid": "AllowDiagnosticExplicitNoBoundaryObjectAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::$ACCOUNT_B_ID:role/RoleDiagnosticExplicitNoBoundary"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF

aws s3api put-bucket-policy \
  --bucket "$BUCKET_NAME" \
  --policy file://bucket-policy.json
Enter fullscreen mode Exit fullscreen mode

Validation:

aws s3api get-bucket-policy \
  --bucket "$BUCKET_NAME" \
  --query Policy \
  --output text |
jq .
Enter fullscreen mode Exit fullscreen mode

The real account also contained an earlier entry for RolePowerUser. I did not use it in the final comparison because the four roles above already covered the cases I needed to isolate.


Step 4: Create the SCP in AWS Organizations

From the management account, or another account with AWS Organizations permissions, define the variables:

export BUCKET_NAME="demo-internal-aa-2026" # Bucket name set at the beginning
export OU_ID="<OU_ID>"
export SCP_NAME="DenyDeleteObjectDemoBucket"
Enter fullscreen mode Exit fullscreen mode

The SCP denies object deletion on the lab bucket:

cat > deny-delete-demo-bucket-scp.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDeleteObjectOnDemoBucket",
      "Effect": "Deny",
      "Action": [
        "s3:DeleteObject",
        "s3:DeleteObjectVersion"
      ],
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF
Enter fullscreen mode Exit fullscreen mode

Create the SCP and attach it to the OU that contains both Account A and Account B:

POLICY_ID=$(aws organizations create-policy \
  --name "$SCP_NAME" \
  --description "Deny object deletion on demo S3 bucket for IAM Access Analyzer lab" \
  --type SERVICE_CONTROL_POLICY \
  --content file://deny-delete-demo-bucket-scp.json \
  --query "Policy.PolicySummary.Id" \
  --output text)

echo "$POLICY_ID"

aws organizations attach-policy \
  --policy-id "$POLICY_ID" \
  --target-id "$OU_ID"
Enter fullscreen mode Exit fullscreen mode

To validate that it was attached:

aws organizations list-policies-for-target \
  --target-id "$OU_ID" \
  --filter SERVICE_CONTROL_POLICY \
  --output table
Enter fullscreen mode Exit fullscreen mode

Step 4.1: Add an RCP for the bucket

After testing the SCP, I extended the exercise with a Resource Control Policy (RCP) to block s3:PutObject directly on the lab bucket.

The purpose of this phase was no longer only to observe a deny from the principal side, but also to compare what happens when the deny comes from the resource side.

The RCP was configured as follows:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPutObjectOnLabBucket",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::demo-internal-aa-2026/*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

An important detail: in an RCP, the correct element is Principal with an uppercase P, and the permitted value for this type of policy is "*".

Create the policy and attach it to the OU that contains both the bucket account and the role account:

export OU_ID="<OU_ID>"

cat > deny-putobject-rcp.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPutObjectOnLabBucket",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::demo-internal-aa-2026/*"
    }
  ]
}
EOF

RCP_ID=$(aws organizations create-policy \
  --name "DenyPutObjectOnLabBucketRCP" \
  --description "RCP to deny PutObject on the lab bucket" \
  --type RESOURCE_CONTROL_POLICY \
  --content file://deny-putobject-rcp.json \
  --query "Policy.PolicySummary.Id" \
  --output text)

echo "$RCP_ID"

aws organizations attach-policy \
  --policy-id "$RCP_ID" \
  --target-id "$OU_ID"
Enter fullscreen mode Exit fullscreen mode

To validate that it was attached:

aws organizations list-policies-for-target \
  --target-id "$OU_ID" \
  --filter RESOURCE_CONTROL_POLICY \
  --output table
Enter fullscreen mode Exit fullscreen mode

At this point, the lab looked like this:

  • SCP → blocks DeleteObject and DeleteObjectVersion
  • RCP → blocks PutObject
  • Permissions boundary → limits the maximum permissions of RoleAdmin

This created a useful comparison because I was no longer testing a single organizational control layer, but several of them at the same time.


Step 5: Create the IAM Access Analyzer Internal Access analyzer

From the console:

IAM > Access Analyzer > Analyzer settings > Create analyzer
Enter fullscreen mode Exit fullscreen mode

Configuration:

Analysis:      Resource analysis - Internal access
Zone of trust: Entire organization
Resource:      arn:aws:s3:::demo-internal-aa-2026
Enter fullscreen mode Exit fullscreen mode

An important detail: for S3, the resource must be the bucket ARN.

Correct:

arn:aws:s3:::demo-internal-aa-2026
Enter fullscreen mode Exit fullscreen mode

Object ARNs with prefixes or wildcards are not supported as monitored resources in Internal Access Analyzer. Only the bucket ARN without a path is supported.

After creating the analyzer, wait until the first findings appear. For the final comparison, allow enough time for reanalysis, as explained later in the article.


Before testing: a quick summary of what each layer does

If you read the previous post, this table provides a quick summary of the scope of each mechanism in this lab:

Layer What it controls Main scope In this lab
IAM policy What the user or role can attempt to do Principal Defines the base permissions of the four roles
Bucket policy Which principals can access the bucket Resource Enables cross-account access from Account B
SCP Maximum permissions available to principals in member accounts Account / OU / organization Blocks DeleteObject and DeleteObjectVersion
RCP Maximum permissions available on resources in member accounts Resource in member account / OU / organization Blocks PutObject on the bucket
Permissions boundary Maximum permissions an IAM principal can have IAM principal Limits RoleAdmin; does not allow GetBucketPolicy

A concise way to read it is:

  • IAM defines what the role attempts to do

  • The bucket policy opens the cross-account access path

  • The SCP limits permissions from the principal side

  • The RCP limits permissions from the resource side

  • The boundary limits the principal's maximum permissions


Step 6: Test access by assuming the roles

From CloudShell in Account B:

export ACCOUNT_B_ID="<ACCOUNT_B_ID>"
export BUCKET_NAME="demo-internal-aa-2026"
export PREFIX="aa-lab"
Enter fullscreen mode Exit fullscreen mode

Define functions to manage credentials and assume roles more easily:

clear_role() {
  unset AWS_ACCESS_KEY_ID
  unset AWS_SECRET_ACCESS_KEY
  unset AWS_SESSION_TOKEN
  unset AWS_SECURITY_TOKEN
}

assume_role() {
  ROLE_NAME="$1"
  clear_role
  CREDS=$(aws sts assume-role \
    --role-arn "arn:aws:iam::$ACCOUNT_B_ID:role/$ROLE_NAME" \
    --role-session-name "lab-$ROLE_NAME" \
    --output json)
  export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | jq -r '.Credentials.AccessKeyId')
  export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | jq -r '.Credentials.SecretAccessKey')
  export AWS_SESSION_TOKEN=$(echo "$CREDS" | jq -r '.Credentials.SessionToken')
  echo "Assuming role: $ROLE_NAME"
  aws sts get-caller-identity
}
Enter fullscreen mode Exit fullscreen mode

Test with RoleReadOnly

assume_role RoleReadOnly
Enter fullscreen mode Exit fullscreen mode
aws s3 ls "s3://$BUCKET_NAME/$PREFIX/"
Enter fullscreen mode Exit fullscreen mode
aws s3 cp "s3://$BUCKET_NAME/$PREFIX/seed.txt" readonly-seed.txt
cat readonly-seed.txt
Enter fullscreen mode Exit fullscreen mode
echo "test readonly put" > readonly-put.txt
aws s3 cp readonly-put.txt "s3://$BUCKET_NAME/$PREFIX/readonly-put.txt"
Enter fullscreen mode Exit fullscreen mode

Observed result: PutObject fails with AccessDenied because of the RCP.

aws s3 rm "s3://$BUCKET_NAME/$PREFIX/seed.txt"
Enter fullscreen mode Exit fullscreen mode

Observed result: DeleteObject fails with AccessDenied because of the IAM identity-based policy.

RoleReadOnly runtime results

Test with RoleAdmin

assume_role RoleAdmin
Enter fullscreen mode Exit fullscreen mode
aws s3 ls "s3://$BUCKET_NAME/$PREFIX/"
Enter fullscreen mode Exit fullscreen mode
aws s3 cp "s3://$BUCKET_NAME/$PREFIX/seed.txt" admin-seed.txt
cat admin-seed.txt
Enter fullscreen mode Exit fullscreen mode
echo "test admin put" > admin-put.txt
aws s3 cp admin-put.txt "s3://$BUCKET_NAME/$PREFIX/admin-put.txt"
Enter fullscreen mode Exit fullscreen mode

Observed result: PutObject fails with AccessDenied because of the RCP.

aws s3 rm "s3://$BUCKET_NAME/$PREFIX/seed.txt"
Enter fullscreen mode Exit fullscreen mode

Observed result: DeleteObject fails with AccessDenied because of the SCP.

RoleAdmin runtime results

Specific test to validate the permissions boundary

To check how the boundary appeared in the denial message, I executed an action that:

  • RoleAdmin would otherwise have through s3:*
  • The bucket policy also allows
  • Was not being blocked by the SCP or the RCP
  • But the permissions boundary does not allow

The selected action was:

assume_role RoleAdmin
aws s3api get-bucket-policy --bucket "$BUCKET_NAME"
Enter fullscreen mode Exit fullscreen mode

This produced the missing evidence: the AccessDenied message states that RoleAdmin is not authorized to perform s3:GetBucketPolicy because no permissions boundary allows that action.

AccessDenied message identifying the permissions boundary

The AccessDenied message explicitly stated that no permissions boundary allowed s3:GetBucketPolicy, confirming that the boundary participated in the evaluation.

Additional tests: explicit actions compared with s3:*

To better isolate the behavior of the finding, I added two roles without permissions boundaries:

  • RoleDiagnosticExplicitNoBoundary, with the actions declared individually.
  • RoleAdminWildcardNoBoundary, with s3:*.

Both roles had access granted by their identity policies and the bucket policy, and both were subject to the same SCP and RCP.

RoleDiagnosticExplicitNoBoundary was allowed to perform GetBucketPolicy, GetObject, PutObject, DeleteObject, and ListBucket. However, its finding showed only:

  • s3:GetBucketPolicy
  • s3:GetObject
  • s3:ListBucket

In other words, PutObject and DeleteObject did not appear, which matched what I expected after applying the RCP and the SCP.

By contrast, RoleAdminWildcardNoBoundary had s3:*, and its finding retained a broad list of actions, including:

  • s3:PutObject
  • s3:DeleteObject
  • s3:DeleteObjectVersion

This happened even though the same finding showed the RCP and SCP restrictions as APPLIED.

RoleAdminWildcardNoBoundary finding details

RCP restriction shown as applied

SCP restriction shown as applied

The screenshots show that the finding for RoleAdminWildcardNoBoundary retains PutObject, DeleteObject, and DeleteObjectVersion, even though the SCP and RCP restrictions appear as applied.

To validate the actual access, I assumed both roles and ran the four main operations.

In both cases:

  • ListBucket and GetObject were allowed.
  • PutObject returned AccessDenied because of an explicit deny in the RCP.
  • DeleteObject returned AccessDenied because of an explicit deny in the SCP.
Role Permission declaration Expected result Observed finding Runtime
RoleDiagnosticExplicitNoBoundary Explicit actions No Put or Delete Does not show Put or Delete List and Get allowed; Put denied by RCP and Delete denied by SCP
RoleAdminWildcardNoBoundary s3:* No Put, Delete, or DeleteObjectVersion Retains those actions List and Get allowed; Put denied by RCP and Delete denied by SCP

The comparison shows an observable difference between the two findings. It does not determine the internal cause of the behavior by itself, but it does make it possible to compare what the analyzer reported with the actual API results.


View the findings in IAM Access Analyzer

With the roles created and the analyzer active, the next step is to review what IAM Access Analyzer detected for the bucket.

In the IAM console, under Access Analyzer, you can see all active analyzers. In this lab, the relevant one is the internal access analyzer.

IAM Access Analyzer analyzer list

When you open the analyzer and select the bucket, a list of findings appears. An important detail is that you will not see only the lab roles. You may also see internal roles from the bucket owner's account, such as administrative roles, AWS IAM Identity Center (SSO) roles, or service roles.

Internal access findings for the S3 bucket

If you expected to find only the four roles created for the lab, this is where you realize that another factor must be considered: the analyzer shows effective access to the resource, not only the principals you manually added to the lab's bucket policy.

This is one of the key points of the scenario: principals in the bucket owner's account do not need to be explicitly named in the bucket policy to have access. If a role or user in the owning account already has permissions through its own IAM identity policy, it can still access the bucket. AWS documents this behavior for S3 here: Granting access to an IAM principal in the same account does not require updating the bucket policy.

The bucket policy becomes necessary when you want to enable cross-account access, as is the case with the lab roles.

What the findings list shows

In the summary view, these are the most useful columns:

Field Meaning
Finding ID Unique identifier for the finding
Resource The analyzed bucket
Resource owner account The account that owns the bucket
Principal The role or principal that has access
Condition Whether access depends on a condition
Shared through The layer that creates the access path, such as a bucket policy or bucket ACL
Access level General action categories: Read, List, Write, Permissions, and Tagging
RCP restriction Whether the analyzer considered a Resource Control Policy
SCP restriction Whether the analyzer considered a Service Control Policy
Status Whether the finding is active or archived

Two columns deserve special attention in this lab:

  • SCP restriction = Applied

  • RCP restriction = Applied

This does not necessarily mean that a specific action such as s3:DeleteObject or s3:PutObject disappeared from the list. It does mean that those layers were considered during the evaluation.

View the details of a finding

The summary list is useful, but the real value appears when you open the details of each finding.

For RoleReadOnly, the result is straightforward:

  • Principal: Account B / RoleReadOnly

  • Principal account: Account B

  • Shared through: Bucket policy

  • Access reported by the finding:

    • Reads3:GetObject
    • Lists3:ListBucket

The finding does not show only broad categories. It directly shows the actions the analyzer is reporting for that principal on the bucket.

RoleReadOnly finding summary

RoleReadOnly finding actions

What this demonstrates about the SCP, RCP, and permissions boundary

This is where one of the most interesting parts of the exercise appeared.

At first, I only had RoleReadOnly and RoleAdmin, but I later added two roles to improve the comparison:

  • RoleDiagnosticExplicitNoBoundary, with the actions declared individually.
  • RoleAdminWildcardNoBoundary, with s3:* and no permissions boundary.

All four roles were subject to the same SCP and RCP, but their findings were not presented in exactly the same way.

Role Configuration Observed finding Runtime result
RoleReadOnly GetObject and ListBucket Shows GetObject and ListBucket Read allowed; write and delete denied
RoleDiagnosticExplicitNoBoundary Get, Put, Delete, List, and GetBucketPolicy declared explicitly Does not show PutObject or DeleteObject List and Get allowed; Put denied by RCP and Delete denied by SCP
RoleAdmin s3:* limited by a permissions boundary Shows only GetObject and ListBucket Put denied by RCP, Delete by SCP, and GetBucketPolicy by boundary
RoleAdminWildcardNoBoundary s3:* without a permissions boundary Retains PutObject, DeleteObject, and DeleteObjectVersion List and Get allowed; Put denied by RCP and Delete denied by SCP

Using the four main lab operations as the reference point—ListBucket, GetObject, PutObject, and DeleteObject—the findings for the first three cases matched the action set I expected after considering the different policy layers.

The difference appeared with RoleAdminWildcardNoBoundary. Its finding continued to include actions that the runtime tests confirmed were denied:

  • PutObject returned AccessDenied because of an explicit deny in the RCP.
  • DeleteObject returned AccessDenied because of an explicit deny in the SCP.

At the same time, both restrictions appeared in the findings as:

resourceControlPolicyRestriction: APPLIED
serviceControlPolicyRestriction: APPLIED
Enter fullscreen mode Exit fullscreen mode

For the complete test set, I waited at least 24 hours after making the changes before reviewing the findings again. I also used get-finding-v2 to confirm that the results had been reanalyzed.

This prevented me from comparing findings that might still have been waiting for an update.

This does not mean that the SCP or the RCP failed. The runtime tests show exactly the opposite: both policies blocked the operations.

What I can document is a difference between the actions reported by the finding and the actions the role could actually perform in this specific scenario.

My final interpretation of the lab is therefore:

  1. The finding is very useful for identifying principals, access paths, and reported actions on the resource.
  2. The SCP and RCP columns indicate whether those layers participated in the evaluation.
  3. When you need to validate a specific API, a runtime test remains important evidence.
  4. If the finding and runtime results do not match, review IAM, the bucket policy, the boundary, the SCP, and the RCP directly, and confirm that the analyzer had enough time to update before drawing a conclusion.

I am not attempting to determine the internal cause of this difference from the outside or to generalize the result to every service or configuration. The goal is to document the configuration and evidence in a reproducible way.

The official reference used to interpret the APPLIED fields is: InternalAccessDetails – AWS IAM Access Analyzer API

Why other roles appear in addition to the lab roles

The findings may also include internal roles such as federated administrators, Control Tower roles, and service roles from the account that owns the bucket.

This does not mean that the lab's bucket policy granted access to all of them.

It means that the bucket is also accessible to internal principals in the resource owner's account, and Access Analyzer displays them because its job is not to show only the cross-account access created for the exercise. It shows the total effective access within the analyzer's scope.

Put another way:

  • Account A owns the bucket.

  • Account B contains the four roles used in the lab.

The lab roles explain the cross-account scenario.

The other findings show intra-account access from roles that already exist in Account A and have permissions through other layers in the environment.

This is already easy to lose track of in a small lab. Now imagine the same problem in a real organization with tens or hundreds of accounts, thousands of roles, federated SSO roles, service roles, Control Tower roles, managed policies, inline policies, bucket policies, ACLs, SCPs, RCPs, permissions boundaries, and many resources beyond a single bucket. At that point, answering “who can actually access what?” stops being a manual policy review and becomes a problem of correlating multiple permission layers.

That is the core difficulty: effective access does not live in a single policy. It comes from the combination of identity policies, resource policies, intra-account permissions, cross-account permissions, and organizational restrictions. If you review only a bucket policy, you may think you understand access to the resource, while actually missing internal principals in the same account, inherited roles, service roles, or access paths that remain valid through other mechanisms. The more accounts, roles, and resources you add, the harder it becomes to distinguish between expected access, inherited access, indirect access, and access that represents real risk.

That is why tools such as IAM Access Analyzer become valuable at scale: not because they replace understanding IAM, but because they help reduce operational complexity when you are no longer looking at four lab roles, but at hundreds or thousands of identities with cross-account permissions over many resources.


What the Archive button does

Archiving a finding does not modify access or change any policies. It only changes how that result is managed inside the analyzer.

When you archive a finding:

  • It no longer appears among active findings.
  • It remains available as an archived finding.
  • You can use archive rules to automatically archive new findings that match defined criteria.
  • When creating a rule, you can choose to apply it only to new findings or also archive existing active findings.

If the access path disappears, the finding moves to Resolved. If the path changes significantly, Access Analyzer may resolve the previous finding and generate a new one.


Lab results

Role ListBucket GetObject PutObject DeleteObject
RoleReadOnly ✅ Allowed ✅ Allowed ❌ RCP ❌ IAM
RoleAdmin ✅ Allowed ✅ Allowed ❌ RCP ❌ SCP
RoleDiagnosticExplicitNoBoundary ✅ Allowed ✅ Allowed ❌ RCP ❌ SCP
RoleAdminWildcardNoBoundary ✅ Allowed ✅ Allowed ❌ RCP ❌ SCP

The table includes only API calls that were actually executed. The findings comparison is covered in the previous section.


What this lab demonstrates

For a cross-account action to work in this scenario, the following conditions must be met:

  1. An identity policy on the role in Account B must allow the action.
  2. A bucket policy in Account A must grant that action to the external role.
  3. No explicit deny applicable to that action and resource can exist.
  4. If the role has a permissions boundary, the action must be included within its maximum allowed permissions.

This does not mean that SCPs or RCPs cannot exist. Both exist in this lab, but they block specific operations: the SCP blocks DeleteObject and DeleteObjectVersion, while the RCP blocks PutObject. The remaining actions continue to be available according to the other applicable policies.

The first part of the lab demonstrates this with the SCP: the role has IAM permission, the bucket policy also grants it, but the organizational explicit deny wins.

The RCP extension reinforces the same idea from the resource side: even when an access path exists, an organizational control can still block the operation at runtime.

The GetBucketPolicy test adds a third important lesson: a permissions boundary can also act as a limiting layer and can be explicitly identified in an AccessDenied message.

The lab therefore demonstrates more than the deny itself. It also shows something more useful in production: you need to understand how to read the finding and interpret runtime errors without assuming that one is a perfect mirror of the other.


Before enabling it in production: pricing

This is something that very few tutorials mention, and it is worth understanding before enabling the feature in production.

IAM Access Analyzer Internal Access is not free for effective-permissions analysis.

Some practical considerations:

  • Do not enable it for every resource by default. Start with buckets that contain sensitive or regulated data.

  • Use tags to identify critical resources—for example, DataClassification: Confidential—and limit the analyzer's scope to those resources.

  • It makes the most sense for specific high-risk resources: buckets containing PCI data, security log repositories, cross-account shared buckets, DynamoDB tables, RDS database snapshots, and RDS cluster snapshots.

  • For the remaining resources, IAM Access Advisor can help prioritize periodic manual reviews and reduce the number of resources for which Internal Access Analyzer needs to be enabled.

The concrete recommendation is to first define what qualifies as a critical resource in your organization, apply data-classification tags, and use Access Analyzer Internal Access only for that subset—not for everything.

Practical note: if you were thinking of enabling it briefly and then deleting it, be careful. In my case, the charge appeared on the same day I enabled it and reflected the full monthly unit for the monitored resource. That alone is a reason to plan the experiment carefully before enabling it.

Internal Access Analyzer charge

I would add another recommendation based on what happened in this lab: before enabling it for critical resources, understand exactly which questions you want it to answer.

  • If your question is “who can reach this resource, and through which access path?”, the analyzer provides substantial value.

  • If your question is “was this specific API effectively blocked by this organizational or identity control layer?”, you will probably also want a runtime validation to avoid surprises when interpreting the finding.


Red Team perspective

Access Analyzer is not useful only for defensive teams, but this section requires some context.

A user or role needs permissions to query the analyzer and its findings. In addition, calls made through the console or the IAM Access Analyzer APIs are recorded in AWS CloudTrail.

It is therefore incorrect to assume that any compromised role in a member account can query the organizational analyzer, or that using it would be invisible.

In an authorized internal Red Team engagement, an identity that already has access to the analyzer could use the findings to:

  1. Review access paths without directly calling the target resource.
  2. Prioritize relevant cross-account relationships.
  3. Compare reported access with executable access.
  4. Evaluate the impact of SCPs, RCPs, and permissions boundaries.

What Access Analyzer shows—and what it does not

To make it clear:

✅ Which identities have access to a specific resource

✅ Which layers participated in the evaluation, including IAM, resource policies, SCPs, and RCPs

✅ Exportable findings for audit evidence

✅ Centralized organization-wide visibility from the account that manages the analyzer

The lab also made the following points much clearer to me:

⚠️ I would not always use it as the only evidence that a specific API has disappeared from effective access simply because an SCP or RCP blocks it at runtime

⚠️ The AccessDenied message may not always show every layer that could be contributing to the denial

⚠️ For specific denials caused by an organizational control or a permissions boundary, it is useful to complement the analysis with a controlled runtime test


Conclusion

The question that started this series was:

Who can actually access your AWS resources?

After this lab, the practical answer remains that you cannot determine it by reviewing a single policy.

IAM Access Analyzer Internal Access provides value by identifying principals, actions, and access paths for critical resources. It also centralizes results at the organization level and makes the findings available through an API.

The lab also produced a specific observation. Using the four main operations as the reference point, the finding matched the expected action set in three controlled cases. For the role with s3:* and no permissions boundary, the finding continued to show PutObject, DeleteObject, and DeleteObjectVersion, even though the restrictions appeared as APPLIED. Runtime tests confirmed that the RCP denied PutObject and the SCP denied DeleteObject.

I will not speculate about the exact cause. What I can do is document the configuration, findings, and runtime tests so that the result can be reproduced.

Because Internal Access Analyzer is a paid capability, I also recommend thinking carefully about the questions you want to answer before enabling it. If you need to identify who can access a critical bucket and through which path, it can be especially useful. But if you only need to validate a specific API, a policy review and a controlled runtime test may be enough.

The cost is calculated per monitored resource, per analyzer, and per Region. Enabling it without first defining the scope can therefore result in a larger-than-expected charge. In production, I would start with genuinely critical resources, such as buckets containing sensitive data, security logs, or resources shared across accounts, and expand the scope only when there is a clear need.

For me, the most useful interpretation of the exercise is:

  • Use the analyzer to gain visibility.
  • Understand what each field means.
  • Validate critical APIs when you need a specific answer.
  • Define the scope before enabling it.
  • Avoid interpreting a single screen without reviewing the other permission layers.

💡 Have you found unexpected access while analyzing your resources? That is often the moment when tools like this go from “interesting” to “necessary.”

Top comments (0)