The problem: two identity systems and a hidden web of references
- A multi-account AWS organization (30+ accounts) still authenticated people through a legacy SAML SSO setup, with roles created per account.
- Each legacy role was a long-lived dependency. Its ARN was referenced in places nobody
had a full list of:
- IAM managed, inline and trust policies
- KMS key policies and S3 bucket policies
- resource policies on Secrets Manager, Lambda, EventBridge, ECR, SNS and SQS
- Terraform code across many repositories
- Removing those roles blindly would lock teams out of production. Leaving them meant two identity systems, inconsistent permissions, and no single answer to "who can do what, where?"
- Static IAM users left behind by a retired secrets platform added long-lived access keys to the mix.
The approach: discover, codify, then retire
- Discover every reference to a legacy role before deleting anything.
- Codify the replacement: permission sets, groups and account assignments as Terraform, with the identity provider owning the user lifecycle.
- Retire legacy roles and static users account by account, with a written rule for which planned deletions are routine and which need a human.
Discover (12 surfaces + 100+ repos) → Clean up references → Identity Center permission sets
→ Group / account assignments → Retire legacy roles and static users
What I built
1. Discovery tooling
Bash tooling that sweeps every account for legacy SSO ARN patterns across a dozen resource surfaces, plus a single-pass sweep of 100+ Git repositories, so references hiding in code were found as well as references in live resources. The output was a map of what would break, and where, before anything was removed.
Illustrative: the shape of the sweep for three of the surfaces.
#!/usr/bin/env bash
# Find references to legacy SSO roles before removing them.
PATTERN='role/legacy-sso-' # placeholder: your legacy role naming pattern
# IAM role trust policies
for role in $(aws iam list-roles --query 'Roles[].RoleName' --output text); do
aws iam get-role --role-name "$role" \
--query 'Role.AssumeRolePolicyDocument' --output json \
| grep -q "$PATTERN" && echo "trust-policy $role"
done
# KMS key policies
for key in $(aws kms list-keys --query 'Keys[].KeyId' --output text); do
aws kms get-key-policy --key-id "$key" --policy-name default --output text 2>/dev/null \
| grep -q "$PATTERN" && echo "kms-key-policy $key"
done
# S3 bucket policies
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
aws s3api get-bucket-policy --bucket "$b" --query Policy --output text 2>/dev/null \
| grep -q "$PATTERN" && echo "bucket-policy $b"
done
2. Identity Center as code
A Terraform module that defines permission sets, group assignments and account assignments for every engineering role (cloud engineering, security administration, SRE, systems administration). It runs in two modes from one codebase:
- Okta SCIM-provisioned users and groups are referenced by name, never created, so joiners, movers and leavers are handled by the identity provider, not in infrastructure code.
- Service and break-glass accounts are created and managed directly, so emergency access doesn't depend on the identity provider being up.
Terraform tests cover each path: user only, group only, SCIM reference, and manual creation.
Illustrative: a permission set, a SCIM group looked up rather than created, and an assignment.
data "aws_ssoadmin_instances" "this" {}
locals {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
}
resource "aws_ssoadmin_permission_set" "sre" {
name = "SRE"
instance_arn = local.instance_arn
session_duration = "PT4H"
}
resource "aws_ssoadmin_managed_policy_attachment" "sre_readonly" {
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.sre.arn
managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# The group is owned by the IdP via SCIM: look it up, never create it.
data "aws_identitystore_group" "sre" {
identity_store_id = local.identity_store_id
alternate_identifier {
unique_attribute {
attribute_path = "DisplayName"
attribute_value = "sre-engineers"
}
}
}
resource "aws_ssoadmin_account_assignment" "sre_prod" {
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.sre.arn
principal_type = "GROUP"
principal_id = data.aws_identitystore_group.sre.group_id
target_type = "AWS_ACCOUNT"
target_id = "111122223333"
}
3. A safe upgrade path across 30+ accounts
The enterprise IAM module's major-version upgrade had to roll across 30+ accounts, and its plans contained expected deletions. I defined the review rule:
| Planned change | Decision |
|---|---|
| Removal of static IAM users left by a retired secrets platform | Acceptable: role-based access replaced them |
| Policy attachment replacements | Acceptable |
| Removal of legacy SSO roles | Escalate immediately: could lock out a production team |
Legacy SSO cleanup in an account became a prerequisite for that account's version upgrade.
Things to watch
- Match on identity-store IDs, not names. SCIM-provisioned users weren't mapping to their groups whenever their Identity Center user ID differed from the declared username. The fix was to look identities up by identity-store ID. Name matching is fragile whenever another system owns the identity.
- Trust policies are the easy ones to miss. A role can look unused yet still be named as a trusted principal somewhere else. Search trust policies and resource policies, not just permission policies.
- Key policies can lock you out of a key. If a KMS key policy names only a legacy role and that role is deleted first, nobody can administer the key. Update key policies before retiring roles.
- Code is part of the estate. Live resources come back from Terraform on the next apply if the repositories still reference the old ARNs. That's why the repository sweep matters.
What it solves
- One identity system for the AWS organization, with every permission set and assignment version-controlled and peer-reviewed.
- Short-lived credentials at sign-in instead of long-lived per-account roles and static IAM users.
- The identity lifecycle stays upstream: disabling someone at the identity provider removes their AWS access.
- Reviewable upgrades: reviewers know which deletions are routine and which need a human.
What it doesn't solve
- Over-broad permission sets. Identity Center makes access consistent, not minimal. Permission sets still need least-privilege design and periodic review, for example with IAM Access Analyzer's unused-access findings.
- Break-glass access sits outside SCIM by design, so it needs its own controls: credentials held securely, an alert every time one is used, and periodic tests that it still works when it's needed.
- Machine identities. This covers people. Workloads need their own pattern (see the case study on Kubernetes and IRSA).
- Name dependency. Referencing SCIM groups by name means the identity provider's group names must stay stable.
Checklist
- [ ] Every legacy role reference found across policies, key policies, resource policies and code
- [ ] Key policies updated before any role they name is removed
- [ ] Permission sets defined in code, one per role, least privilege
- [ ] SCIM identities referenced, never created, in infrastructure code
- [ ] Break-glass accounts outside SCIM, monitored and tested
- [ ] Static IAM users replaced with role-based access
- [ ] A written rule for which planned deletions need escalation
Further reading
- AWS IAM Identity Center User Guide: https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html
- Automatic provisioning (SCIM) in Identity Center: https://docs.aws.amazon.com/singlesignon/latest/userguide/provision-automatically.html
- IAM policy evaluation logic: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html
- Terraform AWS provider:
aws_ssoadmin_permission_set,aws_ssoadmin_account_assignment,aws_identitystore_group(Terraform Registry)