Writing Validations on Typed IAM Policies

What is a Typed IAM Policy?

A typed IAM policy models an AWS IAM policy using a set of types rather than a single string. If you missed our introduction, check out Writing Typed IAM Policies.

The biggest benefit of typed IAM policies is that the contents can be tested with validations much more easily than string IAM policies can. A string is a black box of characters, to the Ludwig compiler (lwc). But types are strictly defined. And when lwc knows all the individual elements that make up a policy, you can test those elements. For example, you can write a validation to ensure that no one writes a policy granting the action *:*, or all actions for all services. Or, you could write a validation ensuring only certain principals are granted permissions in a policy – which is exactly what we’ll demonstrate here.

In this guide, we’ll show you how to write a validation that ensures only whitelisted AWS accounts are granted permissions in an S3 bucket policy.

Writing a Validation on Typed IAM Policies

Writing a validation for a typed policy is much the same as writing a validation for anything else. For a refresher, see Guide to Writing Validations, Part 1. A validation on typed policies can be applied at runtime or at design-time, just like any other validation. It can also be used in conjunction with the NodeStream to validate across types and values.

The most important thing to know about writing a validation for a typed policy is this:

Validations should be written against the resource or entity the policy is attached to.

That means you’re not writing a validation against the Policy.Policy type itself. You’re writing it against the object it’s attached to – in this example, an S3.Bucket, but it could also be an IAM.User in the case of an identity-based policy.

For example, if a user creates a bucket and puts in a string policy directly, there is never a Policy.Policy for the validation to check – the policy only exists as a string. By writing the validation on the bucket we avoid that problem.

That’s why the best method is to write the validation against the object that has the policy. You can then drill down into the attached policy to test its elements. This way, the policy is evaluated whether it’s string or typed.

Note

In this guide, the alias Policy refers to the Fugue.AWS.IAM.Typed.Policy module.

The Validation

This validation checks the principals listed in a typed S3 bucket policy and raises an error if any aren’t whitelisted. It also raises an error if a principal contains a wildcard.

The whitelist is a top-level binding that contains a list of account ID strings. The validation accesses the lists of principals in the policy and compares them to the whitelist.

Here is the best part: Because we’ve written the validation against the object that has the policy, it ensures compliance whether that policy is string or typed!

Let’s discuss the validation logic, and then we’ll show it to you in action.

You can download this module from Github.

Import Libraries

We begin by importing the Fugue.AWS.S3 and Fugue.AWS.IAM.Typed libraries.

import Fugue.AWS.S3 as S3
import Fugue.AWS.IAM.Typed as Typed

Create Whitelist

Next, we create a binding for the list of whitelisted IDs. We’ve populated it with two dummy IDs, but since it’s effectively a list of strings, you can easily add or delete IDs to change who is whitelisted. (To be precise, the type is List<Principal.Id> – but a Principal.Id is a string, so it looks like a List<String>.)

# ADD WHITELISTED AWS ACCOUNT IDS HERE
# This list will be compared to the principals list in a typed bucket policy.
whitelistedIds: ["000000000000", "123456789012"]

Write the Validation Function

To access the list of principals in the bucket policy, we need to go several layers deep.

  1. A Policy.Policy consists of a Policy.Document.
  2. A Policy.Document contains a List<Policy.Statement>.
  3. A Policy.Statement contains an Optional<Principal.Block>.
  4. A Principal.Block contains a list of Principal.Entry wrapped by a Wildcard.Wildcard, which means some entries might use wildcards *. (Wildcard.Wildcard<List<Principal.Entry>>)
  5. And a Principal.Entry contains a List<Principal.Id>. Each Principal.Id is a string representing a principal ID, such as an AWS account number.

So, we’re effectively starting with Policy and drilling all the way down to Id:

Policy -> Document -> Statement -> Block -> Entry -> Id

To accomplish this, we’ve broken the validation into four functions that work together.

  • The first function retrieves the S3 bucket policy (Policy.Policy), unpacks it, and then calls the second function to check each statement in the policy’s list of statements (Policy.Document, or List<Policy.Statement>).
  • The second function calls the third function to look for wildcards in a single statement. It also calls the fourth function to check each entry in a single statement’s list of entries (Principal.Block, or Wildcard.Wildcard<List<Principal.Entry>>).
  • The third function checks whether a principal is a wildcard type (Wildcard.Wildcard).
  • The fourth function checks a single entry’s list of IDs (List<Principal.Id>) against the whitelist.

All but the third function return a Validation, and we use Validation.join to combine them.

The First Function

We named our first function allowOnlyWhitelistedAccountsInBucketPolicy.

Once again, validations should be written against the resource the policy is attached to. That’s why the function takes an S3.Bucket argument.

The function returns a Validation. There are four steps to the function:

  1. Check whether there’s a bucket policy.
  2. If so, retrieve it and change it from a string to a typed policy.
  3. Unpack the optional.
  4. Call the second function to check each statement in the policy’s list of statements.

Step 2 is the reason that the validation works for string and typed policies. Because a typed policy must be rendered as a string when it is attached to an object, any policy attached to an object ends up a string, whether it was written that way or not. This step changes it from a string to typed IAM – even if it wasn’t originally written as typed IAM! This means your users can’t write a string policy to get around your validation.

fun allowOnlyWhitelistedAccountsInBucketPolicy(bucket: S3.Bucket) -> Validation:
  # Look for bucket policy
  case bucket.(S3.Bucket).policy of
    | None -> Validation.success
    | Optional str -> case Typed.Policy.fromString(str) of
      # Get optional bucket policy
      | None -> Validation.error {message: "Error: Unable to parse specified policy"}
      | Optional policy ->
        # Check each statement in the list of policy statements
        Validation.join [checkSingleStatement(statement) for statement in policy.(Typed.Policy.Policy).statements]

The bulk of the function is a case statement nested in another case statement.

Because the S3.Bucket sum type has a single constructor, also called S3.Bucket, we can use dot notation to access the policy field: bucket.(S3.Bucket).policy. We can see that the field takes an Optional<String> policy.

  • If the policy field is empty, there’s no policy to validate and the validation passes with Validation.success.
  • If the policy field is not empty, take the Optional<String> policy and render it to typed IAM with the Policy.fromString function.

Then, we need to “unpack” the optional policy. That means we need to handle two possible cases: the optional policy exists, or the optional policy does not exist. So, we add another case statement:

  • If the policy itself is nonexistent, throw an error.
  • If the policy itself exists, call the second function and iterate over the policy statements.

Validation.join is a simple way to combine a number of validations. You can learn more at Guide to Writing Validations, Part 2.

The Second Function

The second function, checkSingleStatement, takes a Policy.Statement provided by the first function and returns a Validation.

# Helper function to check a list of entries in a single statement
fun checkSingleStatement(statement: Typed.Policy.Statement) -> Validation:
    let entries: Typed.Policy.specificPrincipals(statement)
    Validation.join (List.concat (
      [ Validation.error {
          message: "No wildcards permitted in principals",
          when: case statement.principals of
            | None -> False
            | Optional p -> principalIsWildcard(p) }],
      [ checkSingleEntry(entry) for entry in entries ]
    ))

The function Policy.specificPrincipals takes a Policy.Statement and returns a List<Principal.Entry>. It allows us to bypass the Principal.Block type and go straight to the block’s list of entries. We use a local variable to assign that list of entries to entries.

The validation throws an error if the optional principals field in the statement argument (statement.principals) returns True when the third function is called, and the validation results are stored in a list.

If you could see inside that List<Validation>, you’d see something like this:

[Validation.success, Validation.error {...}, Validation.error {...}]

We also call the fourth function to check each entry in entries, and those validation results are stored in a second list.

That List<Validation> might look like this:

[Validation.success, Validation.error {...}]

We concatenate the two lists, or put them together in a single list. The result could look like this:

[Validation.success, Validation.error {...}, Validation.error {...}, Validation.success, Validation.error {...}]

Then we use Validation.join to combine those validation results with the validation results from the first function.

The Third Function

The third function, principalIsWildcard, takes a Principal.Block and returns a Boolean. If the type field is a Principal (as opposed to NotPrincipal), and the principals field is a Wildcard, the function returns True (which triggers a Validation.error in the second function).

# Helper function to return True if a principal is a Wildcard type
fun principalIsWildcard(principal: Typed.Principal.Block) -> Bool:
  principal.type == Typed.Principal.Principal &&
  principal.principals == Typed.Wildcard.Wildcard

The Fourth Function

The fourth function, checkSingleEntry, takes a Principal.Entry from the second function and returns a Validation.

We declare a local variable, unauthorized, to hold all entry IDs that aren’t in the whitelist. The id for id in entry.ids list comprehension iterates over each Principal.Id listed in the id field of a Principal.Entry.

Finally, we look inside the list unauthorized.

  • If it’s not empty, we throw an error and print each unauthorized ID to the screen.
  • If it’s empty, there aren’t any unauthorized principals in the bucket policy, so it passes validation. (Rather than using an explicit Validation.success condition, we used the when field of Validation.error to define the failure condition, and any other state is implicitly successful.)

The last task is to register the validation and “turn it on.”

# Helper function to check a single entry
fun checkSingleEntry(entry: Typed.Principal.Entry) -> Validation:
    # Make a list of all the entries that don't match the whitelist entries
    let unauthorized: [
      id for id in entry.ids
      if !(List.member(id, whitelistedIds))
    ]
    # Throw an error if there's anything in the unauthorized list
    Validation.error {
      when: !List.isEmpty(unauthorized),
      # Print each unauthorized ID in an error message
      message: "Error: Policy specifies unauthorized principal(s) " ++ String.join(", ", unauthorized)
    }

validate allowOnlyWhitelistedAccountsInBucketPolicy

The Full Module

Here’s how the validation module looks when it’s all put together:

import Fugue.AWS.S3 as S3
import Fugue.AWS.IAM.Typed as Typed

# ADD WHITELISTED AWS ACCOUNT IDS HERE
# This list will be compared to the principals list in a typed bucket policy.
whitelistedIds: ["000000000000", "123456789012"]

fun allowOnlyWhitelistedAccountsInBucketPolicy(bucket: S3.Bucket) -> Validation:
  # Look for bucket policy
  case bucket.(S3.Bucket).policy of
    | None -> Validation.success
    | Optional str -> case Typed.Policy.fromString(str) of
      # Get optional bucket policy
      | None -> Validation.error {message: "Error: Unable to parse specified policy"}
      | Optional policy ->
        # Check each statement in the list of policy statements
        Validation.join [checkSingleStatement(statement) for statement in policy.(Typed.Policy.Policy).statements]

# Helper function to check a list of entries in a single statement
fun checkSingleStatement(statement: Typed.Policy.Statement) -> Validation:
    let entries: Typed.Policy.specificPrincipals(statement)
    Validation.join (List.concat (
      [ Validation.error {
          message: "No wildcards permitted in principals",
          when: case statement.principals of
            | None -> False
            | Optional p -> principalIsWildcard(p) }],
      [ checkSingleEntry(entry) for entry in entries ]
    ))

# Helper function to return True if a principal is a Wildcard type
fun principalIsWildcard(principal: Typed.Principal.Block) -> Bool:
  principal.type == Typed.Principal.Principal &&
  principal.principals == Typed.Wildcard.Wildcard

# Helper function to check a single entry
fun checkSingleEntry(entry: Typed.Principal.Entry) -> Validation:
    # Make a list of all the entries that don't match the whitelist entries
    let unauthorized: [
      id for id in entry.ids
      if !(List.member(id, whitelistedIds))
    ]
    # Throw an error if there's anything in the unauthorized list
    Validation.error {
      when: !List.isEmpty(unauthorized),
      # Print each unauthorized ID in an error message
      message: "Error: Policy specifies unauthorized principal(s) " ++ String.join(", ", unauthorized)
    }

validate allowOnlyWhitelistedAccountsInBucketPolicy

Download module from Github.

Testing The Validation

Let’s test the validation by compiling TypedIAMComposition.lw from Writing Validations on Typed IAM Policies. Will it pass? Or will it fail?

First, download and install the Fugue Client Tools. Grab the validation and composition from Github if you haven’t already and download them to the same folder. Then use the --validation-modules option with lwc to specify the name of the validation to use (without the extension):

lwc TypedIAMComposition.lw --validation-modules ValidateTypedIAMPolicy

Uh oh! Looks like there’s an unauthorized account ID listed as a principal. You should see this error message:

ludwig (validation error):
  "TypedIAMComposition.lw" (line 7, column 13):
  Validation failed:

     7| bucketRepo: S3.Bucket.new {
     8|   name: "test",
     9|   region: AWS.Us-east-1,
    10|   policy: Typed.Policy.toString(typedIAMbucketPolicy)
    11| }

  Error: Policy specifies unauthorized principal(s) 111111111111

  (from ValidateTypedIAMPolicy.allowOnlyWhitelistedAccountsInBucketPolicy)

Great news! Our validation is working as intended. The AWS account ID 111111111111 does not appear in the whitelist and therefore the composition fails validation.

Action required! If you edit line 19 of TypedIAMComposition.lw and replace "111111111111" with "000000000000", then compile the composition again, you’ll see that it compiles successfully without output.

Validating the String Policy

As we mentioned earlier, the validation works on string and typed IAM policies. To prove it, we’ll compile a composition containing a string version of the same policy. You can download it from Github, then compile it with lwc:

lwc StringIAMComposition.lw --validation-modules ValidateTypedIAMPolicy

You should see the following error:

ludwig (validation error):
  "StringIAMComposition.lw" (line 6, column 13):
  Validation failed:

     6| bucketRepo: S3.Bucket.new {
     7|   name: "test",
     8|   region: AWS.Us-east-1,
     9|   policy: stringBucketPolicy
    10| }

  Error: Policy specifies unauthorized principal(s) 222222222222

  (from ValidateTypedIAMPolicy.allowOnlyWhitelistedAccountsInBucketPolicy)

This time, AWS account 222222222222 is the culprit.

Wildcards

There’s one last thing to check. What if a composition contains a wildcard? Since AWS prohibits wildcards in principals, our validation should catch this.

You can try it out by downloading InvalidWildcardPolicy.lw from Github. This composition is the same as TypedIAMComposition.lw except it includes a string wildcard "*" on line 19 and uses Wildcard.wildcard to construct a Wildcard type on line 24.

Let’s compile it:

lwc InvalidWildcardPolicy.lw --validation-modules ValidateTypedIAMPolicy

You should see a number of validation errors. We’ve excerpted the error messages here:

  • The first validation error says Error: Policy specifies unauthorized principal(s) *. This is because the whitelist does not include a wildcard.
  • The second validation error says No wildcards permitted in principals. The principalIsWildcard function raises this error message.
  • The third and fourth validation error both say wildcards aren't allowed in lists of entries. The errors are raised by a built-in validation, Policy.policyIsValid. The string wildcard on line 19 and the Typed.Wildcard.wildcard on line 24 trigger the error.

Now you’ve learned how to write a validation with the typed IAM library to test the contents of IAM policies. (Though as always, remember that you should write it against the resource the policy is attached to!) You’ve also tried out the validation on a noncompliant composition, you’ve seen how the validation can test string and typed policies, and you’ve shown that it works on policies with wildcards. Way to go!

Next Steps

If you want some practice writing typed IAM policies or validations, see the examples in our Github repo for inspiration. You can brush up on typed IAM with Writing Typed IAM Policies. Learn more about validations at Guide to Writing Validations, Part 1. Or, check out some validation walkthroughs in Examples. And as always, reach out to support@fugue.co with any questions.