1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
|
#+date: [2026-02-21 Sat 18:58:39]
#+title: Auditing AWS IAM Users
#+description: How to audit IAM user assignments in AWS.
#+slug: auditing-aws-iam
#+filetags: :audit:
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. Or worse, they don't
know either and ignore you, hoping you forget about the request (ask me how I
know!).
To make it worse, 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.
To solve this problem, this post walks through a script that does it for you and
explains what to look for once you have the output. This is something I've used
in practice and want to share, as cloud auditing knowledge seems scarce.
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 one (or all) of these in
practice, sometimes within the same organization. Knowing how they differ tells
you where to look during an audit and which questions to ask.
** IAM Users
This is the original AWS access model. An IAM user is an identity created
directly inside a single AWS account (e.g., ~iamuser1~ created inside AWS user
~cmc~). This type of account has a username, an 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.
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, these are key risks to understand during your
walkthrough of the environment. Additionally, you should note that 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:
1. *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.
2. *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.
3. *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 (end-user) 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 externali identity provider (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 by running commands 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.
At the end of this process, we will have the evidence we need to test controls
such as administrative access or segregation of duties.
* Prerequisites
You'll need:
- AWS CLI (or AWS CloudShell) 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]]
* 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:*~. 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 is 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. This may or may not give rise to a deficiency.
- *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:
- *Deficiency:* User ~iamtestuser1~ is directly assigned the ~AdministratorAccess~
permission set in account ~cmc~, rather than receiving access through a group
assignment.
- *Root Cause:* Due to {{ root cuase }}, the user ~iamtestuser1~ was inappropriately
provisioned the ~AdministratorAccess~ permission directly rather than through a
group assignment.
- *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.
If requested during review, you can filter the output to just the direct user
assignments:
#+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]]
|