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:
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:
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:
Example Sentinel Policies for use with Terraform Cloud and Terraform Enterprise
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
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 theimport "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 theadmin
role. - The resulting
admin_users
array is used in amap
expression to only keep thename
property of the users. The end result is a string array namedadmin_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
, otherwisefalse
.
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:
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:
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.
Create the policy set, then move on to policies in your organization settings:
Click on Create a new policy and enter the basic details of the policy:
Enter the policy body and specify that this policy should be part of the policy set we created earlier:
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:
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:
- Workflows for Sentinel, where we run tests for our Sentinel policies. These workflows are part of the CI of the Sentinel policies themselves.
- 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:
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.
At least no content that I could find! I did not look through videos about Sentinel, I focused on blog content. ↩︎
This is a guesstimate of the actual number, I belive you can get very far using the
filter
construct along with equality statements. ↩︎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. ↩︎
And as even better developers would have said: you should have started by adding tests, then write the policy. ↩︎