How to Write a Validation: IAM Users and the CIS Benchmark

Overview

This example shows you how to write a Ludwig validation to ensure your infrastructure is compliant with the Center for Internet Security‘s CIS AWS Foundations Benchmark Recommendation 1.16, which states “Ensure IAM policies are attached only to groups or roles.”

Note: The Fugue Compliance Suite includes a similar validation in its Fugue.Compliance.CIS library. To learn more, see the Compliance Suite overview or walkthrough.

Prerequisites

You’ll need to have the Fugue Client Tools installed, because we’re going to use the Ludwig compiler. lwc allows us to compile Ludwig files and apply design-time validations. If you haven’t installed the package yet, follow the instructions here.

To apply the validation at runtime, you’ll need the paid version of Fugue and a running Conductor. See the Quick Setup for Conductor installation.

While not a strict prerequisite, Guide to Writing Validations, Part 1 sets the foundation for this walkthrough. If you’re new to validations, we recommend giving that guide a read first.

What We’ll Do In This Example

We’ll cover how to write Ludwig to make a validation enforcing CIS 1.16. We’ll demonstrate how the validation prevents a noncompliant composition from compiling or running. We’ll also give you a bonus take-home exercise so you can more easily audit your existing infrastructure.

What We’ll Have When We’re Done

A real-world validation module you can keep and apply to your own infrastructure.

How Long It Will Take

About 30 minutes.

Download

You can download the source code for this example here:

If you executed init during the Quick Setup, download the files to the directory where you ran the command.

Get editor plug-ins here.

Let’s Go!

Getting Started

A validation is a type of function that tests a property of your code, and if any portion of the code fails that test, it will not compile and cannot be executed.

Let’s say you’ve decided to write a validation that enforces CIS 1.16, “Ensure IAM policies are attached only to groups or roles.”

According to the standard:

By default, IAM users, groups, and roles have no access to AWS resources. IAM policies are the means by which privileges are granted to users, groups, or roles. It is recommended that IAM policies be applied directly to groups and roles but not users.

The document includes instructions for auditing your infrastructure and remediating any violations. But to keep your infrastructure compliant in the future, you want to write a validation that checks every single composition before the infrastructure is provisioned.

We know that policies should not be directly attached to users, but they can be directly attached to groups, which contain users. So the validation we write should throw an error if a composition contains a user with a directly attached policy.

Now that we’re clear on what our validation should do, we’re ready to start writing it.

Note: For the sake of clarity we refer to the CIS AWS Foundations Benchmark generically as a “standard.” This enables us to reference compliance standards more broadly (NIST, HIPAA, etc.) and simplify the terminology when we’re talking about functionality that may apply to the CIS Benchmark or other compliance standards.

Three Steps to Writing a Validation

As we explained in Guide to Writing Validations, there are three basic steps to writing just about any validation:

  1. Determine the resource you want to validate.
  2. Obtain the resource field (or other info) that needs to be validated.
  3. Perform the check on the field.

Instead of showing the finished module and walking through it line by line, like most of our walkthroughs, we’re going to start with a small part and iterate on it, just like we did in Guide to Writing Validations, Part 1.

Determine the resource we want to validate

From the Recommendation 1.16 description, it’s pretty clear that we need to validate IAM users, which are represented by the IAM.User type in Ludwig.

An IAM.User must not have a directly attached policy, whether it’s inline (IAM.Policy) or standalone (IAM.ManagedPolicy). Instead, an IAM.User must be part of an IAM.Group that has a directly attached IAM.Policy or IAM.ManagedPolicy.

Let’s begin the validation module by importing the Fugue.AWS.IAM library and giving it the alias IAM.

import Fugue.AWS.IAM as IAM

Next, we’ll write the function signature:

fun cis_1_16(user: IAM.User) -> Validation:

This is the first line of the function, as you may recall from the Functions Tutorial. The signature usually follows this format:

fun functionName(parameterName: ParameterType) -> ReturnType:

So our function signature translates to this:

  • cis_1_16 is the function name
  • user is the parameter name
  • IAM.User is the parameter type
  • Validation is the return type

The next step is writing the function body. As we learned from Guide to Writing Validations, Part 1, it’s OK to start writing a validation with the simplest possible function body:

Validation.success

This means that the validation always returns Validation.success, a value that means – well – that the validation succeeded. We’ll add the real logic in a bit, but this is a good placeholder and makes for the shortest possible functioning validation.

Finally, we need to register the validation by using the validate keyword, which tells the compiler to turn the validation on:

validate cis_1_16

Putting everything together, we end up with this:

import Fugue.AWS.IAM as IAM

fun cis_1_16(user: IAM.User) -> Validation:
  Validation.success

validate cis_1_16

This is a tiny but complete validation module. While we develop the validation, it’s useful to test it along the way. You can compile it whenever you want with lwc:

lwc Cis1_16.lw

Right now, you’ll just get a warning that you haven’t used the user variable you defined. You can safely ignore it for now since we’ll be using it soon.

Obtaining the field we want to validate

We’ve got the skeleton of a validation, so now we need to fill in the logic. But first, we need to find the precise field that we are going to validate. We need to look at the Core type definition for an IAM.User.

Why Core?

When writing a composition, users are highly encouraged to declare their infrastructure using Fugue.AWS modules rather than Fugue.Core modules.

Writing Ludwig contains the full explanation, but the gist is that Fugue.Core modules are low-level modules that include the actual types understood by the compiler. In contrast, Fugue.AWS modules are built on the Core modules, and they provide functions that return the Core types, validations that check Core types, convenience values, and other tools.

When you’re writing a composition, it’s best to use the Fugue.AWS modules because they contain abstractions designed for declaring infrastructure.

But when you’re writing a validation, you’re validating the compiler-level Core type, not an abstraction. So in this case, Fugue.Core modules are exactly what we need!

To find the Core type definition for an IAM user, we can just jump right to the Fugue.Core.AWS section of the Standard Library Reference. But it’s useful to know that functions in the Fugue.AWS modules link to the definition of their Core return types.

For example, the IAM.User.new function in the Fugue.AWS library lists its return type as “Type: User” and it links to the IAM.User type definition in the Fugue.Core library.

There, you’ll see the fields you can validate on:

type User:
  | User
      path: Optional<String>
      userName: String
      inlinePolicies: Optional<List<Policy>>
      managedPolicies: Optional<List<ManagedPolicy>>
      loginProfile: Optional<LoginProfile>

As we explained in Guide to Writing Validations, this is a list of all the fields (parameters) that make up the IAM.User type. The User type contains a single constructor, also called User, and inside the constructor are several fields, including two that are relevant for us: inlinePolicies and managedPolicies.

inlinePolicies is an optional list of IAM.Policy, and it represents inline IAM policies, of course. managedPolicies is an optional list of IAM.ManagedPolicy, and it represents managed (standalone) IAM policies, whether AWS-managed or customer-managed.

If a composition declares an IAM user with inline or managed policies, it should fail validation, because according to CIS 1.16, an IAM.User shouldn’t have inlinePolicies or managedPolicies attached to it. So our validation should make sure those fields are empty.

We can use local variables to hold the value of the inlinePolicies and managedPolicies fields. The let keyword allows us to declare a local variable, which will make our validation function a little easier to read:

import Fugue.AWS.IAM as IAM

fun cis_1_16(user: IAM.User) -> Validation:
  let inline: user.(IAM.User).inlinePolicies
  let managed: user.(IAM.User).managedPolicies

  Validation.success

validate cis_1_16

We use dot notation to access the field of a type with a single constructor. In the IAM.User type definition we just looked at, we can see that the IAM.User type contains one constructor, IAM.User, so we can use dot notation here, following this format:

parameterName.(constructor).field

So the syntax user.(IAM.User).inlinePolicies means that inside the parameter user (defined as an IAM.User type in the signature), we want to access the IAM.User constructor and select the inlinePolicies field.

Likewise, user.(IAM.User).managedPolicies means that inside the parameter user (of the type IAM.User), we want to access the constructor IAM.User and select the managedPolicies field.

Performing the checks on the fields

Now that we’ve obtained the necessary fields, it’s time to write the validation logic. But first, we need to deal with the Optional types.

Unpacking Optional types

The inlinePolicies and managedPolicies fields each take an Optional list of policies. Because the fields both take Optional types, the fields themselves are optional and can be omitted from a composition.

To recap Guide to Writing Validations, an Optional type represents two possible states – either the value exists, or it does not exist. Either inlinePolicies contains a value, or it’s not in the composition. It’s like holding a box that may or may not contain donuts. Before we can nosh on a tasty pastry, we need to answer the question of “are there actually donuts in this box?” – which means opening it and unpacking it.

So, like the box of donuts, we have to unpack Optional types before we can get to what’s inside; we have to unpack managedPolicies and inlinePolicies before we can validate their values. We can do this with ?|, the operator form of Optional.unpack.

import Fugue.AWS.IAM as IAM

fun cis_1_16(user: IAM.User) -> Validation:
  let inline: user.(IAM.User).inlinePolicies ?| []
  let managed: user.(IAM.User).managedPolicies ?| []

  Validation.success

validate cis_1_16

?| unpacks an Optional and does one of two things:

  1. If a field is present, the ?| operator assigns its value to the appropriate local variable. So, if a composition declares an IAM user with an inline policy, the local variable inline will represent the policy declared in the inlinePolicies field.
  2. If a field is missing, the ?| operator assigns it a default value. Here, that’s [], an empty list. That’s because inlinePolicies must be of the type List<Policy> and managedPolicies must be of the type List<ManagedPolicy>, so the default value must also be of the type List. And as we mentioned earlier, the condition for a successful validation is to have zero policies attached to a user, so the only acceptable value is an empty List.

Note

For more about Optional, check out Guide to Writing Validations, Ludwig.Optional in the Standard Library Reference, or our Advanced Ludwig Syntax guide.

Adding an if/then/else statement

We’ve unpacked our optional fields and assigned the values to local variables. Now we need to write the test that’ll determine whether a composition passes or fails the validation.

An if/then/else statement should do the trick. Since the goal is to ensure IAM users don’t have directly attached policies, we basically want the code version of this sentence: “if an IAM.User lists zero inline and zero managed policies, then the validation is successful, or else raise a validation error.”

That looks like this:

import Fugue.AWS.IAM as IAM

fun cis_1_16(user: IAM.User) -> Validation:
  let inline: user.(IAM.User).inlinePolicies ?| []
  let managed: user.(IAM.User).managedPolicies ?| []

  if (inline == []) && (managed == [])
  then Validation.success
  else Validation.error {
    message: "User " ++ user.(IAM.User).userName ++ " has directly attached policies. Policies should only be attached to groups or roles."
  }

validate cis_1_16

Let’s run through it. If a composition declares an IAM.User, the validation:

  • Checks whether a inlinePolicies or managedPolicies field is present
    • If so, assigns the value to a local variable
    • If not, assigns the default value (an empty list, []) to the local variable
  • Checks whether the local variable contains an empty list []
    • If so, the validation is successful
    • If not, the validation raises an error

As you can see, we’ve added Validation.error to the function, which includes an error message explaining why the validation failed. For convenience, we added the username of the offending IAM.User, and we used dot notation to access the userName field in the IAM.User constructor in the user parameter.

And we’re done writing the validation module! Let’s test it out.

If you haven’t already, download NoncompliantIAMUser.lw to the same directory containing your validation module. This composition declares the following resources:

  • 3 IAM users (1 compliant, 2 noncompliant)
  • 1 IAM group
  • 2 IAM policies (1 managed, 1 inline)
  • 1 IAM policy document

Applying a Design-Time Validation

We can apply the validation at design-time or at runtime.

Design-time validations are handy for development because the Ludwig compiler enforces them locally at compile time. Use the --validation-modules option with lwc to compile the composition using the Cis1_16 validation:

lwc NoncompliantIAMUser.lw --validation-modules Cis1_16

And you should see the following errors:

ludwig (validation error):
  "NoncompliantIAMUser.lw" (line 16, column 20):
  Validation failed:

    16| noncompliantAlice: IAM.User.new {
    17|   userName: "noncompliantAlice",
    18|   managedPolicies: [myManagedPolicy]  # 1. Comment out this line to fix it
    19| }

  User noncompliantAlice has directly attached policies. Policies should only be attached to groups or roles.

  (from Cis1_16.cis_1_16)

ludwig (validation error):
  "NoncompliantIAMUser.lw" (line 25, column 22):
  Validation failed:

    25| noncompliantCharlie: IAM.User.new {
    26|   userName: "noncompliantCharlie",
    27|   inlinePolicies: [myInlinePolicy]  # 2. Comment out this line to fix it
    28| }

  User noncompliantCharlie has directly attached policies. Policies should only be attached to groups or roles.

  (from Cis1_16.cis_1_16)

The two noncompliant IAM users failed validation, which means our validation is working as intended.

Applying a Runtime Validation

Runtime validations are enforced by the Conductor at runtime, and they ensure compliance of all future processes.

We upload a validation to the Conductor with the fugue policy validation-add command and give it the name Cis1_16:

fugue policy validation-add --name Cis1_16 Cis1_16.lw

You should see the following output:

[fugue validation] Compiling Ludwig File Cis1_16.lw
[ OK ] Successfully compiled. No errors.

Uploading validation module to S3 ...
[ OK ] Successfully uploaded.

Requesting the Conductor create new validation module ...
[ DONE ] Validation module 'Cis1_16' uploaded and added to the Conductor.

Then, we can run the noncompliant composition to see what happens:

fugue run NoncompliantIAMUser.lw

You’ll see a lot of output as Fugue compiles the composition locally, uploads it to S3, and then returns error messages from the failed validation on the Conductor:

[ fugue run ] Running /Users/main-user/NoncompliantIAMUser.lw

Run Details:
    Account: default
    Alias: n/a

Compiling Ludwig file /Users/main-user/NoncompliantIAMUser.lw
[ OK ] Successfully compiled. No errors.

Uploading compiled Ludwig composition to S3...
[ OK ] Successfully uploaded.

Requesting the Conductor to create and run process based on composition ...
[ ERROR ] ludwig (validation error):
  "/tmp/548038282/composition/src/NoncompliantIAMUser.lw" (line 16, column 20):
  Validation failed:

    16| noncompliantAlice: IAM.User.new {
    17|   userName: "noncompliantAlice",
    18|   managedPolicies: [myManagedPolicy]  # 1. Comment out this line to fix it
    19| }

  User noncompliantAlice has directly attached policies. Policies should only be attached to groups or roles.

  (from Cis1_16.cis_1_16)

ludwig (validation error):
  "/tmp/548038282/composition/src/NoncompliantIAMUser.lw" (line 25, column 22):
  Validation failed:

    25| noncompliantCharlie: IAM.User.new {
    26|   userName: "noncompliantCharlie",
    27|   inlinePolicies: [myInlinePolicy]  # 2. Comment out this line to fix it
    28| }

  User noncompliantCharlie has directly attached policies. Policies should only be attached to groups or roles.

  (from Cis1_16.cis_1_16)

The validation error messages are the same as the ones we saw in the design-time validation, except runtime validations list the location of the composition snapshot on the Conductor while design-time validations list the local path to the composition.

As you can see in the output, we included comments on lines 18, 27, and 34 of NoncompliantIAMUser.lw instructing how to bring it into compliance, so feel free to experiment. If the composition passes the design-time validation and compiles, there won’t be any output from lwc. If you fugue run the composition and it passes the runtime validation and compiles, the CLI will report that the process has been created.

Bonus Exercise: Auditing Existing Resources

You’ve written a validation to ensure your infrastructure is compliant with CIS 1.16 at design-time (when you’re configuring the infrastructure) and at runtime (prior to provisioning it). As long as the runtime validation is in place, Fugue will prevent noncompliant processes from being created, ensuring that the only IAM users it creates are compliant ones.

But what about your existing infrastructure? How can you find noncompliant IAM users that aren’t Fugue-managed?

The CIS AWS Foundations Benchmark gives instructions for auditing an account to determine if IAM policies are directly attached to users.

“1. Run the following to get a list of IAM users:”

aws iam list-users --query 'Users[*].UserName' --output text

“2. For each user returned, run the following command to determine if any policies are attached to them:”

aws iam list-attached-user-policies --user-name <iam_user>
aws iam list-user-policies --user-name <iam_user>

“3. If any policies are returned, the user has a direct policy attachment.”

This works just fine, but it’s a little tedious when you’re dealing with a large number of IAM users.

A simpler way to audit your existing infrastructure is to use Transcriber to scan your AWS account and generate a composition representing all your IAM users, then compile the composition using the Cis1_16.lw validation we walked through. The quick two-step process enables you to see all the violations at once, so you don’t need to run the same command for each individual IAM user in your account.

First, run Transcriber. It’s a tool packaged with the Fugue Client Tools, so if you’ve installed the Fugue CLI, you’ve also installed Transcriber. This command tells Transcriber to scan only the aws-iam-users service and save the output as ExistingIAMUsers.lw:

fugue-transcriber -i aws-iam-users ExistingIAMUsers.lw

Then, instruct lwc to compile the output using the Cis1_16 validation module:

lwc ExistingIAMUsers.lw --validation-modules Cis1_16

Note: It’s a best practice to import the validation module into the composition for design-time validations, but you can use the compiler’s --validation-modules flag to quickly test a composition.

If any IAM users in your account have directly attached policies, the validation fails and the output lists the violations individually. And if the IAM users are all compliant, the composition passes validation and compiles successfully.

Transcriber is read-only, so nothing happens to the infrastructure in your account; it’s a safe way to audit your resources. You can then follow the CIS AWS Foundations Benchmark instructions for remediating noncompliant IAM users. (It’s possible to fix violations by migrating your infrastructure to Fugue and then updating the composition to be compliant, but this is an advanced use case that users should not attempt without the assistance of Fugue Support.)

And you’re done! You learned the three steps to writing a validation: determine the resource you want to validate, obtain the field that needs to be validated, and perform the check on the field. You learned how to write a validation to enforce CIS 1.16. You learned how to apply the validation at design-time and runtime. You even learned how to audit your existing resources using the validation. Nice work!

Next Steps

And as always, reach out to support@fugue.co with any questions.