Last active
June 4, 2026 01:14
-
-
Save vishalapte/a7ab8918bbf6e6c6f2e29ca9f91f56f5 to your computer and use it in GitHub Desktop.
Idempotent snapshot of AWS Organizations IAM state across all accounts. Identity Center permission sets and org-managed roles land in global/; account-specific resources in accounts/<name>/. Member accounts symlink to global resources — drift is visible in git status.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # aws-dump-accounts.sh | |
| # Fetches AWS state for all org accounts in the correct order: management | |
| # account first (establishes canonical files), then member accounts | |
| # (symlink where appropriate). | |
| # | |
| # Run as: ADMIN_PROFILE=aws_iam_user ./scripts/aws-dump-accounts.sh | |
| set -euo pipefail | |
| ADMIN_PROFILE="${ADMIN_PROFILE:-admin}" | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| AWS="aws --profile $ADMIN_PROFILE --output json" | |
| MGMT_ACCOUNT_ID=$($AWS organizations describe-organization \ | |
| --query 'Organization.MasterAccountId' --output text) | |
| echo "=== Management account: $MGMT_ACCOUNT_ID ===" | |
| AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}" \ | |
| ADMIN_PROFILE="$ADMIN_PROFILE" \ | |
| "$SCRIPT_DIR/aws-dump-state.sh" | |
| echo | |
| echo "=== Member accounts ===" | |
| ACCOUNT_IDS=$($AWS organizations list-accounts \ | |
| --query 'Accounts[?Status==`ACTIVE`].[Id]' --output text) | |
| for row in $ACCOUNT_IDS; do | |
| [ "$row" = "$MGMT_ACCOUNT_ID" ] && continue | |
| echo | |
| AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}" \ | |
| ADMIN_PROFILE="$ADMIN_PROFILE" \ | |
| TARGET_ACCOUNT_ID="$row" \ | |
| "$SCRIPT_DIR/aws-dump-state.sh" | |
| done |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # aws-dump-state.sh | |
| # Pulls Identity Center permission sets, IAM customer-managed policies, | |
| # and customer-managed IAM roles from the target account into config/. | |
| # | |
| # Idempotent: re-running overwrites files with current AWS state. Use it | |
| # both for initial population and for "refresh from live" before reviewing | |
| # drift. | |
| # | |
| # Run as: ADMIN_PROFILE=aws_iam_user ./scripts/aws-dump-state.sh | |
| # For a member account: | |
| # ADMIN_PROFILE=aws_iam_user TARGET_ACCOUNT_ID=987654321012 \ | |
| # ./scripts/aws-dump-state.sh | |
| set -euo pipefail | |
| ADMIN_PROFILE="${ADMIN_PROFILE:-admin}" | |
| REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| CONFIG_DIR="$REPO_ROOT/config" | |
| GLOBAL_PS_DIR="$CONFIG_DIR/global/iam-identity-center/permission-sets" | |
| GLOBAL_ROLE_DIR="$CONFIG_DIR/global/iam/roles" | |
| GLOBAL_POL_DIR="$CONFIG_DIR/global/iam/policies" | |
| AWS="aws --profile $ADMIN_PROFILE --output json" | |
| MGMT_ACCOUNT_ID=$($AWS organizations describe-organization \ | |
| --query 'Organization.MasterAccountId' --output text) | |
| if [ -n "${TARGET_ACCOUNT_ID:-}" ]; then | |
| ACCOUNT_NAME=$($AWS organizations describe-account \ | |
| --account-id "$TARGET_ACCOUNT_ID" \ | |
| --query 'Account.Name' --output text 2>/dev/null || | |
| echo "$TARGET_ACCOUNT_ID") | |
| msg="=== Assuming OrganizationAccountAccessRole" | |
| echo "$msg in $ACCOUNT_NAME ($TARGET_ACCOUNT_ID) ===" | |
| ROLE_ARN="arn:aws:iam::$TARGET_ACCOUNT_ID:role" | |
| ROLE_ARN="$ROLE_ARN/OrganizationAccountAccessRole" | |
| creds=$($AWS sts assume-role \ | |
| --role-arn "$ROLE_ARN" \ | |
| --role-session-name "fetch-aws-state" \ | |
| --query 'Credentials' --output json) | |
| export AWS_ACCESS_KEY_ID=$(echo "$creds" | jq -r '.AccessKeyId') | |
| export AWS_SECRET_ACCESS_KEY=$(echo "$creds" | jq -r '.SecretAccessKey') | |
| export AWS_SESSION_TOKEN=$(echo "$creds" | jq -r '.SessionToken') | |
| IAM_AWS="aws --output json" | |
| else | |
| ACCOUNT_NAME=$($AWS organizations describe-account \ | |
| --account-id "$MGMT_ACCOUNT_ID" \ | |
| --query 'Account.Name' --output text 2>/dev/null || | |
| echo "$MGMT_ACCOUNT_ID") | |
| IAM_AWS="$AWS" | |
| echo "=== Identity Center: permission sets ===" | |
| rm -rf "$GLOBAL_PS_DIR" "$GLOBAL_ROLE_DIR" "$GLOBAL_POL_DIR" | |
| mkdir -p "$GLOBAL_PS_DIR" "$GLOBAL_ROLE_DIR" "$GLOBAL_POL_DIR" | |
| INSTANCES=$($AWS sso-admin list-instances \ | |
| --query 'Instances[].InstanceArn' --output text) | |
| for INSTANCE_ARN in $INSTANCES; do | |
| echo " instance: $INSTANCE_ARN" | |
| PSARNS=$($AWS sso-admin list-permission-sets \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --query 'PermissionSets[]' --output text) | |
| for psarn in $PSARNS; do | |
| ps_json=$($AWS sso-admin describe-permission-set \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --permission-set-arn "$psarn" \ | |
| --query 'PermissionSet') | |
| name=$(echo "$ps_json" | jq -r '.Name') | |
| echo " $name" | |
| managed=$($AWS sso-admin \ | |
| list-managed-policies-in-permission-set \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --permission-set-arn "$psarn" \ | |
| --query 'AttachedManagedPolicies[].Arn') | |
| customer=$($AWS sso-admin \ | |
| list-customer-managed-policy-references-in-permission-set \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --permission-set-arn "$psarn" \ | |
| --query 'CustomerManagedPolicyReferences') | |
| # Inline policy may not exist; tolerate empty. | |
| inline=$($AWS sso-admin \ | |
| get-inline-policy-for-permission-set \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --permission-set-arn "$psarn" \ | |
| --query 'InlinePolicy' --output text \ | |
| 2>/dev/null || true) | |
| if [ -z "$inline" ] || [ "$inline" = "None" ]; then | |
| inline_json="null" | |
| else | |
| inline_json=$(echo "$inline" | jq .) | |
| fi | |
| # Permissions boundary may not be set. | |
| boundary=$($AWS sso-admin \ | |
| get-permissions-boundary-for-permission-set \ | |
| --instance-arn "$INSTANCE_ARN" \ | |
| --permission-set-arn "$psarn" \ | |
| --query 'PermissionsBoundary' \ | |
| 2>/dev/null || echo "null") | |
| [ -z "$boundary" ] && boundary="null" | |
| jq -n \ | |
| --argjson ps "$ps_json" \ | |
| --argjson managed "$managed" \ | |
| --argjson customer "$customer" \ | |
| --argjson inline "$inline_json" \ | |
| --argjson boundary "$boundary" \ | |
| '{ | |
| name: $ps.Name, | |
| description: ($ps.Description // null), | |
| sessionDuration: ($ps.SessionDuration // null), | |
| relayState: ($ps.RelayState // null), | |
| managedPolicies: $managed, | |
| customerManagedPolicies: $customer, | |
| permissionsBoundary: $boundary, | |
| inlinePolicy: $inline | |
| }' > "$GLOBAL_PS_DIR/${name}.json" | |
| done | |
| done | |
| echo | |
| fi | |
| IAM_POL_DIR="$CONFIG_DIR/accounts/$ACCOUNT_NAME/iam/policies" | |
| IAM_ROLE_DIR="$CONFIG_DIR/accounts/$ACCOUNT_NAME/iam/roles" | |
| rm -rf "$IAM_POL_DIR" "$IAM_ROLE_DIR" | |
| mkdir -p "$IAM_POL_DIR" "$IAM_ROLE_DIR" "$GLOBAL_ROLE_DIR" "$GLOBAL_POL_DIR" | |
| write_or_symlink() { | |
| local content="$1" dest="$2" canonical="$3" rel_symlink="$4" | |
| if [ -n "${TARGET_ACCOUNT_ID:-}" ] && | |
| echo "$content" | grep -qF "$MGMT_ACCOUNT_ID"; then | |
| if [ -f "$canonical" ]; then | |
| content_json=$(printf '%s' "$content" | jq -S .) | |
| if [ "$content_json" = "$(jq -S . "$canonical")" ]; then | |
| ln -sf "$rel_symlink" "$dest" | |
| echo " (symlink → global)" | |
| else | |
| printf '%s' "$content" > "$dest" | |
| echo " (DRIFT: differs from global)" | |
| fi | |
| else | |
| printf '%s' "$content" > "$canonical" | |
| ln -sf "$rel_symlink" "$dest" | |
| echo " (new global + symlink)" | |
| fi | |
| else | |
| printf '%s' "$content" > "$dest" | |
| fi | |
| } | |
| echo "=== IAM: customer-managed policies ===" | |
| POLICY_ARNS=$($IAM_AWS iam list-policies \ | |
| --scope Local --query 'Policies[].Arn' --output text) | |
| for arn in $POLICY_ARNS; do | |
| name=$($IAM_AWS iam get-policy \ | |
| --policy-arn "$arn" \ | |
| --query 'Policy.PolicyName' --output text) | |
| default_ver=$($IAM_AWS iam get-policy \ | |
| --policy-arn "$arn" \ | |
| --query 'Policy.DefaultVersionId' --output text) | |
| printf " %s (%s)" "$name" "$default_ver" | |
| content=$($IAM_AWS iam get-policy-version \ | |
| --policy-arn "$arn" \ | |
| --version-id "$default_ver" \ | |
| --query 'PolicyVersion.Document') | |
| write_or_symlink "$content" \ | |
| "$IAM_POL_DIR/${name}.json" \ | |
| "$GLOBAL_POL_DIR/${name}.json" \ | |
| "../../global/iam/policies/${name}.json" | |
| echo | |
| done | |
| echo | |
| echo "=== IAM: customer roles ===" | |
| ROLES_QUERY='Roles[?!starts_with(Path,`/aws-reserved/`)' | |
| ROLES_QUERY+=' && !starts_with(Path,`/aws-service-role/`)' | |
| ROLES_QUERY+=' && !starts_with(Path,`/service-role/`)].RoleName' | |
| ROLE_NAMES=$($IAM_AWS iam list-roles \ | |
| --query "$ROLES_QUERY" --output text) | |
| for name in $ROLE_NAMES; do | |
| printf " %s" "$name" | |
| role_json=$($IAM_AWS iam get-role \ | |
| --role-name "$name" --query 'Role') | |
| inline_names=$($IAM_AWS iam list-role-policies \ | |
| --role-name "$name" --query 'PolicyNames') | |
| attached=$($IAM_AWS iam list-attached-role-policies \ | |
| --role-name "$name" \ | |
| --query 'AttachedPolicies[].PolicyArn') | |
| inline_map="{}" | |
| for ipname in $(echo "$inline_names" | jq -r '.[]'); do | |
| doc=$($IAM_AWS iam get-role-policy \ | |
| --role-name "$name" \ | |
| --policy-name "$ipname" \ | |
| --query 'PolicyDocument') | |
| inline_map=$(echo "$inline_map" | jq --arg k "$ipname" --argjson v "$doc" '. + {($k): $v}') | |
| done | |
| content=$(jq -n \ | |
| --argjson role "$role_json" \ | |
| --argjson attached "$attached" \ | |
| --argjson inline "$inline_map" \ | |
| '{ | |
| name: $role.RoleName, | |
| description: ($role.Description // null), | |
| path: $role.Path, | |
| maxSessionDuration: $role.MaxSessionDuration, | |
| assumeRolePolicy: $role.AssumeRolePolicyDocument, | |
| permissionsBoundary: ($role.PermissionsBoundary // null), | |
| attachedManagedPolicies: $attached, | |
| inlinePolicies: $inline | |
| }') | |
| write_or_symlink "$content" \ | |
| "$IAM_ROLE_DIR/${name}.json" \ | |
| "$GLOBAL_ROLE_DIR/${name}.json" \ | |
| "../../global/iam/roles/${name}.json" | |
| echo | |
| done | |
| echo | |
| echo "=== IAM: users ===" | |
| users=$($IAM_AWS iam list-users \ | |
| --query 'Users[].UserName' --output text) | |
| if [ -z "$users" ]; then | |
| echo " (none)" | |
| else | |
| echo " TODO: not yet fetched — extend this script when users exist" | |
| echo "$users" | |
| fi | |
| echo | |
| echo "Done. Files under: $CONFIG_DIR" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment