Skip to content

Instantly share code, notes, and snippets.

@vishalapte
Last active June 4, 2026 01:14
Show Gist options
  • Select an option

  • Save vishalapte/a7ab8918bbf6e6c6f2e29ca9f91f56f5 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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
#!/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