Commit 7bdc5cd488
Unregistered key
Layout: unified · split
content/blog/2025-11-23-it-audit-ai.org +1 −1
| @@ -41,7 +41,7 @@ I use Gemini for a handful of specific use-cases, notably: | ||
| 41 | 41 | |
| 42 | 42 | ** Microsoft Copilot |
| 43 | 43 | |
| 44 | Likely the most common AI in use at company's today, due to its integration with | |
| 44 | Likely the most common AI in use at companies today, due to its integration with | |
| 45 | 45 | every Microsoft product, Copilot offers the best AI solution for productivity. |
| 46 | 46 | However, it's usage is likely also the most volatile due to the large gap in |
| 47 | 47 | skill sets between new-hires and senior employees. |
content/blog/2026-02-21-auditing-aws-iam.org added +344
| @@ -0,0 +1,344 @@ | ||
| 1 | #+date: [2026-02-21 Sat 18:58:39] | |
| 2 | #+title: Auditing AWS IAM Identity Center Assignments | |
| 3 | #+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. | |
| 4 | #+slug: auditing-aws-iam | |
| 5 | ||
| 6 | If you've ever asked an IT team for a list of who has access to an AWS account, | |
| 7 | you've probably received a spreadsheet that was already out of date. Or worse, | |
| 8 | an out-of-context screenshot that doesn't explain anything. IAM Identity Center | |
| 9 | doesn't make this easy to extract through the console, and most teams don't have | |
| 10 | a repeatable process for pulling it. This post walks through a script that does | |
| 11 | it for you and explains what to look for once you have the output. | |
| 12 | ||
| 13 | The script is available at [[https://github.com/audit-labs/audit-tools/blob/main/applications/aws/aws_iam_users.sh][audit-labs/audit-tools]]. | |
| 14 | ||
| 15 | * Background: How AWS Provisions Access | |
| 16 | ||
| 17 | Before running anything, it helps to understand the different ways AWS can be | |
| 18 | architected to grant access. You may encounter any of these in practice, | |
| 19 | sometimes within the same organization, and knowing how they differ tells you | |
| 20 | where to look during an audit and which questions to ask. | |
| 21 | ||
| 22 | ** Traditional IAM Users | |
| 23 | ||
| 24 | The original AWS access model. An IAM user is an identity created directly | |
| 25 | inside a single AWS account. It has a username, optional console password, and | |
| 26 | optional access keys for programmatic access. Permissions are granted by | |
| 27 | attaching IAM policies directly to the user, or by adding the user to an IAM | |
| 28 | group that has policies attached to it. | |
| 29 | ||
| 30 | You'll still see IAM users in older environments, smaller organizations, and in | |
| 31 | cases where a service or application needs long-lived programmatic credentials. | |
| 32 | The core problem with IAM users at scale is that they're account-scoped, meaning | |
| 33 | a user in ~Account A~ has no relationship to a user in ~Account B~, even if they're | |
| 34 | the same person. Organizations with dozens or hundreds of accounts end up with a | |
| 35 | fragmented identity landscape that's difficult to review and even harder to | |
| 36 | deprovision consistently. | |
| 37 | ||
| 38 | From an audit perspective, IAM users are high-risk when they have active access | |
| 39 | keys (long-lived credentials that don't expire unless explicitly rotated or | |
| 40 | revoked) or when policies are attached directly to the user rather than | |
| 41 | inherited through a group. | |
| 42 | ||
| 43 | ** IAM Groups | |
| 44 | ||
| 45 | IAM groups are a way to organize IAM users within a single account and attach | |
| 46 | policies to multiple users at once. A user inherits all permissions from every | |
| 47 | group they belong to. Groups themselves cannot assume roles or make API calls. | |
| 48 | They exist only as a convenience for managing user permissions in bulk. | |
| 49 | ||
| 50 | Groups help with consistency, but they don't solve the cross-account problem. An | |
| 51 | IAM group in ~Account A~ still has no bearing on access in ~Account B~. You'll see | |
| 52 | groups used alongside IAM users in the same environments where IAM users are | |
| 53 | prevalent. | |
| 54 | ||
| 55 | ** IAM Roles | |
| 56 | ||
| 57 | Roles are the most flexible identity construct in AWS. Unlike users, roles | |
| 58 | aren't tied to a specific person. They're assumed temporarily by whoever (or | |
| 59 | whatever) is authorized to use them. When an entity assumes a role, AWS issues | |
| 60 | short-lived credentials that expire automatically, usually after an hour. | |
| 61 | ||
| 62 | Roles are used in several contexts: | |
| 63 | ||
| 64 | - *Cross-account access*: ~Account A~ can grant a role in ~Account B~ the ability to | |
| 65 | be assumed by principals in ~Account A~. This is how many organizations handle | |
| 66 | access across accounts without using IAM Identity Center. | |
| 67 | - *Service roles*: AWS services like Lambda, EC2, and ECS use roles to make API | |
| 68 | calls on your behalf. An EC2 instance with an attached instance profile is | |
| 69 | assuming a role every time it calls an AWS API. | |
| 70 | - *Federated access*: External identity providers (Active Directory, Okta, etc.) | |
| 71 | can be configured to exchange a user's external credentials for temporary AWS | |
| 72 | role credentials via SAML or OIDC. This is an older pattern that predates IAM | |
| 73 | Identity Center. | |
| 74 | - *Break-glass access*: A role with broad permissions that is locked down by | |
| 75 | default and only assumed in emergencies. A common method for securing these | |
| 76 | accounts is vaulting the credentials in a PAM platform like CyberArk. | |
| 77 | ||
| 78 | From an audit perspective, roles require you to look at the trust policy (who is | |
| 79 | allowed to assume the role) as well as the permission policy (what the role can | |
| 80 | do once assumed). Both matter. A role with ~AdministratorAccess~ is only as risky | |
| 81 | as its trust policy is permissive. | |
| 82 | ||
| 83 | ** IAM Policies | |
| 84 | ||
| 85 | Policies are the documents that define permissions. They're not an access | |
| 86 | pattern on their own, but understanding the three types helps when reviewing | |
| 87 | output from any access audit: | |
| 88 | ||
| 89 | - *AWS managed policies* are created and maintained by AWS. Examples include | |
| 90 | ~AdministratorAccess~, ~ReadOnlyAccess~, and job-function policies like ~Billing~. | |
| 91 | They're convenient but broad: ~AdministratorAccess~ is a single policy that | |
| 92 | grants full access to every AWS service. | |
| 93 | - *Customer managed policies* are created by the organization. They offer more | |
| 94 | precise control and can be reused across users, groups, and roles. Better | |
| 95 | practice than relying entirely on AWS managed policies for anything sensitive. | |
| 96 | - *Inline policies* are embedded directly into a specific user, group, or role | |
| 97 | rather than existing as a standalone resource. They can't be reused and are | |
| 98 | easier to miss during reviews. When you see an inline policy in audit output, | |
| 99 | treat it as something that warrants a closer look — they're often added ad hoc | |
| 100 | to solve a specific problem and not always well documented. | |
| 101 | ||
| 102 | ** IAM Identity Center | |
| 103 | ||
| 104 | IAM Identity Center (formerly AWS SSO) is the current recommended approach for | |
| 105 | human access in multi-account AWS Organizations. It sits above individual | |
| 106 | accounts and provides centralized access management across all of them from a | |
| 107 | single place. | |
| 108 | ||
| 109 | It connects to an identity source, such as AWS's own directory, Active | |
| 110 | Directory, or an external IdP like Okta or Entra ID, and uses that as the source | |
| 111 | of truth for users and groups. Access is configured within Identity Center | |
| 112 | itself, not inside individual accounts. | |
| 113 | ||
| 114 | The key construct is the permission set: a named bundle of IAM policies | |
| 115 | (managed, customer managed, and/or inline) that defines what a principal can do. | |
| 116 | When you assign a permission set to a user or group for a specific account, AWS | |
| 117 | creates a temporary IAM role in that account that the user assumes when they | |
| 118 | access it. The credentials are short-lived and scoped to the session. | |
| 119 | ||
| 120 | The standard architecture: users belong to groups in the identity source, groups | |
| 121 | are assigned permission sets for specific accounts in Identity Center, and users | |
| 122 | inherit access through their group memberships. Provisioning and deprovisioning | |
| 123 | happen at the group membership level, not by touching individual account | |
| 124 | assignments. | |
| 125 | ||
| 126 | Direct user assignments (where a user is assigned a permission set without going | |
| 127 | through a group) are possible but generally a control gap, because they bypass | |
| 128 | the group-based review and provisioning process. | |
| 129 | ||
| 130 | If the organization uses Identity Center, the access population you need to | |
| 131 | review lives there, not inside individual accounts. Looking at IAM users and | |
| 132 | roles within an account will give you an incomplete picture. You'll see the | |
| 133 | temporary roles that Identity Center creates, but not who is assigned to them or | |
| 134 | why. | |
| 135 | ||
| 136 | * What the Script Does | |
| 137 | ||
| 138 | The script works against a single named AWS account and produces a JSON report | |
| 139 | of every user and group assignment, along with the permission set and policies | |
| 140 | attached to each assignment. | |
| 141 | ||
| 142 | It runs in four steps: | |
| 143 | ||
| 144 | 1. Finds the IAM Identity Center instance and looks up the target account ID by | |
| 145 | name | |
| 146 | 2. Lists all permission sets provisioned to that account | |
| 147 | 3. For each permission set, retrieves all user and group assignments, resolves | |
| 148 | their names from the Identity Store, and fetches the managed and inline | |
| 149 | policies attached to the permission set | |
| 150 | 4. Writes everything to a JSON file named ~report_<account_name>.json~ | |
| 151 | ||
| 152 | The script caches permission set details and principal names to avoid redundant | |
| 153 | API calls when the same permission set or user appears in multiple assignments. | |
| 154 | ||
| 155 | * Prerequisites | |
| 156 | ||
| 157 | You'll need: | |
| 158 | ||
| 159 | - AWS CLI installed and configured with credentials that have read access to | |
| 160 | ~sso-admin~, ~identitystore~, and ~organizations~ | |
| 161 | - ~jq~ installed (used throughout the script for JSON parsing) | |
| 162 | - The name of the AWS account you want to audit (must match exactly as it | |
| 163 | appears in AWS Organizations) | |
| 164 | ||
| 165 | Set the account name at the top of the script before running: | |
| 166 | ||
| 167 | #+begin_src bash | |
| 168 | ACCOUNT_NAME="your-account-name" | |
| 169 | #+end_src | |
| 170 | ||
| 171 | Then make the script executable and run it: | |
| 172 | ||
| 173 | #+begin_src bash | |
| 174 | chmod +x aws_iam_users.sh | |
| 175 | ./aws_iam_users.sh | |
| 176 | #+end_src | |
| 177 | ||
| 178 | #+caption: Script Output | |
| 179 | #+attr_html: :alt Terminal output of aws_iam_users.sh showing discovered assignments for the cmc account. | |
| 180 | [[https://img.cleberg.net/blog/20260221-auditing-aws-iam/script.webp]] | |
| 181 | ||
| 182 | ||
| 183 | * Key Snippet: Resolving Principal Names | |
| 184 | ||
| 185 | The part of the script most worth understanding is how it resolves user and | |
| 186 | group names. AWS stores principals in Identity Center by ID, not by name. To get | |
| 187 | a readable name, the script calls the Identity Store API separately for each | |
| 188 | principal: | |
| 189 | ||
| 190 | #+begin_src bash | |
| 191 | if [ "$PRINCIPAL_TYPE" == "USER" ]; then | |
| 192 | PRINCIPAL_NAME=$(aws identitystore describe-user \ | |
| 193 | --identity-store-id "$IDENTITY_STORE_ID" \ | |
| 194 | --user-id "$PRINCIPAL_ID" \ | |
| 195 | --query "UserName" --output text 2>/dev/null) | |
| 196 | elif [ "$PRINCIPAL_TYPE" == "GROUP" ]; then | |
| 197 | PRINCIPAL_NAME=$(aws identitystore describe-group \ | |
| 198 | --identity-store-id "$IDENTITY_STORE_ID" \ | |
| 199 | --group-id "$PRINCIPAL_ID" \ | |
| 200 | --query "DisplayName" --output text 2>/dev/null) | |
| 201 | fi | |
| 202 | #+end_src | |
| 203 | ||
| 204 | This is also where the cache matters. If the same user appears across multiple | |
| 205 | permission set assignments (which is common), the script reuses the stored name | |
| 206 | rather than making a redundant API call. | |
| 207 | ||
| 208 | * Reading the Output | |
| 209 | ||
| 210 | The script produces a JSON array where each element represents a single | |
| 211 | principal-to-permission-set assignment. Here's an example: | |
| 212 | ||
| 213 | #+begin_src json | |
| 214 | [ | |
| 215 | { | |
| 216 | "principal": { | |
| 217 | "type": "GROUP", | |
| 218 | "name": "testgroup1" | |
| 219 | }, | |
| 220 | "permission_set": { | |
| 221 | "name": "AdministratorAccess", | |
| 222 | "policies": { | |
| 223 | "managed_policies": [ | |
| 224 | "arn:aws:iam::aws:policy/AdministratorAccess" | |
| 225 | ], | |
| 226 | "inline_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Statement2\",\"Effect\":\"Deny\",\"Action\":[\"a4b:*\"],\"Resource\":[\"*\"]}]}" | |
| 227 | } | |
| 228 | } | |
| 229 | }, | |
| 230 | { | |
| 231 | "principal": { | |
| 232 | "type": "USER", | |
| 233 | "name": "iamtestuser1" | |
| 234 | }, | |
| 235 | "permission_set": { | |
| 236 | "name": "AdministratorAccess", | |
| 237 | "policies": { | |
| 238 | "managed_policies": [ | |
| 239 | "arn:aws:iam::aws:policy/AdministratorAccess" | |
| 240 | ], | |
| 241 | "inline_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Statement2\",\"Effect\":\"Deny\",\"Action\":[\"a4b:*\"],\"Resource\":[\"*\"]}]}" | |
| 242 | } | |
| 243 | } | |
| 244 | }, | |
| 245 | { | |
| 246 | "principal": { | |
| 247 | "type": "USER", | |
| 248 | "name": "iamtestuser1" | |
| 249 | }, | |
| 250 | "permission_set": { | |
| 251 | "name": "Billing", | |
| 252 | "policies": { | |
| 253 | "managed_policies": [ | |
| 254 | "arn:aws:iam::aws:policy/job-function/Billing" | |
| 255 | ], | |
| 256 | "inline_policy": "" | |
| 257 | } | |
| 258 | } | |
| 259 | } | |
| 260 | ] | |
| 261 | #+end_src | |
| 262 | ||
| 263 | #+caption: JSON Report | |
| 264 | #+attr_html: :alt The contents of report_cmc.json showing principal and permission set assignments. | |
| 265 | [[https://img.cleberg.net/blog/20260221-auditing-aws-iam/json.webp]] | |
| 266 | ||
| 267 | A few things to look for when reviewing this output: | |
| 268 | ||
| 269 | *Direct user assignments*: ~iamtestuser1~ is assigned ~AdministratorAccess~ directly | |
| 270 | as a USER, not through a group. Access should generally be provisioned through | |
| 271 | groups so that provisioning and deprovisioning are tied to group membership, not | |
| 272 | managed case-by-case. A direct user assignment to a high-privilege permission | |
| 273 | set warrants a conversation with IT about why it exists. | |
| 274 | ||
| 275 | *AdministratorAccess*: This is AWS's broadest managed policy, which allows full | |
| 276 | access to everything in the account. Note who has it and whether that's | |
| 277 | appropriate. In many environments, even senior engineers shouldn't have standing | |
| 278 | ~AdministratorAccess~; it should be reserved for break-glass accounts or | |
| 279 | infrastructure teams with a documented need. | |
| 280 | ||
| 281 | *Inline policies*: The ~inline_policy~ field in this example contains a Deny | |
| 282 | statement for ~a4b:*~ (Alexa for Business). Inline policies attached to permission | |
| 283 | sets are worth reviewing, as they can add permissions or restrict them, and | |
| 284 | they're easy to miss if you're only looking at the managed policy name. | |
| 285 | ||
| 286 | *Multiple assignments for the same user*: ~iamtestuser1~ appears twice: once with | |
| 287 | ~AdministratorAccess~ and once with ~Billing~. This isn't automatically a problem, | |
| 288 | but a user with both full administrative access and explicit billing access is | |
| 289 | worth confirming with IT. | |
| 290 | ||
| 291 | * Common Exceptions and False Positives | |
| 292 | ||
| 293 | *Break-glass accounts*: Most organizations maintain at least one emergency account | |
| 294 | with direct ~AdministratorAccess~ that bypasses normal access controls. A direct | |
| 295 | USER assignment to ~AdministratorAccess~ may be intentional for this reason. Ask | |
| 296 | IT whether a break-glass account exists, confirm the username matches, and | |
| 297 | document it as an accepted exception if appropriate. | |
| 298 | ||
| 299 | *Small organizations or early-stage environments*: Some teams haven't implemented | |
| 300 | group-based access management yet and assign permissions directly to users | |
| 301 | across the board. This isn't a false positive, it's a control gap, but the | |
| 302 | finding should reflect the scale and maturity of the environment. A two-person | |
| 303 | startup with direct assignments is a different risk than an enterprise doing the | |
| 304 | same thing. | |
| 305 | ||
| 306 | *Service or automation accounts*: Occasionally a USER principal in Identity Center | |
| 307 | is a service account rather than a human. These may legitimately have specific | |
| 308 | permission sets assigned directly. Confirm the account's purpose before writing | |
| 309 | it up. | |
| 310 | ||
| 311 | * How to Write Up the Finding | |
| 312 | ||
| 313 | If you identify a direct high-privilege user assignment that isn't a documented | |
| 314 | exception, here's how to frame it: | |
| 315 | ||
| 316 | *Condition:* User ~iamtestuser1~ is directly assigned the ~AdministratorAccess~ | |
| 317 | permission set in account ~cmc~, rather than receiving access through a group | |
| 318 | assignment. | |
| 319 | ||
| 320 | *Criteria:* Access to AWS accounts via IAM Identity Center should be provisioned | |
| 321 | through groups to ensure consistent access management. Direct user assignments | |
| 322 | bypass group-based controls and are not subject to standard group membership | |
| 323 | reviews. | |
| 324 | ||
| 325 | *Risk:* Direct user assignments increase the likelihood of orphaned access | |
| 326 | following role changes or departures. They are also harder to identify during | |
| 327 | periodic access reviews, as reviewers may focus on group membership rather than | |
| 328 | individual assignments. | |
| 329 | ||
| 330 | *Evidence:* Attach the relevant portion of ~report_cmc.json~ filtered to show the | |
| 331 | specific principal, permission set, and policies. The JSON output is | |
| 332 | self-documenting, as it includes the account name in the filename and contains | |
| 333 | the full policy details alongside the assignment. | |
| 334 | ||
| 335 | To filter the output to just the direct user assignments for the evidence | |
| 336 | attachment: | |
| 337 | ||
| 338 | #+begin_src bash | |
| 339 | jq '[.[] | select(.principal.type == "USER")]' report_cmc.json | |
| 340 | #+end_src | |
| 341 | ||
| 342 | #+caption: Filtered User Assignments | |
| 343 | #+attr_html: :alt Terminal output of the jq filter showing only direct user assignments from report_cmc.json. | |
| 344 | [[https://img.cleberg.net/blog/20260221-auditing-aws-iam/jq.webp]] | |