aboutsummaryrefslogtreecommitdiff
path: root/content/blog/2026-02-21-auditing-aws-iam.org
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-02-21 20:12:45 -0600
committerGitHub <[email protected]>2026-02-21 20:12:45 -0600
commit7bdc5cd488bfe0de771155264c119a1d76e31bfc (patch)
tree8c5dbd389a51a6a92136faf1bfc6ec9aef317037 /content/blog/2026-02-21-auditing-aws-iam.org
parentc2005131193d0621b74f9ad9996489f66eda8c68 (diff)
downloadcleberg.net-7bdc5cd488bfe0de771155264c119a1d76e31bfc.tar.gz
cleberg.net-7bdc5cd488bfe0de771155264c119a1d76e31bfc.tar.bz2
cleberg.net-7bdc5cd488bfe0de771155264c119a1d76e31bfc.zip
publish new post: auditing-aws-iam (#7)
* fix typo * publish new post: auditing-aws-iam
Diffstat (limited to 'content/blog/2026-02-21-auditing-aws-iam.org')
-rw-r--r--content/blog/2026-02-21-auditing-aws-iam.org344
1 files changed, 344 insertions, 0 deletions
diff --git a/content/blog/2026-02-21-auditing-aws-iam.org b/content/blog/2026-02-21-auditing-aws-iam.org
new file mode 100644
index 0000000..7241309
--- /dev/null
+++ b/content/blog/2026-02-21-auditing-aws-iam.org
@@ -0,0 +1,344 @@
+#+date: [2026-02-21 Sat 18:58:39]
+#+title: Auditing AWS IAM Identity Center Assignments
+#+description: How to audit IAM Identity Center access assignments in AWS using a shell script, including what to look for, common exceptions, and how to write up findings.
+#+slug: auditing-aws-iam
+
+If you've ever asked an IT team for a list of who has access to an AWS account,
+you've probably received a spreadsheet that was already out of date. Or worse,
+an out-of-context screenshot that doesn't explain anything. IAM Identity Center
+doesn't make this easy to extract through the console, and most teams don't have
+a repeatable process for pulling it. This post walks through a script that does
+it for you and explains what to look for once you have the output.
+
+The script is available at [[https://github.com/audit-labs/audit-tools/blob/main/applications/aws/aws_iam_users.sh][audit-labs/audit-tools]].
+
+* Background: How AWS Provisions Access
+
+Before running anything, it helps to understand the different ways AWS can be
+architected to grant access. You may encounter any of these in practice,
+sometimes within the same organization, and knowing how they differ tells you
+where to look during an audit and which questions to ask.
+
+** Traditional IAM Users
+
+The original AWS access model. An IAM user is an identity created directly
+inside a single AWS account. It has a username, optional console password, and
+optional access keys for programmatic access. Permissions are granted by
+attaching IAM policies directly to the user, or by adding the user to an IAM
+group that has policies attached to it.
+
+You'll still see IAM users in older environments, smaller organizations, and in
+cases where a service or application needs long-lived programmatic credentials.
+The core problem with IAM users at scale is that they're account-scoped, meaning
+a user in ~Account A~ has no relationship to a user in ~Account B~, even if they're
+the same person. Organizations with dozens or hundreds of accounts end up with a
+fragmented identity landscape that's difficult to review and even harder to
+deprovision consistently.
+
+From an audit perspective, IAM users are high-risk when they have active access
+keys (long-lived credentials that don't expire unless explicitly rotated or
+revoked) or when policies are attached directly to the user rather than
+inherited through a group.
+
+** IAM Groups
+
+IAM groups are a way to organize IAM users within a single account and attach
+policies to multiple users at once. A user inherits all permissions from every
+group they belong to. Groups themselves cannot assume roles or make API calls.
+They exist only as a convenience for managing user permissions in bulk.
+
+Groups help with consistency, but they don't solve the cross-account problem. An
+IAM group in ~Account A~ still has no bearing on access in ~Account B~. You'll see
+groups used alongside IAM users in the same environments where IAM users are
+prevalent.
+
+** IAM Roles
+
+Roles are the most flexible identity construct in AWS. Unlike users, roles
+aren't tied to a specific person. They're assumed temporarily by whoever (or
+whatever) is authorized to use them. When an entity assumes a role, AWS issues
+short-lived credentials that expire automatically, usually after an hour.
+
+Roles are used in several contexts:
+
+- *Cross-account access*: ~Account A~ can grant a role in ~Account B~ the ability to
+ be assumed by principals in ~Account A~. This is how many organizations handle
+ access across accounts without using IAM Identity Center.
+- *Service roles*: AWS services like Lambda, EC2, and ECS use roles to make API
+ calls on your behalf. An EC2 instance with an attached instance profile is
+ assuming a role every time it calls an AWS API.
+- *Federated access*: External identity providers (Active Directory, Okta, etc.)
+ can be configured to exchange a user's external credentials for temporary AWS
+ role credentials via SAML or OIDC. This is an older pattern that predates IAM
+ Identity Center.
+- *Break-glass access*: A role with broad permissions that is locked down by
+ default and only assumed in emergencies. A common method for securing these
+ accounts is vaulting the credentials in a PAM platform like CyberArk.
+
+From an audit perspective, roles require you to look at the trust policy (who is
+allowed to assume the role) as well as the permission policy (what the role can
+do once assumed). Both matter. A role with ~AdministratorAccess~ is only as risky
+as its trust policy is permissive.
+
+** IAM Policies
+
+Policies are the documents that define permissions. They're not an access
+pattern on their own, but understanding the three types helps when reviewing
+output from any access audit:
+
+- *AWS managed policies* are created and maintained by AWS. Examples include
+ ~AdministratorAccess~, ~ReadOnlyAccess~, and job-function policies like ~Billing~.
+ They're convenient but broad: ~AdministratorAccess~ is a single policy that
+ grants full access to every AWS service.
+- *Customer managed policies* are created by the organization. They offer more
+ precise control and can be reused across users, groups, and roles. Better
+ practice than relying entirely on AWS managed policies for anything sensitive.
+- *Inline policies* are embedded directly into a specific user, group, or role
+ rather than existing as a standalone resource. They can't be reused and are
+ easier to miss during reviews. When you see an inline policy in audit output,
+ treat it as something that warrants a closer look — they're often added ad hoc
+ to solve a specific problem and not always well documented.
+
+** IAM Identity Center
+
+IAM Identity Center (formerly AWS SSO) is the current recommended approach for
+human access in multi-account AWS Organizations. It sits above individual
+accounts and provides centralized access management across all of them from a
+single place.
+
+It connects to an identity source, such as AWS's own directory, Active
+Directory, or an external IdP like Okta or Entra ID, and uses that as the source
+of truth for users and groups. Access is configured within Identity Center
+itself, not inside individual accounts.
+
+The key construct is the permission set: a named bundle of IAM policies
+(managed, customer managed, and/or inline) that defines what a principal can do.
+When you assign a permission set to a user or group for a specific account, AWS
+creates a temporary IAM role in that account that the user assumes when they
+access it. The credentials are short-lived and scoped to the session.
+
+The standard architecture: users belong to groups in the identity source, groups
+are assigned permission sets for specific accounts in Identity Center, and users
+inherit access through their group memberships. Provisioning and deprovisioning
+happen at the group membership level, not by touching individual account
+assignments.
+
+Direct user assignments (where a user is assigned a permission set without going
+through a group) are possible but generally a control gap, because they bypass
+the group-based review and provisioning process.
+
+If the organization uses Identity Center, the access population you need to
+review lives there, not inside individual accounts. Looking at IAM users and
+roles within an account will give you an incomplete picture. You'll see the
+temporary roles that Identity Center creates, but not who is assigned to them or
+why.
+
+* What the Script Does
+
+The script works against a single named AWS account and produces a JSON report
+of every user and group assignment, along with the permission set and policies
+attached to each assignment.
+
+It runs in four steps:
+
+1. Finds the IAM Identity Center instance and looks up the target account ID by
+ name
+2. Lists all permission sets provisioned to that account
+3. For each permission set, retrieves all user and group assignments, resolves
+ their names from the Identity Store, and fetches the managed and inline
+ policies attached to the permission set
+4. Writes everything to a JSON file named ~report_<account_name>.json~
+
+The script caches permission set details and principal names to avoid redundant
+API calls when the same permission set or user appears in multiple assignments.
+
+* Prerequisites
+
+You'll need:
+
+- AWS CLI installed and configured with credentials that have read access to
+ ~sso-admin~, ~identitystore~, and ~organizations~
+- ~jq~ installed (used throughout the script for JSON parsing)
+- The name of the AWS account you want to audit (must match exactly as it
+ appears in AWS Organizations)
+
+Set the account name at the top of the script before running:
+
+#+begin_src bash
+ACCOUNT_NAME="your-account-name"
+#+end_src
+
+Then make the script executable and run it:
+
+#+begin_src bash
+chmod +x aws_iam_users.sh
+./aws_iam_users.sh
+#+end_src
+
+#+caption: Script Output
+#+attr_html: :alt Terminal output of aws_iam_users.sh showing discovered assignments for the cmc account.
+[[https://img.cleberg.net/blog/20260221-auditing-aws-iam/script.webp]]
+
+
+* Key Snippet: Resolving Principal Names
+
+The part of the script most worth understanding is how it resolves user and
+group names. AWS stores principals in Identity Center by ID, not by name. To get
+a readable name, the script calls the Identity Store API separately for each
+principal:
+
+#+begin_src bash
+if [ "$PRINCIPAL_TYPE" == "USER" ]; then
+ PRINCIPAL_NAME=$(aws identitystore describe-user \
+ --identity-store-id "$IDENTITY_STORE_ID" \
+ --user-id "$PRINCIPAL_ID" \
+ --query "UserName" --output text 2>/dev/null)
+elif [ "$PRINCIPAL_TYPE" == "GROUP" ]; then
+ PRINCIPAL_NAME=$(aws identitystore describe-group \
+ --identity-store-id "$IDENTITY_STORE_ID" \
+ --group-id "$PRINCIPAL_ID" \
+ --query "DisplayName" --output text 2>/dev/null)
+fi
+#+end_src
+
+This is also where the cache matters. If the same user appears across multiple
+permission set assignments (which is common), the script reuses the stored name
+rather than making a redundant API call.
+
+* Reading the Output
+
+The script produces a JSON array where each element represents a single
+principal-to-permission-set assignment. Here's an example:
+
+#+begin_src json
+[
+ {
+ "principal": {
+ "type": "GROUP",
+ "name": "testgroup1"
+ },
+ "permission_set": {
+ "name": "AdministratorAccess",
+ "policies": {
+ "managed_policies": [
+ "arn:aws:iam::aws:policy/AdministratorAccess"
+ ],
+ "inline_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Statement2\",\"Effect\":\"Deny\",\"Action\":[\"a4b:*\"],\"Resource\":[\"*\"]}]}"
+ }
+ }
+ },
+ {
+ "principal": {
+ "type": "USER",
+ "name": "iamtestuser1"
+ },
+ "permission_set": {
+ "name": "AdministratorAccess",
+ "policies": {
+ "managed_policies": [
+ "arn:aws:iam::aws:policy/AdministratorAccess"
+ ],
+ "inline_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Statement2\",\"Effect\":\"Deny\",\"Action\":[\"a4b:*\"],\"Resource\":[\"*\"]}]}"
+ }
+ }
+ },
+ {
+ "principal": {
+ "type": "USER",
+ "name": "iamtestuser1"
+ },
+ "permission_set": {
+ "name": "Billing",
+ "policies": {
+ "managed_policies": [
+ "arn:aws:iam::aws:policy/job-function/Billing"
+ ],
+ "inline_policy": ""
+ }
+ }
+ }
+]
+#+end_src
+
+#+caption: JSON Report
+#+attr_html: :alt The contents of report_cmc.json showing principal and permission set assignments.
+[[https://img.cleberg.net/blog/20260221-auditing-aws-iam/json.webp]]
+
+A few things to look for when reviewing this output:
+
+*Direct user assignments*: ~iamtestuser1~ is assigned ~AdministratorAccess~ directly
+as a USER, not through a group. Access should generally be provisioned through
+groups so that provisioning and deprovisioning are tied to group membership, not
+managed case-by-case. A direct user assignment to a high-privilege permission
+set warrants a conversation with IT about why it exists.
+
+*AdministratorAccess*: This is AWS's broadest managed policy, which allows full
+access to everything in the account. Note who has it and whether that's
+appropriate. In many environments, even senior engineers shouldn't have standing
+~AdministratorAccess~; it should be reserved for break-glass accounts or
+infrastructure teams with a documented need.
+
+*Inline policies*: The ~inline_policy~ field in this example contains a Deny
+statement for ~a4b:*~ (Alexa for Business). Inline policies attached to permission
+sets are worth reviewing, as they can add permissions or restrict them, and
+they're easy to miss if you're only looking at the managed policy name.
+
+*Multiple assignments for the same user*: ~iamtestuser1~ appears twice: once with
+~AdministratorAccess~ and once with ~Billing~. This isn't automatically a problem,
+but a user with both full administrative access and explicit billing access is
+worth confirming with IT.
+
+* Common Exceptions and False Positives
+
+*Break-glass accounts*: Most organizations maintain at least one emergency account
+with direct ~AdministratorAccess~ that bypasses normal access controls. A direct
+USER assignment to ~AdministratorAccess~ may be intentional for this reason. Ask
+IT whether a break-glass account exists, confirm the username matches, and
+document it as an accepted exception if appropriate.
+
+*Small organizations or early-stage environments*: Some teams haven't implemented
+group-based access management yet and assign permissions directly to users
+across the board. This isn't a false positive, it's a control gap, but the
+finding should reflect the scale and maturity of the environment. A two-person
+startup with direct assignments is a different risk than an enterprise doing the
+same thing.
+
+*Service or automation accounts*: Occasionally a USER principal in Identity Center
+is a service account rather than a human. These may legitimately have specific
+permission sets assigned directly. Confirm the account's purpose before writing
+it up.
+
+* How to Write Up the Finding
+
+If you identify a direct high-privilege user assignment that isn't a documented
+exception, here's how to frame it:
+
+*Condition:* User ~iamtestuser1~ is directly assigned the ~AdministratorAccess~
+permission set in account ~cmc~, rather than receiving access through a group
+assignment.
+
+*Criteria:* Access to AWS accounts via IAM Identity Center should be provisioned
+through groups to ensure consistent access management. Direct user assignments
+bypass group-based controls and are not subject to standard group membership
+reviews.
+
+*Risk:* Direct user assignments increase the likelihood of orphaned access
+following role changes or departures. They are also harder to identify during
+periodic access reviews, as reviewers may focus on group membership rather than
+individual assignments.
+
+*Evidence:* Attach the relevant portion of ~report_cmc.json~ filtered to show the
+specific principal, permission set, and policies. The JSON output is
+self-documenting, as it includes the account name in the filename and contains
+the full policy details alongside the assignment.
+
+To filter the output to just the direct user assignments for the evidence
+attachment:
+
+#+begin_src bash
+jq '[.[] | select(.principal.type == "USER")]' report_cmc.json
+#+end_src
+
+#+caption: Filtered User Assignments
+#+attr_html: :alt Terminal output of the jq filter showing only direct user assignments from report_cmc.json.
+[[https://img.cleberg.net/blog/20260221-auditing-aws-iam/jq.webp]]