Initializing Enclave...

How to Fix IAM Access Advisor Showing Unused But Policy Evaluation Still Denying Access

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–45 mins


TL;DR

  • What broke: IAM Access Advisor reports a service as unused (no last-accessed data), but aws iam simulate-principal-policy or live API calls return an explicit Deny. The identity policy is irrelevant — a higher-order policy layer (SCP, permission boundary, or resource-based policy) is the actual enforcement point, and Access Advisor does not evaluate those layers.
  • How to fix it: Run aws iam simulate-principal-policy with --resource-arns and cross-reference against active SCPs on the account and any permission boundaries attached to the role. Remove the conflicting explicit Deny or add the missing Allow at the correct layer.
  • Shortcut: Use our Client-Side Sandbox below to auto-refactor this — paste your policy JSON and simulation output, and it identifies the blocking layer without sending your ARNs anywhere.

The Incident (What Does the Error Mean?)

Access Advisor's last-accessed data is sourced from CloudTrail service last-accessed timestamps scoped to the identity policy only. It has zero visibility into:

  • Service Control Policies (SCPs) at the AWS Organizations level
  • Permission boundaries attached to the IAM role or user
  • Resource-based policies (S3 bucket policies, KMS key policies, SQS queue policies)
  • Session policies passed during sts:AssumeRole

So when Access Advisor says "S3 last accessed: never", it means the identity policy has never successfully called S3 — not that the principal has no S3 access configured. The actual deny is firing upstream.

Raw simulation output showing the symptom:

$ aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/AppRole \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::prod-data-bucket/*

{
  "EvaluationResults": [
    {
      "EvalActionName": "s3:GetObject",
      "EvalDecision": "explicitDeny",
      "MatchedStatements": [
        {
          "SourcePolicyId": "PermissionsBoundary",
          "SourcePolicyType": "IAM Policy",
          "StartPosition": { "Line": 1, "Column": 1 }
        }
      ]
    }
  ]
}

The identity policy allows s3:GetObject. The permission boundary does not. Access Advisor never sees a successful call, so it marks S3 as "unused" — and an engineer removes the S3 allow from the identity policy, thinking it's dead code. The boundary deny remains. The app breaks silently in prod on next deploy.


The Attack Vector / Blast Radius

This is a silent misconfiguration amplifier. The danger is not the deny itself — it's the operational response to the false signal.

Scenario 1 — Privilege escalation via cleanup: A security team runs an Access Advisor audit to enforce least privilege. They see iam:PassRole marked as "unused" on a CI/CD role. They remove it from the identity policy. The SCP was the actual blocker. Six months later, the SCP is updated (org restructure, new OU), the block lifts, and the identity policy now has iam:PassRole re-added by a developer who "just needed it to work" — but this time without the SCP guardrail, because the SCP was modified separately. Net result: privilege escalation through policy drift.

Scenario 2 — KMS key lockout: A KMS key policy has an explicit Deny for a role. Access Advisor shows KMS as unused for that role. Team removes KMS permissions from the identity policy. Key policy deny is later removed during a rotation. Application now has zero KMS permissions at the identity layer and cannot decrypt data. RTO: however long it takes someone to realize Access Advisor lied.

Scenario 3 — Cross-account resource policy gap: Role in Account A accesses S3 in Account B. The bucket policy in Account B has no Allow for Account A's role. Access Advisor in Account A shows S3 unused. It literally cannot see Account B's bucket policy. This is not a bug — it's a documented scope limitation that engineers routinely forget.


How to Fix It (The Solution)

Step 1: Identify the actual blocking layer

# Simulate with full context — this is the only reliable diagnostic
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME \
  --action-names s3:GetObject kms:Decrypt iam:PassRole \
  --resource-arns "arn:aws:s3:::your-bucket/*" \
  --context-entries '[{"ContextKeyName":"aws:RequestedRegion","ContextKeyValues":["us-east-1"],"ContextKeyType":"string"}]'

Look at SourcePolicyType in MatchedStatements. Values to watch:

  • IAM Policy with ID PermissionsBoundary → permission boundary is the blocker
  • ResourceBasedPolicy → the resource (S3/KMS/SQS) has a deny or missing allow
  • Organizations Policy → SCP is blocking

Step 2: Check SCPs on the account

# Get the account's OU chain
aws organizations list-parents --child-id ACCOUNT_ID

# List SCPs attached at each OU level
aws organizations list-policies-for-target \
  --target-id ou-XXXX-XXXXXXXX \
  --filter SERVICE_CONTROL_POLICY

# Get the actual SCP content
aws organizations describe-policy --policy-id p-XXXXXXXXXXXX

Basic Fix — Permission Boundary Missing Allow

# Permission Boundary Policy (arn:aws:iam::123456789012:policy/AppRoleBoundary)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEC2Core",
      "Effect": "Allow",
      "Action": ["ec2:Describe*"],
      "Resource": "*"
    },
+   {
+     "Sid": "AllowS3ReadProd",
+     "Effect": "Allow",
+     "Action": [
+       "s3:GetObject",
+       "s3:ListBucket"
+     ],
+     "Resource": [
+       "arn:aws:s3:::prod-data-bucket",
+       "arn:aws:s3:::prod-data-bucket/*"
+     ]
+   }
  ]
}

Enterprise Best Practice — SCP with Explicit Deny Requiring Condition

Never use a blanket SCP deny without a condition key escape hatch for break-glass roles.

# SCP attached at OU level
{
  "Version": "2012-10-17",
  "Statement": [
    {
-     "Sid": "DenyS3Delete",
-     "Effect": "Deny",
-     "Action": "s3:DeleteObject",
-     "Resource": "*"
+     "Sid": "DenyS3DeleteExceptBreakGlass",
+     "Effect": "Deny",
+     "Action": "s3:DeleteObject",
+     "Resource": "*",
+     "Condition": {
+       "StringNotLike": {
+         "aws:PrincipalARN": [
+           "arn:aws:iam::*:role/BreakGlassRole",
+           "arn:aws:iam::*:role/DataLifecycleLambdaRole"
+         ]
+       }
+     }
    }
  ]
}

Critical rule: Every SCP Deny must be reviewed alongside Access Advisor output. Access Advisor unused ≠ permission unused. It means the identity policy layer was never the enforcement point.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Never use Access Advisor alone for least-privilege audits

Access Advisor is a starting signal, not a verdict. Always pair it with simulate-principal-policy before removing any permission.

# Add this as a pre-PR check in your IAM policy pipeline
aws iam simulate-principal-policy \
  --policy-source-arn "$ROLE_ARN" \
  --action-names $(cat actions-to-remove.txt | tr '\n' ' ') \
  --resource-arns "*" | \
  jq '.EvaluationResults[] | select(.EvalDecision != "allowed") | .EvalActionName'

2. Checkov — Scan for permission boundaries on all roles

# .checkov.yml
checks:
  - CKV_AWS_274  # Ensure IAM roles have permission boundaries attached
checkov -d ./terraform --check CKV_AWS_274 --compact

3. OPA policy — Block IAM role creation without permission boundary

# policy/iam_role_boundary.rego
package aws.iam

deny[msg] {
  resource := input.resource.aws_iam_role[name]
  not resource.config.permissions_boundary
  msg := sprintf(
    "IAM role '%v' must have a permissions_boundary. Access Advisor cannot detect SCP/boundary conflicts without one.",
    [name]
  )
}

4. Terraform — Enforce boundary on every role module

resource "aws_iam_role" "app_role" {
  name               = "AppRole"
  assume_role_policy = data.aws_iam_policy_document.assume.json
+ permissions_boundary = aws_iam_policy.role_boundary.arn
}

5. AWS Config Rule — Continuous compliance

aws configservice put-config-rule --config-rule '{
  "ConfigRuleName": "iam-role-requires-permission-boundary",
  "Source": {
    "Owner": "AWS",
    "SourceIdentifier": "IAM_CUSTOMER_POLICY_BLOCKED_KMS_ACTIONS"
  }
}'

For SCP drift specifically, enable AWS Organizations Tag Policy + Drift Detection and alert on any SCP modification via CloudTrail → EventBridge → SNS with a 15-minute SLA for review.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →