IAM Least Privilege Automation: Right-Sizing Permissions with Access Analysis

IAM Least Privilege Automation: Right-Sizing Permissions with Access Analysis

Problem

IAM permissions accumulate. Roles that were provisioned with AdministratorAccess to unblock a deployment in 2022 are still carrying that policy in 2026. Service accounts copy-pasted from an existing role inherit every permission the original role needed — plus the ones it didn’t. Developers request s3:* when they need s3:GetObject on a single prefix, and nobody audits the policy six months later when the task is done.

The result is a cloud environment where effective permissions are orders of magnitude broader than what any workload actually uses. When an attacker compromises a service account — through a stolen credential, an SSRF vulnerability, or a supply chain compromise — they inherit every one of those permissions.

Specific failure modes:

  • Copy-paste provisioning: A new service needs S3 access. An engineer finds an existing role, copies its policy, and attaches it. The new role inherits ec2:*, rds:*, and iam:PassRole from the original — none of which the new service needs.
  • “Just add star for now”: Debugging a permission error at 2am, an engineer changes s3:GetObject to s3:*. The fix works; the cleanup never happens.
  • Role inheritance and managed policies: AWS-managed policies like PowerUserAccess grant hundreds of permissions. Attaching them to a role that needs three specific actions adds hundreds of unnecessary permissions to the blast radius.
  • Never removing permissions: Services are decommissioned, but their roles are not. Role policies granted for a migration six months ago remain active. Permissions granted for a temporary compliance audit are never revoked.
  • Delegated admin abuse: Administrators create sub-administrator roles for developers, granting iam:CreateRole and iam:AttachRolePolicy without permission boundaries. Developers can then create roles with permissions exceeding their own.

The correct model is simple to state and difficult to enforce without tooling: every principal should have exactly the permissions it demonstrably needs, scoped to the resources it demonstrably accesses, for no longer than required.

Target systems: AWS IAM Access Analyzer; Cloudsplaining; GCP Policy Analyzer; Azure Privileged Identity Management; PMapper; OIDC workload identity (AWS, GCP, Azure).

Threat Model

  • Adversary 1 — Compromised service account lateral movement: An attacker exploits an SSRF in a web application and reaches the EC2 metadata endpoint. The instance role has iam:PassRole, ec2:RunInstances, and s3:* — accumulated through copy-paste provisioning. The attacker uses iam:PassRole to attach a powerful role to a new EC2 instance they launch, effectively escalating to administrator. Least-privilege enforcement would have removed iam:PassRole entirely from this role.
  • Adversary 2 — Abandoned role exploitation: A CI/CD service account for a decommissioned project retains ecr:* and ecs:UpdateService permissions. An attacker who obtains the access key (leaked in git history) can push a malicious container image and trigger a deployment to production ECS services.
  • Adversary 3 — Privilege escalation via permission graph: An attacker with limited IAM permissions uses a chain of iam:CreateRoleiam:AttachRolePolicysts:AssumeRole to construct a path to AdministratorAccess. PMapper graph analysis would have identified this escalation path before the attacker did.
  • Adversary 4 — Overpermissive workload exfiltration: A compromised Lambda function with s3:GetObject on * (rather than a specific bucket ARN) can read every S3 object in the account. Resource-scoped least-privilege limits exfiltration to the specific bucket the function legitimately needs.
  • Access level: Adversaries 1 and 4 exploit already-compromised workload credentials. Adversaries 2 and 3 use valid but overpermissive credentials.
  • Objective: Privilege escalation, lateral movement, data exfiltration.
  • Blast radius: Without least-privilege enforcement, a single compromised credential can pivot to account-wide access. With least-privilege, the blast radius is bounded to what the principal demonstrably needs.

Configuration

Step 1: AWS IAM Access Analyzer — Generating Least-Privilege Policies from CloudTrail

IAM Access Analyzer’s policy generation feature reads CloudTrail logs and produces a policy containing only the actions a principal actually used during the observation window. The observation window can be up to 90 days.

aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/MyServiceRole

aws iam get-service-last-accessed-details \
  --job-id <job-id> \
  --query 'ServicesLastAccessed[?TotalAuthenticatedEntities>`0`].[ServiceName,LastAuthenticated]' \
  --output table

For policy generation (requires CloudTrail data events enabled):

aws accessanalyzer start-policy-generation \
  --policy-generation-details principalArn=arn:aws:iam::123456789012:role/MyServiceRole \
  --cloud-trail-details '{
    "trails": [{"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/management-events", "allRegions": true}],
    "accessRole": "arn:aws:iam::123456789012:role/AccessAnalyzerRole",
    "startTime": "2026-02-01T00:00:00Z",
    "endTime": "2026-05-01T00:00:00Z"
  }'

aws accessanalyzer get-generated-policy \
  --job-id <job-id> \
  --include-service-level-template

The output is a JSON IAM policy containing exactly the actions observed in CloudTrail. This becomes the candidate replacement policy — not the final policy, but the minimum baseline to review and apply.

Access Analyzer also surfaces unused access findings: IAM roles with permissions that were never used within the analysis window, unused access keys, and unused passwords. Enable the unused access analyzer:

aws accessanalyzer create-analyzer \
  --analyzer-name unused-access-analyzer \
  --type ACCOUNT_UNUSED_ACCESS \
  --configuration '{"unusedAccess": {"unusedAccessAge": 90}}'

Query findings:

aws accessanalyzer list-findings-v2 \
  --analyzer-arn arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unused-access-analyzer \
  --filter '{"findingType": {"eq": ["UnusedPermission"]}}' \
  --query 'findings[].{Resource: resource, Status: status, UpdatedAt: updatedAt}' \
  --output table

Step 2: Cloudsplaining — IAM Risk Reporting and Dangerous Permission Combinations

Cloudsplaining generates HTML reports identifying IAM policy risks across an entire AWS account. It is particularly useful for detecting dangerous permission combinations that Access Analyzer does not flag.

pip install cloudsplaining

aws iam get-account-authorization-details \
  --output json > iam-account-details.json

cloudsplaining scan \
  --input-file iam-account-details.json \
  --output ./cloudsplaining-report \
  --exclusions-file exclusions.yml

The exclusions file prevents noise from expected high-privilege roles (e.g., your break-glass administrator):

policies:
  - "AdministratorAccess"
roles:
  - "OrganizationAccountAccessRole"
users: []
groups: []

The report flags:

  • iam:PassRole combined with ec2:RunInstances: An attacker who compromises this role can launch an EC2 instance with any role in the account, effectively escalating to the most powerful role they can pass.
  • iam:PassRole combined with lambda:CreateFunction or lambda:UpdateFunctionCode: Same pattern — create a Lambda with a powerful role.
  • iam:CreatePolicyVersion: Can overwrite an existing policy with arbitrary permissions.
  • iam:AttachUserPolicy, iam:AttachRolePolicy: Can attach any policy, including AdministratorAccess, to any principal.
  • Data exfiltration risks: s3:GetObject with * resource, rds:CopyDBSnapshot + rds:ModifyDBSnapshotAttribute, ec2:CreateSnapshot + ec2:ModifySnapshotAttribute.
  • Privilege escalation combos: iam:UpdateAssumeRolePolicy (modify trust policy of any role) + sts:AssumeRole.

Generate a machine-readable findings file for pipeline integration:

cloudsplaining scan \
  --input-file iam-account-details.json \
  --output ./cloudsplaining-report \
  --fmt json

Step 3: GCP Policy Analyzer — Querying Who Has Access to What

GCP Policy Analyzer lets you query the effective IAM policies across your resource hierarchy — organisation, folders, projects, and resources — to determine which principals have access to which resources and why.

gcloud policy-intelligence query-activity \
  --project=my-project-id \
  --activity-type=serviceAccountLastAuthentication \
  --query-statement='activities.activity."serviceAccountLastAuthentication".lastAuthenticatedTime < "2026-02-01T00:00:00Z"'

Find all principals with access to a specific resource:

gcloud asset search-all-iam-policies \
  --scope=projects/my-project-id \
  --query='policy.role.permissions:storage.objects.get' \
  --format='table(resource, policy.bindings.role, policy.bindings.members)'

Analyse policy activity to identify service accounts unused in the last 90 days:

gcloud policy-intelligence query-activity \
  --project=my-project-id \
  --activity-type=serviceAccountKeyLastAuthentication \
  --query-statement='activities.activity."serviceAccountKeyLastAuthentication".lastAuthenticatedTime < "2026-02-09T00:00:00Z"' \
  --format='table(fullResourceName,activities.activity.serviceAccountKeyLastAuthentication.lastAuthenticatedTime)'

GCP’s recommender API generates IAM recommendations automatically. Review and apply them:

gcloud recommender recommendations list \
  --project=my-project-id \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --format='table(name, stateInfo.state, primaryImpact.securityProjection.details.revokedIamPermissionsCount)'

gcloud recommender recommendations mark-applied \
  --project=my-project-id \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --recommendation=RECOMMENDATION_ID \
  --etag=ETAG

IAM recommendations are generated weekly based on 90 days of activity. A recommendation that suggests removing roles/editor from a service account that has only used bigquery.tables.get and bigquery.tables.list is actionable immediately.

For workloads running outside GCP, use Workload Identity Federation to eliminate service account keys entirely — see GCP Workload Identity Federation for implementation details.

Step 4: Azure Privileged Identity Management and Access Reviews

Azure Privileged Identity Management (PIM) implements just-in-time (JIT) access for Azure RBAC roles and Entra ID roles. Instead of permanent role assignments, users are eligible for roles and activate them on demand with justification and a bounded duration.

Configure PIM via the Azure CLI:

az rest --method PUT \
  --url "https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/{guid}?api-version=2022-04-01-preview" \
  --body '{
    "properties": {
      "principalId": "<user-object-id>",
      "roleDefinitionId": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
      "requestType": "AdminAssign",
      "scheduleInfo": {
        "expiration": {
          "type": "AfterDuration",
          "duration": "P180D"
        }
      }
    }
  }'

Activation requests require MFA and a business justification. You can also require approval from a designated approver. The activation window is bounded (typically 1–8 hours) after which the role reverts to ineligible.

Access reviews audit existing role assignments on a schedule:

az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions" \
  --body '{
    "displayName": "Subscription Owner Quarterly Review",
    "descriptionForAdmins": "Quarterly review of subscription owner assignments",
    "scope": {
      "@odata.type": "#microsoft.graph.principalResourceMembershipsScope",
      "principalScopes": [{"@odata.type": "#microsoft.graph.accessReviewQueryScope", "query": "/users", "queryType": "MicrosoftGraph"}],
      "resourceScopes": [{"@odata.type": "#microsoft.graph.accessReviewQueryScope", "query": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleAssignments?$filter=roleDefinitionId eq '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'", "queryType": "ARM"}]
    },
    "reviewers": [{"query": "/users/<security-team-object-id>", "queryType": "MicrosoftGraph"}],
    "settings": {
      "mailNotificationsEnabled": true,
      "autoApplyDecisionsEnabled": true,
      "defaultDecision": "Deny",
      "instanceDurationInDays": 14,
      "recurrence": {"pattern": {"type": "absoluteMonthly", "interval": 3}, "range": {"type": "noEnd"}}
    }
  }'

With autoApplyDecisionsEnabled: true and defaultDecision: Deny, reviewers who take no action result in role removal — the safe default. Reviewers who explicitly approve an assignment preserve it for another quarter.

Entra ID also provides access recommendations driven by usage signals: principals who have not used a role in 90 days surface as candidates for removal or demotion to a lower-privilege role.

Step 5: AWS Permission Boundaries — Limiting Delegated Admin Blast Radius

Permission boundaries constrain the maximum effective permissions a role can have, regardless of what policies are attached. They are the primary mechanism for safe delegated administration: you can allow developers to create and manage their own roles without allowing them to create roles more powerful than their own.

A permission boundary for developer-managed roles:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowDeveloperServices",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:Query",
        "dynamodb:Scan",
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "xray:PutTraceSegments",
        "xray:PutTelemetryRecords"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyIAMEscalation",
      "Effect": "Deny",
      "Action": [
        "iam:CreateRole",
        "iam:AttachRolePolicy",
        "iam:PutRolePolicy",
        "iam:PassRole"
      ],
      "Resource": "*"
    }
  ]
}

Enforce that any role created by developers must have this boundary attached:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowRoleCreationWithBoundary",
      "Effect": "Allow",
      "Action": ["iam:CreateRole", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
      "Resource": "arn:aws:iam::123456789012:role/dev-*",
      "Condition": {
        "StringEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/DeveloperBoundary"
        }
      }
    },
    {
      "Sid": "DenyBoundaryModification",
      "Effect": "Deny",
      "Action": [
        "iam:DeleteRolePermissionsBoundary",
        "iam:PutRolePermissionsBoundary"
      ],
      "Resource": "*"
    }
  ]
}

The DenyBoundaryModification statement prevents a developer from removing the boundary from a role they created, which would otherwise allow them to escalate permissions.

Step 6: OIDC Workload Identity — Short-Lived Scoped Credentials

Long-lived access keys are the root cause of many IAM bloat problems: they are created, distributed, and then never rotated or revoked. OIDC workload identity replaces static keys with short-lived tokens scoped to a specific workload identity.

For GitHub Actions accessing AWS:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
        }
      }
    }
  ]
}

The sub condition locks the trust relationship to a specific repository and environment. A token from myorg/otherrepo cannot assume this role. This is least-privilege at the identity layer: not just scoping permissions, but scoping which identities can request them.

For Kubernetes workloads, IRSA (IAM Roles for Service Accounts) provides the same pattern — see AWS IRSA Workload Identity for implementation details. The OIDC credential expires after one hour by default; there are no access keys to leak, rotate, or audit.

Step 7: PMapper — Privilege Escalation Graph Analysis

PMapper builds a directed graph of IAM principals and the relationships between them. An edge from principal A to principal B means A can escalate to B’s permissions through some sequence of API calls. It identifies privilege escalation paths that static policy analysis misses.

pip install principalmapper

pmapper --profile myprofile graph create

pmapper --profile myprofile graph display

pmapper --profile myprofile analysis \
  --output-type text \
  --include-perm-boundary-in-output

pmapper --profile myprofile argquery \
  --principal arn:aws:iam::123456789012:role/WebAppRole \
  --action iam:CreateRole \
  --resource '*'

The argquery command answers: can WebAppRole eventually perform iam:CreateRole on any resource, through any chain of sts:AssumeRole calls or other IAM actions? If the answer is yes, PMapper shows the full path:

WebAppRole
  -> can use iam:PassRole to pass AdminRole to Lambda
  -> can use lambda:CreateFunction with AdminRole
  -> AdminRole can perform iam:CreateRole

Run PMapper as part of a CI gate on any IAM change:

pmapper --profile myprofile graph create

pmapper --profile myprofile analysis \
  --output-type json > pmapper-findings.json

jq '.findings[] | select(.description | contains("can escalate"))' pmapper-findings.json

If any finding surfaces a new escalation path introduced by the PR, fail the pipeline.

Step 8: Building the Continuous Right-Sizing Pipeline

The individual tools above are useful in isolation. Combined in a pipeline, they enforce least-privilege continuously rather than as a point-in-time audit.

Pipeline architecture:

CloudTrail (90-day window)
    ↓
Access Analyzer: generate-service-last-accessed-details per role
    ↓
Delta analysis: current policy vs observed usage
    ↓
Candidate policy: minimum observed permissions
    ↓
Cloudsplaining: check candidate policy for dangerous combos
    ↓
PMapper: check candidate policy for new escalation paths
    ↓
PR: proposed policy replacement with diff
    ↓
Human review + approval
    ↓
Terraform apply: update IAM role policy
    ↓
Drift detection: alert if effective policy diverges from Terraform state

The PR step is important. Automated policy replacement without review creates operational risk — a workload that uses a permission intermittently (for example, a monthly billing report that writes to S3) will not appear in a 30-day CloudTrail window. The generated policy will be too tight. Human review catches these cases.

For the drift detection step, use AWS Config rules:

aws configservice put-config-rule --config-rule '{
  "ConfigRuleName": "iam-no-inline-policies",
  "Source": {"Owner": "AWS", "SourceIdentifier": "IAM_NO_INLINE_POLICY_CHECK"}
}'

aws configservice put-config-rule --config-rule '{
  "ConfigRuleName": "iam-policy-no-statements-with-admin-access",
  "Source": {"Owner": "AWS", "SourceIdentifier": "IAM_POLICY_NO_STATEMENTS_WITH_ADMIN_ACCESS"}
}'

aws configservice put-config-rule --config-rule '{
  "ConfigRuleName": "iam-role-managed-policy-check",
  "ConfigRuleName": "iam-no-inline-policies",
  "Source": {"Owner": "AWS", "SourceIdentifier": "IAM_NO_INLINE_POLICY_CHECK"}
}'

Integrate with EventBridge to trigger re-analysis whenever a role policy changes:

aws events put-rule \
  --name iam-policy-change-trigger \
  --event-pattern '{
    "source": ["aws.iam"],
    "detail-type": ["AWS API Call via CloudTrail"],
    "detail": {
      "eventName": ["PutRolePolicy", "AttachRolePolicy", "CreateRole", "UpdateAssumeRolePolicy"]
    }
  }' \
  --state ENABLED

aws events put-targets \
  --rule iam-policy-change-trigger \
  --targets '[{
    "Id": "iam-right-sizing-function",
    "Arn": "arn:aws:lambda:us-east-1:123456789012:function:iam-right-sizing-analyzer"
  }]'

Every IAM policy change triggers analysis. New findings open a GitHub issue or Jira ticket with the specific role, the change that was made, and the recommended least-privilege replacement.

Verification

After implementing this pipeline, verify coverage:

aws accessanalyzer list-analyzers \
  --query 'analyzers[].{Name: name, Type: type, Status: status}' \
  --output table

aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/MyServiceRole \
  && sleep 5 \
  && aws iam get-service-last-accessed-details \
    --job-id <job-id> \
    --query 'ServicesLastAccessed[?TotalAuthenticatedEntities==`0`].ServiceName' \
    --output text

pmapper --profile myprofile analysis --output-type json \
  | jq '[.findings[] | select(.description | contains("can escalate"))] | length'

The first command confirms analyzers are active. The second lists services that appear in the policy but were never accessed — candidates for immediate removal. The third counts live escalation paths; this number should be zero for all non-administrator roles.

For GCP, verify recommender coverage:

gcloud recommender recommendations list \
  --project=my-project-id \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --filter='stateInfo.state=ACTIVE' \
  --format='table(name, stateInfo.state)' \
  | wc -l

Any non-zero count represents actionable recommendations that have not been applied.

Operational Notes

CloudTrail coverage is a prerequisite. Access Analyzer policy generation requires CloudTrail data events for S3, Lambda, and other services — not just management events. Management events alone will produce incomplete policies that miss resource-level access patterns.

90 days is the observation window ceiling for Access Analyzer. Permissions used less frequently than once per quarter will not appear in generated policies. Supplement automated analysis with manual review of permissions for infrequent operations (quarterly billing exports, annual compliance reports, disaster recovery procedures).

Generated policies are a floor, not a ceiling. Start with the generated policy, add back permissions for known infrequent operations with specific resource ARNs, then PR that as the replacement. Never apply generated policies directly to production without review.

PMapper needs periodic re-runs. The IAM graph changes with every role and policy modification. Re-run graph creation weekly or on every IAM change event. Stale graphs miss new escalation paths.

Permission boundaries require organisational consistency. A permission boundary policy is only effective if it cannot be removed. Enforce boundary attachment through an SCP at the organisation level:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RequireBoundaryOnRoleCreation",
      "Effect": "Deny",
      "Action": "iam:CreateRole",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::*:policy/RequiredBoundary"
        }
      }
    }
  ]
}

Applied at the organisation level via SCP, this cannot be overridden by any account-level IAM policy — including from the root user of a member account.

For cross-cloud environments, the Zero Trust Architecture Principles article covers how least-privilege IAM fits into the broader zero trust model, including network segmentation and continuous verification.

The goal is not a one-time cleanup. IAM bloat is structural — it will re-accumulate unless the pipeline continuously detects and remediates it. Treat IAM right-sizing as a continuous process, not a project.