AWS IAM Orphan Cleanup
Purpose
Use this runbook to find and safely delete orphan IAM resources — roles,
policies, OIDC providers, and access keys that no Terraform state owns and no
workload uses. Orphans accumulate from hand-built roles, abandoned migrations,
and incomplete terraform destroy runs. They are a security and audit liability.
This runbook follows the same shape as AWS SES Pause: Manual Notification Recovery: preconditions → safety → dry-run → approve → execute → verify → close-out.
Scope
- Accounts: any HungryHub account —
sandbox 079994049689,prod 202255947274,genai-sandbox 965444437277,genai-prod 512438352490(see AWS Account and Profile Map) - Resource types: IAM roles, managed + inline policies, OIDC providers, access keys
- Out of scope: anything still referenced in a live Terraform state — remove it via
terraform state rm/ the owning module first, never delete from under live state
When to use this runbook
Use this runbook if any of these are true:
- A
terraform state rmleft physical AWS resources behind (state cleaned, AWS side not). - A hand-built role is being retired or has been superseded by a Terraform-managed one.
- A periodic audit found roles/policies/keys with no attachment or no recent use.
Preconditions
- You have an SSO session for the target account (
aws sso login --profile <name>). - You have verified the active account with
aws sts get-caller-identity --profile <name>— orphan cleanup is a destructive, account-specific operation. - You have confirmed the resource is not in any live Terraform state (
terraform state list | grep <name>in every stack that could own it). - You have human approval from an IAM owner (
@saiqulhaq-hhor@irfanwicaksana-hh) before any delete.
Safety notes
- Always run the dry-run / discovery queries first and review the full list before deleting anything.
- IAM deletes are irreversible. A wrongly-deleted role breaks every workflow that assumes it.
- Delete in dependency order (detach/delete policies before the role) or the delete call fails.
- Never delete the shared OIDC provider
token.actions.githubusercontent.com— many roles trust it. - Record the account, operator, and timestamp of every delete for the audit trail.
Procedure
1) Confirm the active account
aws sts get-caller-identity --profile <profile>
# Confirm Account matches the intended target before continuing.
2) Find orphan IAM roles (no attachments + no recent AssumeRole)
# List roles, then for a candidate check for attached/inline policies:
aws iam list-attached-role-policies --role-name <role> --profile <profile>
aws iam list-role-policies --role-name <role> --profile <profile>
# Check whether the role has been assumed recently (CloudTrail, last N days).
# Run once per assume-role event name — a role can be assumed via plain
# AssumeRole (STS) and/or AssumeRoleWithWebIdentity (OIDC), so check both:
for EVENT in AssumeRole AssumeRoleWithWebIdentity; do
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=$EVENT \
--profile <profile> --region <region> \
--query "Events[?contains(CloudTrailEvent, '<role>')]"
done
A role with no policy attachments and no AssumeRole /
AssumeRoleWithWebIdentity traffic in the lookback window is a strong orphan
candidate.
3) Find unattached managed policies and stale OIDC providers
# Customer-managed policies with zero attachments:
aws iam list-policies --scope Local --only-attached false --profile <profile>
# OIDC providers (confirm the GitHub one is still needed before touching any):
aws iam list-open-id-connect-providers --profile <profile>
4) Find access keys unused 90+ days
aws iam list-access-keys --user-name <user> --profile <profile>
aws iam get-access-key-last-used --access-key-id <key-id> --profile <profile>
# If LastUsedDate is older than 90 days (or null), flag for rotation/removal.
5) Approval checkpoint (before any delete)
- The full candidate list is reviewed.
- Each candidate is confirmed absent from every live Terraform state.
- An IAM owner has approved the specific list.
6) Execute the safe delete order
Inline policies first, then attachments, then the role:
# 1. Delete inline policies on the role
aws iam delete-role-policy --role-name <role> --policy-name <inline-policy> --profile <profile>
# 2. Detach any managed policies
aws iam detach-role-policy --role-name <role> --policy-arn <arn> --profile <profile>
# 3. Delete the role itself
aws iam delete-role --role-name <role> --profile <profile>
# For a managed policy: delete every non-default version first, then the
# policy itself (delete-policy fails if any non-default version remains).
aws iam list-policy-versions --policy-arn <arn> --profile <profile> \
--query "Versions[?IsDefaultVersion==\`false\`].VersionId" --output text
# For each VersionId returned above:
aws iam delete-policy-version --policy-arn <arn> --version-id <version-id> --profile <profile>
# Then delete the policy (this removes the default version too):
aws iam delete-policy --policy-arn <arn> --profile <profile>
7) Verify results
aws iam get-role --role-name <role> --profile <profile> # expect NoSuchEntity
Collect for the record: account ID, each deleted resource name, the operator, and the timestamp.
8) Close-out checklist
- Active account verified before each delete.
- Every deleted resource confirmed absent from live Terraform state first.
- Delete list approved by an IAM owner.
- Deletes ran in dependency order (inline → attached → role).
- Post-delete
get-rolereturnsNoSuchEntityfor each. - Shared OIDC provider untouched.
- Cleanup summary (account, resources, operator, timestamp) recorded.
Worked example — 2026-06-05 relay OIDC orphan cleanup
The OIDC Subject Typo Incident left
5 orphan resources in main prod (202255947274):
prod-oidc-hh-relay-eval-role+ inlinehh-relay-eval-ci-policyprod-oidc-hh-relay-agentcore-deploy-role+ inlinehh-relay-agentcore-deploy-policyrelay-eval-ci(main-prod duplicate) + inlinerelay-eval-ci-policy
The state was removed earlier via terraform state rm
(hungryhub-terraform PR #378,
cleanup-orphan-oidc-relay-state.yml). On 2026-06-05 the 5 physical
resources were destroyed using exactly the step-6 order above
(aws iam delete-role-policy then aws iam delete-role per role), outside any
PR. This is the canonical example of the state-side-then-AWS-side two-step.
Two distinct relay cleanups — don’t conflate them.
cleanup-orphan-oidc-relay-state.ymldoesterraform state rmon the orphan OIDC roles (the ones above). A separate workflow,destroy-orphaned-relay-iam.yml, destroys a leftoverrelay-bot-*IAM user (residual from removed access-key resources) — a different resource, with its ownconfirm_destroyinput. The repo-side runbook documents both:hungryhub-terraform/docs/runbooks/orphan-oidc-relay-state-cleanup.md.
Related
- OIDC Subject Typo Incident — what produced these orphans
- AWS Account and Profile Map — which account/profile to target
- hungryhub-iam — Architecture and Operations
hungryhub-terraform/docs/runbooks/orphan-oidc-relay-state-cleanup.md— the repo-side state-rm runbook + the two-cleanups distinction- AWS SES Pause: Manual Notification Recovery — the runbook this mirrors