Alright, let’s talk about the thing that actually does the work in IAM: the policy document. This is where the rubber meets the road. Forget the users and groups for a second; they’re just containers for these bad boys. An IAM policy is a JSON document that formally states one or more permissions. It’s the universe’s most pedantic bouncer’s list, and it will absolutely, positively follow its instructions to the letter. And yes, it’s JSON, because this is the cloud, and we apparently decided XML wasn’t painful enough.

The core of any policy is a statement, which is a single permission rule. You’ll have a list of these statements in the Statement array. Don’t try to be clever and have a single statement do everything; break them up. It’s cleaner, easier to debug, and AWS’s own tools often generate policies with multiple single-permission statements.

The Absolute Core: Effect, Action, Resource

Every meaningful statement boils down to these three elements. They answer the fundamental questions: Allow or Deny? What can they do? On what can they do it?

  • Effect is the simplest. It’s either "Allow" or "Deny". That’s it. There is no “Maybe,” “Ask Later,” or “If It’s a Tuesday.” This is your most powerful tool. Want to make sure something absolutely cannot happen? Use an explicit "Deny". It trumps an "Allow" every single time.

  • Action is the verb. It specifies the exact API operation(s) you’re governing. These are always prefixed by the service name (s3, ec2, dynamodb) followed by the operation itself (GetObject, RunInstances, Query). You can specify a single action or a whole list of them. Pro tip: AWS has a nasty habit of adding new actions for new features. If you use a wildcard (s3:*), you’re implicitly allowing actions that didn’t even exist when you wrote the policy. This is often necessary, but be aware of the potential for privilege creep.

  • Resource is the noun—the specific AWS entity the action applies to. This is most often an Amazon Resource Name (ARN). The critical thing to understand here is the level of granularity. For some services, like S3, you can specify a bucket, or even a specific object prefix within a bucket (arn:aws:s3:::my-production-bucket/private-data/*). For others, like IAM itself, many actions can only be applied to a wildcard resource (*) because the “resource” is the IAM service itself. This is a common source of misconfigurations. Locking this down to the most specific ARN possible is Security 101.

Here’s a policy that puts it all together. It allows a user to read and list objects only in a specific folder of a specific S3 bucket, and nothing else.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-app-bucket",
        "arn:aws:s3:::my-app-bucket/public-assets/*"
      ]
    }
  ]
}

Notice the Version field at the top? Don’t change it. Just always use "2012-10-17". It’s not a software version; it’s the policy language version. The older version is deprecated and lacks features we’re about to discuss.

The Superpower: Condition Blocks

This is where IAM moves from being a simple toggle switch to a full-blown control panel. The Condition block (or Condition key, to be precise) lets you place additional, fine-grained constraints on when a statement is in effect. This is how you implement rules like “This user can only access this bucket from the IP range of our office” or “This role can only be assumed between 9 AM and 5 PM.”

Conditions are built around condition operators (like StringEquals, IpAddress, DateGreaterThan) and service-specific condition keys. The logic is powerful but can get convoluted. A word of warning: the syntax for checking multiple values for a single key is, frankly, weird. You don’t use a list; you use a condition operator suffix like ForAnyValue:StringEquals.

Here’s an example enforcing that MFA (Multi-Factor Authentication) is present and that the request is coming from our corporate network.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        },
        "IpAddress": {
          "aws:SourceIp": ["192.0.2.0/24", "203.0.113.0/24"]
        }
      }
    }
  ]
}

The Devil’s in the Details: Common Pitfalls

  1. Not Explicitly Denying: Relying solely on an implicit deny (the default state) is fine until it’s not. If someone attaches a new, overly permissive policy elsewhere, that implicit deny is ignored. For true nuclear options (like “no one should ever be able to delete this backup bucket”), a explicit "Deny" statement is your best friend.
  2. The Wildcard Trap: Using "Action": "s3:*" and "Resource": "*" is the AWS equivalent of running your application as root. It works, but you will regret it. Start restrictive and loosen up as needed. Use the IAM Access Advisor to see which permissions are actually being used and prune accordingly.
  3. Conflicting Conditions: When you have multiple policies, their conditions are evaluated independently. An allow with a condition and a deny without one will result in a deny. The logic is additive, and a single deny from anywhere wins. Trace through the logic carefully.
  4. Ignoring Global Condition Keys: Keys prefixed with aws:, like aws:SourceIp or aws:PrincipalArn, are available in all services. Use them! They are your primary tool for applying global security rules across your entire account.