Skip to main content

Deep-Dive: Hashicorp Sentinel with Terraform

·6586 words·31 mins
Hashicorp Sentinel Policy Terraform Github

Introduction
#

I sometimes play a board game called Talisman. I bought the game back in 2007 after watching an episode of The Big Bang Theory where they played that game. The game board consists of three distinct regions:

  • the outer region,
  • the middle region, and
  • the inner region.

Most characters begin the game in the outer region. The goal is to get into the inner region and reach the Crown of Command. To move from the outer region to the middle region there are a number of options available. One option is to fight the large stone Sentinel guarding the bridge between the two regions. The Sentinel in Talisman is what inspired the feature image for this post.

Is this post about Talisman? Not at all! I wanted to paint a picture of a sentinel being a guard that you have to pass to get to the other side. In the world of Terraform the outer region is the plan phase. The middle region is the apply phase. Of course the sentinel in Talisman corresponds to HashiCorp Sentinel, a collection of policies that that your infrastructure must respect.

This post is a deep-dive into HashiCorp Sentinel: what it is and how it works.

I recently wrote a guest blog post for Spacelift about Policy as Code:

[External] How to Enforce Policy as Code in Terraform (spacelift.io)
Spacelift.io Terraform Policy Sentinel Opa

Part of that blog post covered HashiCorp Sentinel. When I was doing research for that blog post I discovered that there is no1 content covering how to use HashiCorp Sentinel for Terraform outside of HCP Terraform and Terraform Enterprise (collectively referred to as HCP Terraform from now on). My main goal with this post is to cover how to use the Sentinel CLI for Terraform outside of HCP Terraform, for instance how to use it on your local system and in a GitHub Actions workflow. However, I will also briefly go through the integration with HCP Terraform.

By the way, I don’t think the guys in the Big Bang Theory actually knew anything about playing Talisman for real. It is clear from their jargon around it.

Before we move on, remember back in January when I spoke at HashiTalks 2024? I did include a tiny piece about HashiCorp Sentinel there as well when I talked about some things are not tests, some things are policies. I won’t talk about that in this post, but it is a good thing to keep in mind. See the full talk here:

If you are more into reading the HashiTalks 2024 presentation is also available as a blog post:

HashiTalks 2024: Mastering Terraform Testing, a layered approach to testing complex infrastructure
·6685 words·32 mins
Hashicorp Hashitalks Terraform Testing

The rest of this post will introduce HashiCorp Sentinel and explain how it works as a standalone tool and together with Terraform. The focus of this deep-dive is on getting up and running with Sentinel for common scenarios. However, this is not a deep-dive on writing the actual policies. I will present most of the syntax you will need for 90%2 of your policies, so you will get far using what you find in this post. There are great example policies to learn from on the internet. I recommend looking through the policies available in the following repository:

hashicorp/terraform-sentinel-policies

Example Sentinel Policies for use with Terraform Cloud and Terraform Enterprise

HCL
149
240

What is HashiCorp Sentinel?
#

Sentinel is a framework and language for policy as code. I’ve written extensively on what policy as code is in my guest blog post on spacelift.io, so I will not repeat all of it here. Instead, I am including an elevator pitch of policy as code in the context of Terraform and Sentinel:

Policy as Code is the codified rules around your Terraform infrastructure, such as restricting what resources types or what cloud regions your developers are allowed to use. Policies are written in the Sentinel language and they are evaluated using the Sentinel policy engine. Policies are enforced through a platform such as HCP Terraform, or through your own crafted continuous integration (CI) pipelines using the Sentinel CLI. The “as code” part refers to treating your policies like you do your application code; store it in version control, run unit tests, peer-review any changes, etc.

In the rest of this post we will see Sentinel in three different settings:

  • As a stand-alone CLI tool used to write and test policies on your local system. This is useful during development of Sentinel policies.
  • As a policy as code framework integrated with HCP Terraform. This integration simplifies applying policies at scale for your Terraform workspaces.
  • As a part of your continuous integration platform. This allows you to be in full control of how you run Sentinel for your Terraform configurations.

It would be impossible to cover all the details of the Sentinel language and framework in a single post, so I want to refer you to the official Sentinel documentation for all the details.

Stand-alone Sentinel
#

HashiCorp Sentinel comes in the form of a Command Line Interface (CLI) you can install and run locally. In this section we will learn everything we need to know to be able to use Sentinel on our local system, and we will be well prepared to run Sentinel in a CI pipeline.

Install the Sentinel CLI
#

You can find the available versions and binaries for Sentinel on releases.hashicorp.com/sentinel/. The binary is available for Linux, Mac, and Windows. Download the binary for your system, unzip the file, and move the Sentinel binary to a directory in your $PATH. At the time of writing the latest available version is 0.26.2.

Verify that the installation worked by printing the version number:

$ sentinel version
Sentinel v0.26.2
As with most DevOps and cloud tutorials out there I am using a Mac and I use a zsh environment. Chances are everything will work fine if you are on a different system, but the commands and output shown come from my Mac environment and they might look different for your environment.

To see all the available Sentinel CLI commands use the help flag (-h or --help):

$ sentinel -h
Usage: sentinel [--version] [--help] <command> [<args>]

Available commands are:
    apply      Execute a policy and output the result
    fmt        Format Sentinel policy to a canonical format
    test       Test policies
    version    Prints the Sentinel runtime version

We’ve already covered the version command. The fmt command is used to format your policy files into the canonical Sentinel format. It is good practice to do so, and I will implicitly be using it for policies described in this post but I will not show the command itself.

The other two commands, test and apply will be covered in the following sections.

Sentinel configuration file
#

Before we start working with the Sentinel CLI for real you should first create a Sentinel configuration file named sentinel.hcl. You can name the file whatever you want, and you can split your configuration up into multiple files. Sentinel will automatically pick up all the .hcl files in the current directory and merge them into a single configuration file. In a later section we’ll come back to how we could split the configuration file into logical pieces.

To start with, add the following content to sentinel.hcl:

policy "sample" {
  source            = "./sample.sentinel"
  enforcement_level = "advisory"
}

This configuration file defines a single policy named sample using the policy block. In the policy block we provide details on where the policy body is located using the source argument, and we configure an enforcement level using the enforcement_level argument. We’ll come back to what the enforcement_level argument is when we discuss Sentinel in HCP Terraform.

Create an empty file named sample.sentinel in the same directory as the sentinel.hcl file. We will soon write an actual policy in this file. If you want to add additional policies you should can do so by adding additional policy blocks in the configuration file.

Writing and evaluating policies
#

The Sentinel language has some resemblance to functional programming languages. It is declarative in nature.

A Sentinel policy must contain a rule named main. One of the simplest policies we can write is:

main = rule {
  true
}

The body of this rule (contained in the curly brackets) consists of a single statement: true. The overall policy result is the result of evaluating the main rule. In this case it is always evaluated to true.

To evaluate this policy using the Sentinel CLI run the sentinel apply command:

$ sentinel apply
Pass - sample.sentinel

Sentinel loads the policies defined in the configuration file sentinel.hcl and evaluates them. If you want to run a specific policy you could pass the policy file name in the command:

$ sentinel apply sample.sentinel
Pass - sample.sentinel

You can have any number of rules in your Sentinel policy. In general, you use other rules (I call them sub rules) to build up your main rule. An example of what that might look like:

this_must_be_true = rule {
  true
}

also_this = rule {
  true
}

main = rule {
  this_must_be_true and also_this
}

Splitting your policy up like this can increase the readability of your policy.

Your Sentinel policies would not make much of a difference if they only had static data to work with. Before we look at how to use Sentinel with Terraform, we’ll start with a simple JSON file named userdata.json:

{
    "users": [
        {
            "name": "jane",
            "role": "admin"
        },
        {
            "name": "john",
            "role": "reader"
        }
    ]
}

This file contains static data, but let’s imagine it is generated in some other process and is updated every now and then. Create a new directory named data and add userdata.json to it.

The JSON content of this file describes simple data for our users. Let’s write a policy that reads this data and uses it to evaluate a policy.

To be able to import JSON data into Sentinel you must configure the import in your Sentinel configuration file. Add the following block in sentinel.hcl:

import "static" "userdata" {
  source = "./data/userdata.json"
  format = "json"
}

This import block configures a static import of JSON data, there are other types of imports that we will see later on. Now we can write a policy that reads this JSON data and uses it to make policy decisions:

import "userdata"

param username

admin_users = filter userdata.users as user {
  user.role is "admin"
}

admin_names = map admin_users as admin { admin.name }

main = rule {
  username in admin_names
}

There are a few new things going on in this policy:

  • We import the content from userdata.json using the import "userdata" statement. The name of the import must correspond to the configured import in the configuration file.
  • We have a parameter named username. A value for this parameter must be provided to Sentinel during the execution.
  • There is a filter expression that filters data based on some condition. In this case the user data from the JSON file is filtered by looking for users who has the admin role.
  • The resulting admin_users array is used in a map expression to only keep the name property of the users. The end result is a string array named admin_names.
  • Finally, we check if the username provided as an input parameter is part of the list of admin names. If it is, the main rule evaluates to true, otherwise false.

I think the previous policy nicely illustrates the declarative and functional aspects of the Sentinel language.

We can run sentinel apply and add the -param flag to provide a value for the parameter named username:

$ sentinel apply -param "username=jane"
Pass - sample.sentinel

$ sentinel apply -param "username=john"
Fail - sample.sentinel

sample.sentinel:12:1 - Rule "main"
  Value:
    false

We see that the policy returns Pass when username=jane, as expected. Likewise, it returns Fail when username=john, this was also expected.

You could pass values for parameters using the configuration file as well:

policy "sample" {
  source            = "./policies/sample.sentinel"
  enforcement_level = "advisory"
  params = {
    username = "jane"
  }
}

For this policy example passing parameters in the Sentinel configuration file does not really make sense, but there are other use-cases where this is appropriate.

Wouldn’t it be great if we could test the policy using some other means than running a CLI command for each case?

Testing Sentinel policies
#

Instead of manually running tests for your policies you can set up automatic tests. This is unit testing for your policies.

Start by creating a new directory named test, and inside of that directory create another directory named sample:

$ mkdir -p test/sample

Sentinel looks for tests in the test directory, and each directory in the test directory must correspond to the name of a policy defined in your configuration file. Finally, inside of each test directory you will create one or more .hcl files that contains the test code. A sample directory structure for tests looks like this:

$ tree .
.
└── test
    ├── sample-policy-1
    │   ├── test-1.hcl
    │   └── test-2.hcl
    ├── sample-policy-2
    │   ├── test-1.hcl
    │   └── test-2.hcl
    └── sample-policy-3
        ├── test-1.hcl
        └── test-2.hcl

When we run tests we usually want to mock external data in order to be in control of it. For our policy we would like to mock the userdata.json data. Create a file named admin.hcl in the test/sample directory. Add the following content:

mock "userdata" {
  data = {
    users = [
      {
        name = "jane"
        role = "admin"
      }
      {
        name = "john"
        role = "reader"
      }
    ]
  }
}

param "username" {
  value = "jane"
}

Here we have used a mock block to mock the userdata import. The mock has a data argument where you provide the mocked data. In this case the mocked data is the content of userdata.json expressed with HCL syntax. We also have a param block providing a value for the parameter in the policy. A test in Sentinel is assumed to evaluate to a passing policy, thus we do not need to add anything else to the test in this case.

We can run the test using the sentinel test command:

$ sentinel test
PASS - sample.sentinel
  PASS - test/sample/admin.hcl
1 tests completed in 1.732291ms

What if we want a test that makes sure the policy fails when we provide a username that is not an admin? Create a new file named reader.hcl in the test/sample directory. Add the following content:

mock "userdata" {
  data = {
    users = [
      {
        name = "jane"
        role = "admin"
      }
      {
        name = "john"
        role = "reader"
      }
    ]
  }
}

param "username" {
  value = "john"
}

test {
  rules = {
    main = false
  }
}

The difference here is that we have a test block where we specifically say that we expect the main rule to evaluate to false. If we run the tests now we see:

$ sentinel test
PASS - sample.sentinel
  PASS - test/sample/admin.hcl
  PASS - test/sample/reader.hcl
1 tests completed in 2.107084ms

The output still says 1 tests completed. This is because all tests for a given policy count as one test.

Sentinel policies for Terraform
#

After covering the basics of Sentinel we are ready to apply Sentinel policies for our Terraform changes. We’ll remove all the dummy and sample data and policies we have been using so far. Set up a new directory with the following structure:

.
├── http-public-ingress.sentinel <--- empty policy file
├── main.tf                      <--- terraform configuration (see below) 
├── sentinel.hcl                 <--- empty configuration file
└── test
    └── http-public-ingress      <--- empty directory

3 directories, 3 files

We need a Terraform configuration to work with. To keep it light this is the full Terraform configuration we will use:

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "5.54.1"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_security_group" "web" {
  name        = "web"
  description = "Web server security group"

  tags = {
    Name  = "Web"
    Owner = "web-team"
  }
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id

  ip_protocol = "tcp"
  cidr_ipv4   = "0.0.0.0/0"
  from_port   = 80
  to_port     = 80
}

We have two resources, an AWS security group (aws_security_group) and a corresponding security group rule (aws_vpc_security_group_ingress_rule). A security group controls the network traffic that is allowed to pass in (ingress) or out (egress) of a network subnet in AWS. A security group can have zero to many rules3. Each rule specifies the protocol, the range of ports to open, and the source or destination of the traffic.

To successfully be able to follow along this example in your own environment you must have an AWS account and have AWS CLI credentials configured in your terminal. The details of how to set this up is beyond the scope of this post.

Sentinel can work with data in JSON format, this include your Terraform plan, state, and configuration. To simplify this process you can enable the Terraform feature for Sentinel. You do this in your configuration file. Add the following sentinel block to sentinel.hcl:

sentinel {
  features = {
    terraform = true
  }
}

With the Terraform feature enabled we can go ahead and configure imports specifically for Terraform. In this example we will only use the tfplan/v2 import, to work with our Terraform plan. Add the following import block to sentinel.hcl:

import "plugin" "tfplan/v2" {
  config = {
    plan_path = "./plan.json"
  }
}

We configure the import with the path to the Terraform plan file plan.json. The benefit of using the tfplan/v2 import instead of raw JSON is that Sentinel handles parsing the file and making it conveniently available as a series of collections.

To be able to run Sentinel policies for Terraform we must first create the plan.json file. If you remember, the Terraform plan file is in binary format, so we need to convert it to JSON first.

Start by initializing the Terraform configuration:

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching "5.54.1"...
- Installing hashicorp/aws v5.54.1...
- Installed hashicorp/aws v5.54.1 (signed by HashiCorp)

Terraform has been successfully initialized!

Next run a Terraform plan and save the plan as tfplan:

$ terraform plan -out=tfplan
... (output truncated)
Plan: 2 to add, 0 to change, 0 to destroy.

Finally, use the terraform show command to convert the binary plan to JSON content, and use jq to pretty-print the content to plan.json:

$ terraform show -json tfplan | jq > plan.json

I would like to write a policy to block AWS security groups from exposing port 80 (HTTP) to the public internet (0.0.0.0/0). This is exactly what the sample Terraform configuration is currently doing.

Taking a look at the Terraform plan file we see the following (among other things):

{
  "...": "...",
  "resource_changes": [
    "...",
    {
      "address": "aws_vpc_security_group_ingress_rule.http",
      "type": "aws_vpc_security_group_ingress_rule",
      "name": "http",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": [
          "create"
        ],
        "before": null,
        "after": {
          "cidr_ipv4": "0.0.0.0/0",
          "from_port": 80,
          "ip_protocol": "tcp",
          "to_port": 80
        }
      }
    }
  ]
}

The plan file is telling us that if we apply these resource_changes there will be a aws_vpc_security_group_ingress_rule that will expose port 80 for the cidr_ipv4 equal to 0.0.0.0/0.

The first step in our policy is to import tfplan/v2:

import "tfplan/v2" as tfplan

Here you see how you can rename the import. The Terraform plan is now available in tfplan.

Next we need to filter the proposed changes in the plan to only find the aws_vpc_security_group_ingress_rule resources:

ingress_rules = filter tfplan.resource_changes as _, rc {
	rc.type is "aws_vpc_security_group_ingress_rule" and
		rc.change.actions is ["create"]
}

One collection available in tfplan is resource_changes. This contains all the data in the corresponding part of the JSON file. The filter expression filter tfplan.resource_changes as _, rc { ... } means go through each resource change, ignore the key but keep the value in the variable rc, then apply the filter expressions in the curly brackets to each resource change. The filter expressions looks for aws_vpc_security_group_ingress_rule resource types that will be created.

We could add additional expressions in the filter above, or we could create a new expression to further filter resources:

public_http_ingress = filter ingress_rules as _, ingress_rule {
  ingress_rule.change.after.cidr_ipv4 is "0.0.0.0/0" and
  ingress_rule.change.after.from_port is 80 and
  ingress_rule.change.after.to_port is 80
}

If you are familiar with AWS security groups you know that you can specify a range of ports to open. The filter expression above would miss if port 80 was included as part of a range. One way to solve this is to change the previous filter expression to:

public_http_ingress = filter ingress_rules as _, ingress_rule {
	ingress_rule.change.after.cidr_ipv4 is "0.0.0.0/0" and
		80 in range(
			ingress_rule.change.after.from_port,
			ingress_rule.change.after.to_port + 1,
		)
}

Here I have used the range(x,y) function. I add 1 to the to_port because the range does not include the last element.

Next, we must include at least one rule, this is our main rule:

main = rule {
  length(public_http_ingress) is 0
}

This rule checks that there are no elements in the public_http_ingress array. An empty array means there was no resource matching our filter expressions, which is what we want.

The complete policy body now looks like this:

import "tfplan/v2" as tfplan

ingress_rules = filter tfplan.resource_changes as _, rc {
	rc.type is "aws_vpc_security_group_ingress_rule" and
		rc.change.actions is ["create"]
}

public_http_ingress = filter ingress_rules as _, ingress_rule {
	ingress_rule.change.after.cidr_ipv4 is "0.0.0.0/0" and
		80 in range(
			ingress_rule.change.after.from_port,
			ingress_rule.change.after.to_port + 1,
		)
}

main = rule {
	length(public_http_ingress) is 0
}

Add this Policy body to http-public-ingress.sentinel.

We are almost ready to apply this policy to our Terraform plan. First, add the following block to sentinel.hcl:

policy "http-public-ingress" {
  source            = "./http-public-ingress.sentinel"
  enforcement_level = "hard-mandatory"
}

Next run Sentinel:

$ sentinel apply
Fail - http-public-ingress.sentinel

http-public-ingress.sentinel:16:1 - Rule "main"
  Value:
    false

The policy fails, as we expected.

As any good developer, we would now like to add tests to our policy4. In the test/http-public-ingress directory, create a new file named good.hcl with the following content:

mock "tfplan/v2" {
  module {
    source = "mock-good-tfplan-v2.sentinel"
  }
}

This is a test file where we configure a mock block for the tfplan/v2 import. This mock block reads the data through a module block. The data is contained in a separate Sentinel file named mock-good-tfplan-v2.sentinel. Add this file in test/http-public-ingress with the following content:

resource_changes = {
    "aws_vpc_security_group_ingress_rule.https": {
        "type": "aws_vpc_security_group_ingress_rule",
        "change": {
            "actions": [
                "create",
            ],
            "after": {
                "cidr_ipv4": "0.0.0.0/0",
                "from_port": 443,
                "to_port": 443,
            },
        },
    },
}

This data is part of what a real plan looks like using the Terraform feature for Sentinel. It is stripped down to only include the data that we actually need to test the policy.

Similarly as for good.hcl, add a new file named bad.hcl with the following content:

mock "tfplan/v2" {
  module {
    source = "mock-bad-tfplan-v2.sentinel"
  }
}

test {
  rules = {
    main = false
  }
}

This is a test that should verify that the policy evaluation fails if port 80 is exposed to the public internet. And the mock data in mock-bad-tfplan-v2.sentinel in test/http-public-ingress:

resource_changes = {
    "aws_vpc_security_group_ingress_rule.http": {
        "type": "aws_vpc_security_group_ingress_rule",
        "change": {
            "actions": [
                "create",
            ],
            "after": {
                "cidr_ipv4": "0.0.0.0/0",
                "from_port": 0,
                "to_port": 1024,
            },
        },
    },
}

This data is expected to fail the policy because we have provided a port range of 0-1024.

We are now ready to run the tests:

$ sentinel test
PASS - http-public-ingress.sentinel
  PASS - test/http-public-ingress/bad.hcl
  PASS - test/http-public-ingress/good.hcl
1 tests completed in 1.737333ms

All our tests pass!

Writing the Sentinel mocks can be complicated. In HCP Terraform we can get Sentinel mock data generated for our configurations, which simplifies getting started. We might still need to modify the mock data in order to tune it for our tests.

We only have a single policy for our Terraform configuration so far, but the steps we have been through covers everything we need to know to expand our policy base. All that is left is to write more policies, but that is left as an exercise for the reader.

Refactoring the Sentinel configuration file
#

I mentioned that we can split the Sentinel configuration file into multiple files. This makes sense when the file is expanding in size. You could keep the sentinel block in the file named sentinel.hcl:

sentinel {
  features = {
    terraform = true
  }
}

You could keep all your import definitions in imports.hcl:

import "plugin" "tfplan/v2" {
  config = {
    plan_path = "./plan.json"
  }
}

import "plugin" "tfconfig/v2" {
  config = {
    plan = "./plan.json"
  }
}

import "plugin" "tfstate/v2" {
  config = {
    path = "./plan.json"
  }
}

And of course you could keep your policies in a separate file named policies.hcl:

policy "policy-1" {
  source            = "./policy-1.sentinel"
  enforcement_level = "advisory"
}

policy "policy-2" {
  source            = "./policy-2.sentinel"
  enforcement_level = "soft-mandatory"
}

policy "policy-3" {
  source            = "./policy-3.sentinel"
  enforcement_level = "hard-mandatory"
}

These files are automatically merged by Sentinel at apply time.

HCP Terraform and Sentinel
#

HCP Terraform provides a managed experience for Terraform, as well as for Sentinel.

If you are not a fan of Sentinel (despite making it this far in this post) you can also use Open Policy Agent (OPA) policies written in Rego in HCP Terraform.

There are some limitations when it comes to policies in HCP Terraform, this section will work with these limitations as it is probably the more common scenario. See Limitations in HCP Terraform for details.

Policy sets
#

The best way to manage policies in HCP Terraform is through policy sets. A policy set is a collection of logically grouped Sentinel policies. What is a logical group of policies? One example would be all policies that should apply to your production workloads.

If you are using Terraform to manage your HCP Terraform environment through the tfe provider, you can create a policy set with the tfe_policy_set resource type:

resource "tfe_policy_set" "production" {
  name                = "production-policies"
  description         = "Policies for production workloads"
  kind                = "sentinel"
  agent_enabled       = "true"
  policy_tool_version = "0.26.2"
  policy_ids          = [
    tfe_sentinel_policy.http_public_ingress.id,
    tfe_sentinel_policy.aws_vpcs.id,
  ]
  workspace_ids       = [
    tfe_workspace.app01.id,
    tfe_workspace.app02.id,
  ]
}

You give the policy set a name and a description to clearly state what it is for. The kind argument is set to sentinel, but it can also be set to opa for OPA policies. You can select the version of Sentinel to use, 0.26.2 in this example. You specify what policies should be included in the policy set using the policy_ids argument. We will see how policies are created in the next subsection. Finally, to apply the policy set to your workspaces you provide the workspace IDs in the workspace_ids argument.

Policies
#

You can also create the actual policies using the tfe provider for Terraform, and then connect them to the desired policy sets as we saw in the previous subsection. The policy body should be stored in a separate file for simplicity, but you could also provide the policy inline as a string. A policy is created using the tfe_policy resource type:

resource "tfe_policy" "http_public_ingress" {
  name         = "http-public-ingress"
  description  = "Policies for AWS security groups"
  kind         = "sentinel"
  policy       = file("${path.module}/policies/http-public-ingress.sentinel")
  enforce_mode = "hard-mandatory"
}

The policy is given a name and a description to indicate its purpose. The kind argument is set to sentinel, but as with policy sets it could also be set to opa for OPA policies. The policy is read from an external file. Finally, the policy is given an enforcement_mode of hard-mandatory. Earlier in this article I mentioned that we would get back to what the enforcement mode was, now is the time! There are three enforcement modes for Sentinel policies:

  • An advisory policy will only produce a warning if violated. It will not stop a terraform apply from happening. Use this enforcement mode for policies that you don’t want to enforce. This is useful if you are introducing new policies and would like to give your users some time to adjust their infrastructure before the policy is enforced.
  • A soft-mandatory policy will stop a subsequent terraform apply unless an exception has been configured for the policy. This means you can allow certain infrastructure to violate the policy. An example of this might be if you have a policy to restrict public access for AWS S3 buckets, but in some rare cases this is exactly what you want to do.
  • A hard-mandatory policy will stop a subsequent terraform apply and you can’t configure exceptions for it. This enforcement mode is for important policies that should not be violated no matter what. An example of this would be policies related to regulatory compliance, where violations could negatively impact your business.

Set up policies in HCP Terraform UI
#

You can also create policies and policy sets in the HCP Terraform UI.

In your organization settings, select policy sets to begin creating a policy set:

policy sets

You can create a policy set connected to a version control system (VCS) or not. See Limitations in HCP Terraform for details why you might not be able to do this. Select “No VCS Connection”. Next, configure the policy set details:

policy sets

Note the scope of policies settings, here you can configure if the policies should apply to all workspaces or just a few selected ones. You could also exclude certain workspaces.

policy sets

Create the policy set, then move on to policies in your organization settings:

policy sets

Click on Create a new policy and enter the basic details of the policy:

policy sets

Enter the policy body and specify that this policy should be part of the policy set we created earlier:

policy sets

With your policies created, either using the UI or using Terraform, what does it look like when you start a run in HCP Terraform? Your policies are applied directly after the plan and cost estimation phases have completed. This means the Sentinel policies have access to your plan file, your cost estimation details, as well as your Terraform state file and metadata for the current run. If there are violations to the policies the run will stop:

policy sets

That is the gist of using the Sentinel integration in HCP Terraform.

Limitations in HCP Terraform
#

Unfortunately there are some limitations when it comes to Sentinel policies in HCP Terraform. Unless you are using a plus tier account you will only be able to create a few policies for your organization. You can create up to five policies, but only a single policy can use the enforcement mode of soft-mandatory or hard-mandatory. The other four policies must use the advisory enforcement mode. Also, you will not be able to store your policies in a repository and connect them to a policy set in HCP Terraform.

Sentinel in CI
#

If you are not using HCP Terraform but would still like to use Sentinel in your CI workflows, you can utilize the Sentinel CLI. In this section we will look at how to do this in GitHub Actions. Since we know how to use the Sentinel CLI locally we basically already know how to use it in our CI.

Specifically, we will look at two different use-cases:

  1. Workflows for Sentinel, where we run tests for our Sentinel policies. These workflows are part of the CI of the Sentinel policies themselves.
  2. Workflows for Terraform with Sentinel, where the purpose is to run the standard CI workflow for Terraform but add Sentinel into the mix. This is where we verify that our infrastructure fulfills our policies.

Workflows for Sentinel
#

One part of the policy as code idea is to treat your policies as you would treat your application or infrastructure code. Thus, it can make sense to keep one or more separate repositories dedicated for our policies.

In this section we will look at a CI workflow for Sentinel in GitHub Actions. We will install the Sentinel CLI and we will trigger unit tests for our policies.

We will reuse some of the code we have gone through earlier. Your repository should initially look like this:

.
├── http-public-ingress.sentinel
├── sentinel.hcl
└── test
    └── http-public-ingress
        ├── bad.hcl
        ├── good.hcl
        ├── mock-bad-tfplan-v2.sentinel
        └── mock-good-tfplan-v2.sentinel

We have a single policy in http-public-ingress.sentinel, a Sentinel configuration file in sentinel.hcl, and we have two test files and two mock files for our policy.

With this in place, create a new directory named .github, and inside of that directory create another directory named workflows:

$ mkdir -p .github/workflows

Inside of the workflows directory create a GitHub Actions workflow file named ci-1.yaml with the following content:

name: CI for Sentinel policies (1)

on:
  pull_request:
    branches:
      - main

jobs:
  sentinel:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4
      - name: Run Sentinel tests
        uses: hashicorp/sentinel-github-actions@master
        with:
          stl_actions_version: 0.26.2
          stl_actions_subcommand: "test"
          stl_actions_working_dir: "."
          stl_actions_comment: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The steps of this workflow are fairly self-explanatory. We have used the hashicorp/sentinel-github-actions action that takes care of installing Sentinel for us and in this case also trigger the tests.

To read more about the available options for the HashiCorp GitHub action for Sentinel, see the HashiCorp repository:

hashicorp/sentinel-github-actions

Shell
14
7

If you don’t want to use the hashicorp/sentinel-github-actions action, and instead do all the steps yourself, then read on! Create another file in .github/workflows named ci-2.yaml with the following content:

name: CI for Sentinel policies (2)

on:
  pull_request:
    branches:
      - main

jobs:
  policy-as-code:
    runs-on: ubuntu-latest
    env:
      SENTINEL_VERSION: 0.26.2
    steps:
      - name: Check-out source code
        uses: actions/checkout@v4
      - name: Install Sentinel CLI
        run: |
          FILENAME="sentinel_${{ env.SENTINEL_VERSION }}_linux_amd64.zip"
          wget "https://releases.hashicorp.com/sentinel/${{ env.SENTINEL_VERSION }}/$FILENAME"
          unzip "$FILENAME" -d $HOME/bin
          chmod +x $HOME/bin/sentinel
          echo "$HOME/bin" >> $GITHUB_PATH          
      - name: Run Sentinel tests
        run: sentinel test

The difference is that we install the Sentinel CLI ourselves by downloading the binary and updating the path variable ($GITHUB_PATH), then we issue the sentinel test command.

The end result is the same for these two workflows.

What we have gained is verification that any changes to our policies passes our tests before we merge the changes into the main branch.

Workflows for Terraform with Sentinel
#

Another thing we want to do in GitHub is to run Sentinel policies for our Terraform infrastructure changes.

When we make changes to our Terraform configuration and open up a pull request into the main branch we want to run a Terraform plan operation and then evaluate our Sentinel policies against the Terraform plan output.

To follow along this example, use the sample Terraform configuration given earlier in this post. Your repository structure should look like the following:

.
├── main.tf
└── sentinel.hcl

Add the following content to the Sentinel configuration file:

sentinel {
  features = {
    terraform = true
  }
}

import "plugin" "tfplan/v2" {
  config = {
    plan_path = "./tfplan.json"
  }
}

policy "http_public_ingress" {
  source            = "git::https://github.com/mattias-fjellstrom/sentinel-github-actions/http-public-ingress.sentinel"
  enforcement_level = "hard-mandatory"
}

The configuration file contains the sentinel block where we enable the Terraform feature, then we configure an import for the Terraform plan file. Note that the Terraform plan file is expected to be named tfplan.json and be placed in the root directory. We configure a single policy named http_public_ingress. Note that we set the source argument for the policy to a GitHub link. This link refers to the same repository we set up in the previous subsection.

Create a new directory named .github, and inside of that directory create another directory named workflows:

$ mkdir -p .github/workflows

Create a file named ci.yaml in .github/workflows with the following content:

name: Terraform CI

on:
  pull_request:
    branches:
      - main

jobs:
  ci:
    runs-on: ubuntu-latest
    env:
      SENTINEL_VERSION: 0.26.2
    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4
      - name: Install Terraform CLI
        uses: hashicorp/setup-terraform@v3
      - name: Install Sentinel CLI
        run: |
          FILENAME="sentinel_${{ env.SENTINEL_VERSION }}_linux_amd64.zip"
          wget "https://releases.hashicorp.com/sentinel/${{ env.SENTINEL_VERSION }}/$FILENAME"
          unzip "$FILENAME" -d $HOME/bin
          chmod +x $HOME/bin/sentinel
          echo "$HOME/bin" >> $GITHUB_PATH          
      - name: Initialize Terraform
        run: terraform init
      - name: Generate Terraform plan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          terraform plan -out=tfplan
          terraform show -json tfplan | jq > tfplan.json          
      - name: Run Sentinel policies
        run: sentinel apply

The steps of this workflow are labeled and fairly self-explanatory. An important step is the generation of the Terraform plan file in JSON content. Remember that this file must be named exactly as what we specified for the import block in sentinel.hcl.

What about the enforcement level? When we run the Sentinel CLI outside of HCP Terraform, there is no automatic enforcement of the different levels for our policies. What we can do is to pass the -json flag to the sentinel apply command. The output we get for a failing policy then changes to the following:

{
  "duration": 1307,
  "error": null,
  "policies": [
    {
      "duration": 1,
      "error": null,
      "policy": {
        "enforcement_level": "hard-mandatory",
        "name": "http_public_ingress.sentinel"
      },
      "result": false,
      "trace": {
        "description": "",
        "error": null,
        "print": "",
        "result": false,
        "rules": {
          "main": {
            "desc": "",
            "ident": "main",
            "position": {
              "filename": "http_public_ingress.sentinel",
              "offset": 403,
              "line": 16,
              "column": 1
            },
            "value": false
          }
        }
      }
    }
  ],
  "result": false,
  "warnings": null
}

Compare this to the output without the -json flag:

Fail - http_public_ingress.sentinel

http_public_ingress.sentinel:16:1 - Rule "main"
  Value:
    false

We can parse the JSON output to draw conclusions on what we want to do with the result. It is not as straightforward how to handle this as it is in HCP Terraform where we just control everything using the enforcement levels. One cool thing you could do is to use the output JSON as input to another Sentinel policy.

Summary
#

In this post we have gone through what HashiCorp Sentinel is and how it works in some detail. I dare say it is the most comprehensive walkthrough of HashiCorp Sentinel available on the internet at the time of writing that not only focuses on HCP Terraform or Terraform Enterprise (outside the official documentation, of course!)

In summary, we have looked at the following:

  • Using the Sentinel CLI on our local system. We learned about the Sentinel language and about the Sentinel CLI and how we should structure our configuration files, test files, and policies.
  • How Sentinel is integrated with HCP Terraform. Here we saw that HCP Terraform simplifies the problem of applying Sentinel policies at scale for multiple workspaces. However, we also learned that there are some limitations that makes policies difficult to work with unless you have access to a plus tier HCP Terraform account.
  • How Sentinel can be used in a CI setting with GitHub Actions. Here we saw how we can build our own Sentinel setup, both for running tests while developing our Sentinel policies, as well as how to integrate Sentinel in our workflows for Terraform.

HashiCorp Sentinel is a good framework for policy as code. However, it has not gained as much popularity as the current frontrunner known as Open Policy Agent (OPA). OPA has some features that makes it easier to apply in various different contexts, but if Terraform is the context you are applying policy as code then you should consider Sentinel as a great alternative.

Personally I find the Sentinel language easier to read and write, but that is a matter of taste and experience.


  1. At least no content that I could find! I did not look through videos about Sentinel, I focused on blog content. ↩︎

  2. This is a guesstimate of the actual number, I belive you can get very far using the filter construct along with equality statements. ↩︎

  3. Currently the limit seems to be 60 ingress rules and 60 egress rules for both IPv4 and IPv6, so in total there could be 2*60*2=240 rules. ↩︎

  4. And as even better developers would have said: you should have started by adding tests, then write the policy. ↩︎

Mattias Fjellström
Author
Mattias Fjellström
Cloud architect consultant and an HashiCorp Ambassador