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 | ** Microsoft Copilot | 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 | every Microsoft product, Copilot offers the best AI solution for productivity. | 45 | every Microsoft product, Copilot offers the best AI solution for productivity. |
| 46 | However, it's usage is likely also the most volatile due to the large gap in | 46 | However, it's usage is likely also the most volatile due to the large gap in |
| 47 | skill sets between new-hires and senior employees. | 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]] | ||