Writing Typed IAM Policies

What is a Typed IAM Policy?

In AWS, an IAM policy is a set of permissions that can be attached to an entity such as an identity or resource. The policy is typically stored as a JSON document.

In Ludwig, an IAM.Policy policyDocument is treated as a string, whether it’s inline or read from a JSON file:

import Fugue.AWS.IAM as IAM

# This policy is inline
bucketPolicy: IAM.Policy.new {
  policyName: "another-policy",
  policyDocument: '{
     "Version": "2012-10-17",
     "Statement": [
       {
         "Effect": "Allow",
         "Action": ["s3:ListBucket"],
         "Resource": ["arn:aws:s3:::test"],
         "Principal": {
             "AWS": ["123456789012", "111111111111"]
         }
       },
       {
         "Effect": "Allow",
         "Action": ["s3:GetObject"],
         "Resource": ["arn:aws:s3:::test/*"],
         "Principal": {
             "AWS": ["123456789012", "000000000000"]
         }
       }
     ]
   }'
}

# This policy is read from an external file
bucketPolicyFromFile: IAM.Policy.new {
  policyName: "my-policy",
  policyDocument: String.readFileUtf8("policy/my-policy.json")
}

Unfortunately, a string can’t be validated as easily as other types can. A regular expression can help find out if a string policy contains the “s3:ListBucket” action, but ultimately, a string can be any combination of characters, and the Ludwig compiler doesn’t know or care what’s inside it.

An IAM.Typed.Policy, however, treats the individual elements of a policy as strictly defined types. This enables you to gain the benefits of type safety in your IAM policies. Instead of handling a black box of “String,” the Ludwig compiler (lwc) knows exactly what to expect. It knows that a Policy.Document should contain a List<Policy.Statement>, and that a Principal.EntryType can only be Principal.AWS, Principal.Federated, or Principal.Service. As a result, it’ll tell you when something is wrong at compile-time, not runtime.

For example, if you have an invalid “Version” statement in a JSON policy, the string will still compile – and elicit an error from AWS upon fugue run. But when that “Version” statement is an IAM.Typed.Policy.Version type, then the composition won’t compile unless its value is V2008-10-17 or V2012-10-17, the type’s only two constructors.

Another advantage of a typed policy is that you can use variables to make parts reusable and less repetitive. For example, if you use the same list of principals frequently, you can assign the list to devOpsPrincipals and use the variable wherever you’d normally use the list.

A typed IAM policy can do everything a string IAM policy can do.

Validations and Typed IAM Policies

Type safety alone is reason enough to use typed IAM policies, but there’s an even better reason. The biggest benefit of typed policies over string policies is that you can write validations to test them. When the compiler knows all the 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 an S3 bucket policy.

We’ll discuss validations further in Writing Validations on Typed IAM Policies. In the meantime, let’s compare a typed policy to a string policy.

An Example Typed IAM Policy

Here’s an example of a typed IAM policy. It does the same thing as the string policy we shared above:

import Fugue.AWS.IAM.Typed as Typed

typedIAMbucketPolicy: Typed.Policy.new {
  version: Typed.Policy.V2012-10-17,
  statements: [
    Typed.Policy.allow {
      actions: [ "s3:ListBucket" ],
      resources: [ "arn:aws:s3:::test" ],
      principals: [ Typed.Policy.aws ["123456789012", "111111111111"] ]
      },
    Typed.Policy.allow {
      actions: [ "s3:GetObject"],
      resources: [ "arn:aws:s3:::test/*"],
      principals: [ Typed.Policy.aws ["123456789012", "000000000000"] ]
    }
  ]
}

Download module from Github.

Note that the examples use different modules. The string policy uses Fugue.AWS.IAM.Policy and the typed policy uses Fugue.AWS.IAM.Typed.Policy.

Note

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

As you can see, the typed version of the IAM policy looks quite similar to the JSON version. The basic parts are all there: version, statements, effects, actions, resources, and principals. And the actions, resources, and principals still take a string. But because the policy itself is typed, there are some differences – the version is a constructor, “Effect” is a Policy.allow function, and its fields actions, resources, and principals expect certain values.

For example, if you were to specify a list of integers instead of a list of strings for actions – like actions: [ 1 ] – the Ludwig compiler would catch the type error.

Now that we’ve examined an example typed policy, let’s cover how to write your own. But to do that, we first need to go over the modules in the Fugue.AWS.IAM.Typed library.

The Typed IAM Library

The Fugue.AWS.IAM.Typed library contains eight modules:

Fugue.AWS.IAM.Typed.Action
Includes types and functions for actions, which are the individual tasks granted or denied in a policy. Actions are formatted as <service>:<task>. Example: sqs:SendMessage.
Fugue.AWS.IAM.Typed.AssumeRolePolicy
Enables you to create a policy allowing the sts:AssumeRole action, such as for a cross-account role.
Fugue.AWS.IAM.Typed.Condition
Allows you to specify conditions for permissions. For example, you can allow an action only when it is performed from a given IP address.
Fugue.AWS.IAM.Typed.JSONParse
For internal use. Contains tools for parsing JSON objects.
Fugue.AWS.IAM.Typed.Policy
Includes many of the basic types that make up a typed policy, like Policy.Policy, Policy.Statement, and more. If you’re writing a typed policy, this is where you start.
Fugue.AWS.IAM.Typed.Principal
Contains types and functions related to policy principals, which are entities allowed or denied access to a resource.
Fugue.AWS.IAM.Typed.Resource
Allows you to specify which resources a statement covers.
Fugue.AWS.IAM.Typed.Wildcard
Contains types and functions for handling wildcards, or *.

This list should give you a hint on where to find the types, constructors, and functions you need for your policy.

How to Write a Typed IAM Policy

The basics of writing a typed IAM policy are the same as for writing a string IAM policy.

A policy must contain one or more statements, and the policy may also contain a version and/or ID.

Instead of using "Effect": "Allow" or "Effect": "Deny" as you would in a string policy, you use the Policy.allow or Policy.deny functions to create a statement with the given effect.

Each statement must contain two fields at a minimum:

These fields are optional:

You can’t have both actions and notActions in the same statement, and the same goes for resources / notResources and principals / notPrincipals.

For example, if you explicitly list the resources that aren’t affected by a statement (notResources), the remaining resources are implicitly affected (resources), so it would be redundant (and time-consuming!) to list both.

Typed Policy Line by Line

Import Library

Let’s return to the typed IAM policy from earlier. (Download module from Github.)

We begin by importing the Fugue.AWS.IAM.Typed library with the alias Typed:

import Fugue.AWS.IAM.Typed as Typed

Create New Policy

Next, we create a new typed policy with the new function. We’ll name the binding typedIAMbucketPolicy:

typedIAMbucketPolicy: Typed.Policy.new {

The Policy.Policy type has one constructor, also named Policy.Policy, which takes a Policy.Document argument. If you look at the type definition for Policy.Document, you can see that it has a required statements field and optional version and id fields:

type Document:
  version: Optional<Version>
  id: Optional<Id>
  statements: List<Statement>

Set Version

We include the version field in this policy, but not id. As we mentioned earlier, the Version type has two constructors, V2008-10-17 and V2012-10-17. We select V2012-10-17 and start the statements list:

version: Typed.Policy.V2012-10-17,
statements: [

Write First Statement

The statements field must be a List<Policy.Statement>, as we saw in the Policy.Document type definition above. The two ways to construct a statement are Policy.allow and Policy.deny. We’ve chosen Policy.allow:

Typed.Policy.allow {

The Policy.allow function has eight arguments, all of which are optional. Below, we’ve used actions, resources, and principals.

Actions

actions contains a list of string permissions, just like in the original policy. This first statement contains a single action, s3:ListBucket.

actions: [ "s3:ListBucket" ],

Resources

resources contains a list of string ARNs, just like in the original policy. This first statement contains the resource test, an S3 bucket with the ARN arn:aws:s3:::test.

resources: [ "arn:aws:s3:::test" ],

Because our S3 bucket policy is a resource-based policy, AWS requires the principals field.

Principals

The Policy.aws function essentially takes a list of AWS account IDs and returns a list of AWS principals that the policy will apply to. (If you used the notPrincipals field instead, the function would return a list of principals the policy would not apply to.) Below, Policy.aws ensures the policy statement is applied to the AWS account IDs "123456789012" and "111111111111".

principals: [ Typed.Policy.aws ["123456789012", "111111111111"] ]
},

Alternatively, we could have used the Policy.service function to return a list of service principals (like ["elasticmapreduce.amazonaws.com"]) or the Policy.federated function to return a list of federated principals (for example, ["accounts.google.com"]).

Policy.aws vs. Principal.aws

The Policy and Principal modules both contain aws, federated, and service functions. The Policy functions (for example, Policy.aws) return a Wildcard<Entry>, which is an Entry that might include a wildcard (the string "*"). The Principal functions (like Principal.aws), however, return just an Entry.

Because the Policy.allow and Policy.deny statement constructors expect a List<Wildcard<Entry>> for the principals field, use Policy.aws, Policy.federated, or Policy.service with them.

Principal.aws, Principal.federated, and Principal.service are better suited for validations and other situations where you’d want to return an Entry instead of Wildcard<Entry>.

Write Second Statement

Next, we add a second Policy.allow statement. This statement says that the AWS account IDs "123456789012" and "000000000000" have the "s3:GetObject" action on the contents of the test bucket. Since the permission indicates a different resource, it needs its own statement:

    Typed.Policy.allow {
      actions: [ "s3:GetObject"],
      resources: [ "arn:aws:s3:::test/*"],
      principals: [ Typed.Policy.aws ["123456789012", "000000000000"] ]
    }
  ]
}

And that’s the basics of writing a typed IAM policy! Here’s the whole thing, one more time:

import Fugue.AWS.IAM.Typed as Typed

typedIAMbucketPolicy: Typed.Policy.new {
  version: Typed.Policy.V2012-10-17,
  statements: [
    Typed.Policy.allow {
      actions: [ "s3:ListBucket" ],
      resources: [ "arn:aws:s3:::test" ],
      principals: [ Typed.Policy.aws ["123456789012", "111111111111"] ]
      },
    Typed.Policy.allow {
      actions: [ "s3:GetObject"],
      resources: [ "arn:aws:s3:::test/*"],
      principals: [ Typed.Policy.aws ["123456789012", "000000000000"] ]
    }
  ]
}

We’re not done with our policy yet, though – there’s one more critical step.

Render the Policy as a String

In order for lwc to successfully compile and attach the policy to an IAM entity or other resource, the policy must be rendered as a string. Don’t worry; you still get all the benefits of type safety in compilation. But the end result must be a string.

For example, the S3.Bucket type contains a policy field that expects a string. If it gets a typed policy instead, the compiler will throw an error:

ludwig (type error):
  "TypedIAMComposition.lw" (line 26, column 27):
  Type error in the expression:

    26| bucketRepo: S3.Bucket.new {
    27|   name: "test",
    28|   region: AWS.Us-east-1,
    29|   policy: typedIAMbucketPolicy
    30| }

  Could not match the type (1):
    Policy
  With (2):
    Optional<String>

  1. Type Policy inferred from the use of typedIAMbucketPolicy:

    29|   policy: typedIAMbucketPolicy
                  ^^^^^^^^^^^^^^^^^^^^

  typedIAMbucketPolicy has the type:
    Policy

  2. Type Optional<String> expected in first argument of new:

    26| bucketRepo: S3.Bucket.new {
                    ^^^^^^^^^^^^^

  new has the type:
    fun{
         name: String,
         region: Region,
         ...
       } -> Bucket

So, how do you render a typed policy as a string policy? Simple – with the Policy.toString function. Here’s our bucket declaration:

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

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

As you can see, we pass the name of the policy binding, typedIAMbucketPolicy, to the function with Typed.Policy.toString(typedIAMbucketPolicy). Since the typed policy has been rendered as a string, lwc can successfully compile it.

Here’s the finished composition:

composition

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

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

typedIAMbucketPolicy: Typed.Policy.new {
  version: Typed.Policy.V2012-10-17,
  statements: [
    Typed.Policy.allow {
      actions: [ "s3:ListBucket" ],
      resources: [ "arn:aws:s3:::test" ],
      principals: [ Typed.Policy.aws ["123456789012", "111111111111"] ]
      },
    Typed.Policy.allow {
      actions: [ "s3:GetObject"],
      resources: [ "arn:aws:s3:::test/*"],
      principals: [ Typed.Policy.aws ["123456789012", "000000000000"] ]
    }
  ]
}

To try it for yourself, you can download and install the Fugue Client Tools. If you haven’t done so yet, grab the composition from Github and compile it with lwc:

lwc TypedIAMComposition.lw

If compilation is successful, lwc returns without output.

You can fugue run this composition if you like. Just replace test with a globally unique bucket name and replace the AWS account IDs with your own.

Warning

Sometimes updating a typed IAM process can fail. See Troubleshooting for a workaround. If you intend to use typed IAM policies in production, contact support@fugue.co.

Next Steps

Now that you’ve seen how to write a typed IAM policy, you’re ready to learn how to write a validation for it! Continue onward with Writing Validations on Typed IAM Policies. Or, if you’d rather brush up on validation basics first, see Guide to Writing Validations, Part 1. You can also read the typed IAM documentation at the Standard Library Reference. For more typed IAM policy examples, check out our Github repo. And as always, reach out to support@fugue.co with any questions.