Guide to Writing Validations, Part 2

This is the second part of a two-part guide, so make sure you’ve read Part 1 before continuing!

Common validations on fields

In Part 1, we showed how you can use the when parameter with List.isEmpty to trigger an error when a list is empty, and we showed how to use it with the != operator to trigger an error when a type isn’t AWS.Us-east-1. Now we’re going to take a quick break from our load balancer validation to give some other ways to use the when condition and different kinds of fields.

Booleans

Naturally, the simplest type for validations is a straight Bool, True or False.

Operation Syntax examples
And portOpen && validCidr
Or validIpv4 || validIpv6
Not !List.isEmpty(tags)

Numbers

Ludwig has two number types. Ints are natural numbers, possibly negative, and they have infinite precision. Floats, on the other hand, are floating point numbers with double machine precision. In terms of notation, floating point numbers must always have a fractional part, even if it is .0. This means 1 is an Int and 1.0 (or 1.) is a Float.

Int

Operation Syntax examples
Equality port == 80
Inequality port != 80
Larger than, smaller than port > 0 && port < 80
Greater than or equal to, smaller than or equal to port >= 80 && port <= 8080

Conversion to string

Validation.error {
  message: "Port " ++ Int.toString(port) ++ " is not valid",
  when: port < 0
}

Float

Operation Syntax examples
Equality pi == 3.14
Inequality pi != 3.14
Larger than, smaller than threshold > 2.1 && threshold < 3
Greater than or equal to, smaller than or equal to threshold >= 1.0 && threshold <= 5.0

Conversion to string

Validation.error {
  message: "Threshold " ++ Float.toString(treshold) ++ " is not valid"
  when: threshold < 0.0
}

Strings

Operation Syntax examples
Equality host == "localhost"
Inequality host != "localhost"
Empty? String.isEmpty(host)
Length String.length(bucketName) < 32
Starts with String.startsWith("0.0.0.0", cidr)
Ends with String.endsWith("/0", cidr)

Regex

A special class of String-based validations are Regex-based validations. Regexes (or regular expressions) allow you to match strings to certain patterns and extract parts of the strings.

A regex can be declared separately. We can do that using a local variable with the let keyword, just as we saw before. Let’s look at an example:

import Fugue.AWS.ELB as ELB
import Fugue.AWS as AWS

fun hasValidName(lb: ELB.LoadBalancer) -> Validation:
  let name: lb.(ELB.LoadBalancer).loadBalancerName
  let regex: Regex.new {
    pattern: "^[a-z]+$"
  }
  Validation.error {
    when: !Regex.matches {
      regex: regex,
      input: name
    },
    message: "LoadBalancer must have a valid name"
  }

validate hasValidName

As you can see, we can simply use the Regex.matches function to test if the input, or the value of loadBalancerName, matches the regex we compiled, and if it doesn’t match, the function rejects it. We can also make this a bit shorter by not using a local variable to store the regex:

import Fugue.AWS.ELB as ELB
import Fugue.AWS as AWS

fun hasValidName(lb: ELB.LoadBalancer) -> Validation:
  let name: lb.(ELB.LoadBalancer).loadBalancerName
  Validation.error {
    when: !Regex.matches {
      regex: Regex.new {pattern: "^[a-z]+$"},
      input: name
    },
    message: "LoadBalancer must have a valid name"
  }

validate hasValidName

(You can download this validation from Github.)

Regexes are powerful and you can do a lot of things with them, including extracting pieces of the string you want to compare or inspect. To learn more, check out the Ludwig.Regex documentation.

Lists

Here, we only demonstrate some of the simple properties you can check on a List. Most validations that involve lists are more complicated and relate to first-order logic with quantifiers. This means saying things such as: “at least one of the things in the list must...” or “all things in the list must...” This isn’t too tricky in Ludwig, as we’ll see later – but let’s look at the simple tests first. We’ve already seen one of them, List.isEmpty.

Operation Syntax examples
Equality tags == [AWS.tag("department", "sales")]
Inequality tags != [AWS.tag("department", "sales")]
Empty? List.isEmpty(tags)
Length List.length(tags) > 1
Indexing (taking an item from the list) List.index(ports, 0) == 80

Sum types

Earlier, when we talked about accessing the data you want from a resource, we briefly mentioned sum types and constructors. Now we’re going to look at them in more detail and illustrate how you can validate them.

Interpreting the docs for a sum type

The sum type we saw before, ELB.LoadBalancer, only had a single constructor. That allowed us to easily access the data in it. Now, let’s look at a type that has more than one constructor, EC2.IpPermissionTarget.

type IpPermissionTarget:
  | IpRanges List<IpRange>
  | SecurityGroups List<SecurityGroup>
  | PrefixLists List<PrefixList>

This time, we have not one but three constructors. This means that there are three ways to construct an IpPermissionTarget, depending on the functions you use and what you want to achieve. Writing a validation against this is not too different from the branching we explained earlier.

Case statements (again)

We can start by creating a simple validation that doesn’t allow the user to use empty prefix lists, which is a lot like our example in the optionals section.

import Fugue.AWS.EC2 as EC2

fun noEmptyPrefixLists(tgt: EC2.IpPermissionTarget) -> Validation:
  case tgt of
  | EC2.IpRanges list -> Validation.success
  | EC2.SecurityGroups list -> Validation.success
  | EC2.PrefixLists list ->
    Validation.error {
      when: List.isEmpty(list),
      message: "Please don't use empty PrefixLists"
    }

validate noEmptyPrefixLists

(You can download this validation from Github.)

The code we put here should look similar to the Optional example, but with more cases. As we mentioned before, the compiler forces us to handle all cases, which ensures that we are very likely to end up with a validation that works well in any outcome. We’ve also brought back the Validation.success we used earlier. If the EC2.IpPermissionTarget type has the EC2.IpRanges or EC2.SecurityGroups constructors, the validation automatically passes, because we don’t need to test them. We only need to test an EC2.IpPermissionTarget type with a EC2.PrefixLists constructor, and we’ll use List.isEmpty to throw an error if the EC2.PrefixLists is empty.

Now is a good time to make sure our new validation module compiles:

lwc NoEmptyPrefixLists.lw

You’ll notice that we get some warnings, like this one:

ludwig (warning):
  "MyValidation.lw" (line 5, column 5):
  Defined but not used:

    5|   | EC2.IpRanges list -> Validation.success
           ^^^^^^^^^^^^^^^^^

  Case expression variable list will be discarded

  Hint:
    You can suppress the warning by prefixing the variable name
    with a double underscore, e.g. __list

That’s because the first two cases (IpRanges and SecurityGroups) aren’t actually using the local list variable. We can clean up that warning by using _ instead. In Ludwig patterns, _ is generally used as a wildcard for something that we will discard anyway, something that we don’t care about.

Since we don’t care about the list in the first two cases, we can write:

import Fugue.AWS.EC2 as EC2

fun noEmptyPrefixLists(tgt: EC2.IpPermissionTarget) -> Validation:
  case tgt of
  | EC2.IpRanges _ -> Validation.success
  | EC2.SecurityGroups _ -> Validation.success
  | EC2.PrefixLists list ->
    Validation.error {
      when: List.isEmpty(list),
      message: "Please don't use empty PrefixLists"
    }

validate noEmptyPrefixLists

And the warnings are cleared now! But there’s no reason to stop here – plus, going a bit further will teach us a few more useful things about writing Ludwig, so let’s do just that.

More case statements

Ludwig looks at cases in a top-down order. In our case, what we really want to do is throw the validation error in the empty prefix list case, and in the other two cases we “don’t care.” This means we can simply use _ there as well! This looks like:

import Fugue.AWS.EC2 as EC2

fun noEmptyPrefixLists(tgt: EC2.IpPermissionTarget) -> Validation:
  case tgt of
  | EC2.PrefixLists list ->
    Validation.error {
      when: List.isEmpty(list),
      message: "Please don't use empty PrefixLists"
    }
  | _ -> Validation.success

validate noEmptyPrefixLists

This still handles all possible cases, so the compiler is happy. If you try removing the _ case, the compiler will tell you that your validation is incomplete. The validation is a bit hard to read, though, because of the indentation. We can clean it up a bit more by moving the case statement to inside the when parameter, which illustrates Ludwig’s flexibility. The when parameter simply takes a Bool, so we need to produce a Bool from all branches of the case statement. The result looks like this:

import Fugue.AWS.EC2 as EC2

fun noEmptyPrefixLists(tgt: EC2.IpPermissionTarget) -> Validation:
  Validation.error {
    when:
      case tgt of
      | EC2.PrefixLists list -> List.isEmpty(list)
      | _ -> False,
    message: "Please don't use empty PrefixLists"
  }

validate noEmptyPrefixLists

(You can download this validation from Github.)

As you can see, you can use case statements to analyze sum types wherever it’s convenient.

Optional is a sum type!

At this point, it should be clear that Optional is simply a sum type as well, and it could be defined as:

type Optional<a>:
  | None
  | Optional a

Where a is a parameter of the type. That parameter allows us to have Optional<Int>, Optional<String>, and even Optional<List<AWS.Tag>> (sound familiar?) without defining multiple Optional types.

List comprehensions and more first-order logic

List comprehensions

Ludwig provides list comprehensions that allow us to easily deal with lists in a syntax familiar to Python programmers. List comprehensions are very valuable for validations since they let you do things that could otherwise only be accomplished with complicated for loops and recursion. (We also touched on this briefly in Functions Tutorial, Part 5.)

We’ll briefly introduce the syntax before looking at concrete examples. This simple (contrived) list comprehension doubles each number in a list, if that number is larger than 5:

numbers1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
numbers2: [x * 2 for x in numbers1 if x > 5]

Not too exciting, but we see some key points:

  • numbers1 is the “input” of the list expression, the list that we “loop over.” As you’ve probably guessed, this needs to be a List.
  • x * 2 is the output for a single element. In this case, the output list is also a list of integers, or List<Int>, but we’ll see other examples (such as a list of Validations, i.e. List<Validation>).
  • We can filter the input list by adding an if part. This is optional; [x * 2 for x in numbers1] is also a valid list comprehension.

Now that we have a general idea of what list comprehensions do, we can look at a better example. Let’s examine the type definition for EC2.IpPermissionTarget again:

type IpPermissionTarget:
  | IpRanges List<IpRange>
  | SecurityGroups List<SecurityGroup>
  | PrefixLists List<PrefixList>

List comprehensions allow us to work first-order logic, as we stated earlier. We can use them to express something like this: for each IpRange in the list IpRanges, ensure that IpRange isn’t "0.0.0.0/0". Or, put in more natural language, ensure that the list of IP ranges doesn’t contain a “0.0.0.0/0” IP range.

Joining validations

For our first attempt at this validation, we’ll start by defining an auxiliary validation to use with the help of the EC2.IpRange documentation. This logic should look familiar:

import Fugue.AWS.EC2 as EC2

fun notOpen(r: EC2.IpRange) -> Validation:
  Validation.error {
     when: r.(EC2.IpRange).cidrIp == "0.0.0.0/0",
     message: "Don't use 0.0.0.0/0 in your IP range"
  }

validate notOpen

The validation checks if an EC2.IpRange type with an EC2.IpRange constructor has a cidrIp of “0.0.0.0/0”, and if it does, the validation throws an error.

What we now want to do is apply this validation to every IpRange in the IpRanges type (which, again, is a list of IpRange). This is easily done using a list comprehension, and the result looks like [notOpen(r) for r in list]. This gives us a List<Validation>. We can use the auxiliary function Validation.join on this, which requires exactly List<Validation>.

With our auxiliary validation function notOpen, the validation we get is:

import Fugue.AWS.EC2 as EC2

fun notOpen(r: EC2.IpRange) -> Validation:
  Validation.error {
     when: r.(EC2.IpRange).cidrIp == "0.0.0.0/0",
     message: "Don't use 0.0.0.0/0 in your IP range"
  }

fun noOpenIpRanges(tgt: EC2.IpPermissionTarget) -> Validation:
  case tgt of
  | EC2.IpRanges list ->
    Validation.join [notOpen(r) for r in list]
  | _ -> Validation.success

validate notOpen

Validation.join is the simplest way to put together a number of validations, but it can only be used for validations of the form all elements must pass this validation. We can do more complex constructions and combine them as well. In the next section, we’ll see that this isn’t too hard, either.

List argument shorthand

If functions take a single list argument, the parentheses may be dropped. That means that writing

Validation.join [notOpen(r) for r in list]

is really just a nicer way of writing:

Validation.join([notOpen(r) for r in list])

List.and and List.or

List comprehensions allow us to easily apply a function to every element of a list. In the previous example, we used Validation.join for that. Let’s see what the example looks like using List.or:

import Fugue.AWS.EC2 as EC2

fun noOpenIpRanges(tgt: EC2.IpPermissionTarget) -> Validation:
  Validation.error {
     when:
       case tgt of
       | EC2.IpRanges list ->
         List.or [
           r.(EC2.IpRange).cidrIp == "0.0.0.0/0"
           for r in list
         ]
       | _ -> False,
     message: "Don't use 0.0.0.0/0 in your IP range"
  }

validate noOpenIpRanges

(You can download this validation from Github.)

It’s similar to what we used above, but we moved the list comprehension inside the when parameter. By applying a comparison (==) to every element of the list, we get a list of Booleans (List<Bool>).

We can then simply use List.and or List.or on the result.

  • List.and returns True if all elements in the list are True. (This means it also returns True if the list is empty.)
  • List.or returns True if at least one element in the list is True.

In the case above, we should clearly use List.or – we want to raise a validation error if at least one of the IP ranges does not satisfy our policy.

Full example: CIS 4.1

With all of this, we now have enough context to understand a larger example. The CIS 4.1 standard says:

Security groups provide stateful filtering of ingress/egress network traffic to AWS resources. It is recommended that no security group allows unrestricted ingress access to port 22.

We can encode this in Ludwig:

import Fugue.AWS.EC2 as EC2

fun cis_4_1(sg: EC2.SecurityGroup) -> Validation:
  # 1. Obtain ingress rules
  let ingress: sg.(EC2.SecurityGroup).ipPermissions ?| []

  # 2. Does an IpPermissionTarget contain the "open" cidr?
  let fun containsOpenTarget(tgt: EC2.IpPermissionTarget) -> Bool:
    case tgt of
    | EC2.IpRanges list -> List.or [
        r.(EC2.IpRange).cidrIp == "0.0.0.0/0" ||
        r.(EC2.IpRange).cidrIp == "::/0"
        for r in list
      ]
    | _ -> False

  # 3. Check a single ingress rule
  let fun checkIngress(ig: EC2.IpPermission) -> Validation:
    # Obtain data
    let fromPort: ig.(EC2.IpPermission).fromPort
    let toPort: ig.(EC2.IpPermission).toPort
    Validation.error {
      when:
        (fromPort < 1 || (fromPort <= 22 && toPort >= 22)) &&
        containsOpenTarget(ig.(EC2.IpPermission).target),
      message: "Security group should not allow traffic from 0.0.0.0/0 on port 22"
  }

  # 4. Check every ingress rule
  Validation.join [checkIngress(p) for p in ingress]

validate cis_4_1

You can download this validation from Github.

Let’s walk through this:

  1. We first obtain the data we need from the EC2.SecurityGroup. Since the type definition says ingress is an optional field, we use the empty list as the default.
  2. We define an auxiliary function to determine if an IP permission target contains the open CIDR block. This is similar to the auxiliary validations we saw before, except that we return a Bool now. Instead of defining this function at the top level of the file and making it globally available, we’ve made this a local function (just like a local variable) using let. We only need it to be within scope of the cis_4_1 function.
  3. Now, we can write another auxiliary validation function to check a single EC2.IpPermission. In here, we also start by obtaining the data we need, and using a more complicated when parameter. Let’s take a closer look:
when:
  (fromPort < 1 || (fromPort <= 22 && toPort >= 22)) &&
  containsOpenTarget(ig.(EC2.IpPermission).target)

fromPort is a local variable defined a few lines earlier that represents the beginning of an EC2.IpPermission port range. The AWS API considers a “-1” value to mean all ports, which is inclusive of port 22. So if the IP permission port range includes all ports, the statement fromPort < 1 evaluates to True.

(fromPort <= 22 && toPort >= 22) evaluates True if port 22 is between the fromPort and toPort values (or equal to either). For example, if fromPort is 20 and toPort is 30, that means port 22 is in that range, and the statement evaluates to True.

If either of the above conditions is True, then the next step is determining if the IP permission target contains an open CIDR block, using the containsOpenTarget() function from step 2.

And if that condition is also True, then the entire when statement evaluates to True and triggers the Validation.error. Otherwise, the single ingress rule passes validation.

So, to sum it up in plain English: “Raise an error if an IP permission port range contains port 22 and the IP permission target contains an open CIDR block.”

If it helps, you can restate the when statement like so:

(portRangeIncludesAllPorts || portRangeIncludes22) &&
targetContainsOpenCidrBlock

4. Finally, we can use Validation.join to run our auxiliary validation on all ingress rules. To restate that in plain English: “For every IP permission in a security group, raise an error if the IP permission port range contains port 22 and the IP permission target contains an open CIDR block.” If any single rule triggers the Validation.error, then the whole composition fails compilation.

Next Steps

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