Sentinel policy examples
Enterprise Only
Sentinel requires Vault Enterprise license or an HCP Vault Plus tier cluster.
This tutorial assumes that you have some familiarity with Sentinel policies. If you are new to Sentinel, go through the Sentinel Policies tutorial first.
Personas
In an enterprise setting, developers, admins, or other members of the security team can author Sentinel endpoint governing policies (EGPs) like the examples in this tutorial to ensure that secrets written to Vault adhere to desired formats.
This tutorial is aimed primarily at developers or other personas who will author policies.
All secrets written by users who do not use a root token will be subject to the format validation rules enforced by EGPs.
Prerequisites
You need the following:
git required for cloning repositories used in the scenario.
Download Sentinel Simulator and add it to your PATH environment variable
Prepare your environment
Before you can follow the tutorial, take some time to prepare your environment.
Open a new terminal session and verify that you have a working
sentinel
binary.$ sentinel versionSentinel v0.22.1
If you encounter an error with either of these two steps, ensure that your
sentinel
binary matches your platform and operating system types, and that it resides in a directory named in the list output byecho $PATH
.Clone the tutorial repository.
$ git clone https://github.com/hashicorp-education/learn-vault-sentinel-examples.git
Change your working directory to
learn-vault-sentinel-examples
.$ cd learn-vault-sentinel-examples
The directory contains the following files and directories:
.├── README.md├── allow-azure-tenant.sentinel├── validate-aws-keys.sentinel├── validate-azure-credentials.sentinel├── validate-delete-version-after-setting.sentinel├── validate-state-codes.sentinel├── validate-zip-codes.sentinel└── test ├── allow-azure-tenant │ ├── fail-create-azure-auth-non-approved-tenant.hcl │ ├── fail-create-oidc-non-approved-tenant.hcl │ ├── fail-update-oidc-non-approved-tenant.hcl │ ├── pass-create-azure-auth-approved-tenant.hcl │ ├── pass-create-oidc-approved-tenant.hcl │ ├── pass-create-oidc-secureauth.hcl │ ├── pass-login.hcl │ ├── pass-read-oidc-config.hcl │ └── pass-update-oidc-approved-tenant.hcl ├── validate-aws-keys │ ├── fail-kv-v1.json │ ├── fail-kv-v2.json │ ├── pass-kv-v1.json │ └── pass-kv-v2.json ├── validate-azure-credentials │ ├── fail-kv-v1.json │ ├── fail-kv-v2.json │ ├── pass-kv-v1.json │ └── pass-kv-v2.json ├── validate-delete-version-after-setting │ ├── fail-2592000-seconds.json │ ├── fail-800-hours.json │ ├── pass-2591999-seconds.json │ └── pass-400-hours.json ├── validate-state-codes │ ├── fail-kv-v1.json │ ├── fail-kv-v2.json │ ├── pass-kv-v1.json │ └── pass-kv-v2.json └── validate-zip-codes ├── fail-kv-v1.json ├── fail-kv-v2.json ├── pass-kv-v1.json └── pass-kv-v2.json7 directories, 36 files
There are six example Sentinel policies with their test cases.
Sentinel policy file | Description |
---|---|
allow-azure-tenant.sentinel | Permit create and update operations for Azure related auth methods that specify an approved Azure tenant ID. The list of approved Azure tenants can be found in the approved_azure_tenants list within the policy. |
validate-aws-keys.sentinel | Checks to see if AWS IAM keys are present. |
validate-azure-credentials.sentinel | Checks whether any secret being written has the "tenant_id", "client_id", and "client_secret" keys used by Vault's Azure secrets engine and Azure auth method. |
validate-delete-version-after-setting.sentinel | Checks whether a write operation against the KV v2 secrets engine's <path>/metadata paths have their delete_version_after value set to less than 30 days (720 hours). |
validate-state-codes.sentinel | Check to see if state codes conform with the policy. |
validate-zip-codes.sentinel | Check to see if the write request comform with the zip code format specified by the policy. |
This tutorial reviews the validate-zip-codes.sentinel
and
validate-delete-version-after-setting.sentinel
Sentinel policies.
Validate ZIP code
When a client sends a secret to Vault, there is no way to validate that the provided value complies with the desired format. For example, the expected input value is a 5-digit numeric value, and you wish to reject values that are not.
Sentinel allows Vault Enterprise customers to solve this challenge by checking that keys with specified names adhere to the desired formats. Additionally, the EGPs can be restricted to only apply when a secret includes multiple related keys or occurs on specific Vault paths.
Review the first example EGP (validate-zip-codes.sentinel
). It uses a regular expression to test request data for specifically formatted fields like zipcode, zip_code, or zip-code, and ensures that their contents are correctly formatted for U.S. ZIP codes.
Clarification
This policy does not actually ensure that the request data represents a valid ZIP code for a specific location, only that the request data is formatted the same as a valid U.S. ZIP code.
Here are the contents of the policy; a detailed explanation of the code follows.
validate-zip-codes.sentinel
# Policy that requires keys of secrets with name "zipcode", "zip-code", or# "zip_code" to be a valid 5-digit U.S. zipcode# For use with Vault KV v1 and v2 secrets engines# Function that validates zip codesvalidate_zip_codes = func() { # Print some information about the request # Note that these messages will only be printed when the policy is violated print("Namespace path:", namespace.path) print("Request path:", request.path) print("Request data:", request.data) # Test for request.data for kv v1 # Test for "zipcode" key if "zipcode" in keys(request.data) { if request.data.zipcode not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } # Test for "zip_code" key if "zip_code" in keys(request.data) { if request.data.zip_code not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } # Test for "zip-code" key if "zip-code" in keys(request.data) { # Using request.data.zip-code gives error if request.data["zip-code"] not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } # Test for request.data.data for kv v2 # Test for "zipcode" key if "data" in keys(request.data) and "zipcode" in keys(request.data.data) { if request.data.data.zipcode not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } # Test for "zip_code" key if "data" in keys(request.data) and "zip_code" in keys(request.data.data) { if request.data.data.zip_code not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } # Test for "zip-code" key if "data" in keys(request.data) and "zip-code" in keys(request.data.data) { if request.data.datazip-code not matches "^[0-9]{5}$" { print("Invalid zip code") return false } } return true}# Main Rulezip_codes_validated = validate_zip_codes()main = rule { zip_codes_validated}
Tip
The policy code is generously commented throughout. You are encouraged to both read these comments for additional insight, but also to comment your own policy code to help others understand it later.
At line 6, the validate_zip_codes
function is defined; this function does all of the heavy lifting for this policy, and contains 6 individual conditionals defined as if statements. It returns either true or false depending on the outcomes of the 6 conditional tests within the function.
Lines 10 through 12 add some print statements for debugging purposes. They print any Enterprise Namespace value contained in the request along with the request path, and request data. It is important to understand how print
statements in Sentinel policies for Vault work. They are never invoked on successful policy evaluation; instead, they are invoked only when policy evaluation fails for debugging purposes.
The first of 3 conditionals that relate to the KV version 1 secrets engine begins on line 17. It uses an if
statement to check the request data zipcode
field. Using a regular expression (^[0-9]{5}$
), the field is tested against the U.S. ZIP code format to determine whether it contains 5 sequential numbers 0-9.
The next two conditionals beginning at lines 24 and 32 are variations of the first one, and perform essentially the same regular expression comparison, but against fields named zip_code
and zip-code
respectively.
The next 3 conditionals on lines 44 through 65 define the same checks as the first 3 except that they are for the KV version 2 secrets engine as evidenced by the request.data.data
field.
Line 73 defines the main
rule. It is the only rule defined in this policy; the output of validate_zip_codes
determines this rule evaluation, with true evaluating to a passing evaluation and false evaluating to a failing evaluation.
zip_codes_validated = validate_zip_codes()main = rule { zip_codes_validated}
Write tests for passing scenarios
The Sentinel test cases are available in the learn-vault-sentinel-examples/test
directory.
As a refresher on tests or to those curious about policy testing, the tests for validate-zip-codes are included here for your reference along with instructions on how to run them.
Here are the passing tests. They mock data under global and requires that the main
rule evaluate to true. First is the passing test for KV version 1 secrets engine.
/test/validate-zip-codes/success-kv1.json
{ "global": { "namespace": { "path": "" }, "request": { "path": "kv1/address", "data": { "zip-code": "12345" } } }, "test": { "main": true }}
This is the passing test for KV version 2 secrets engine.
/test/validate-zip-codes/success-kv2.json
{ "global": { "namespace": { "path": "" }, "request": { "path": "kv2/address", "data": { "data": { "zip_code": "77777" } } } }, "test": { "main": true }}
Write tests for failure scenarios
Here are the failing test examples. They mock incorrect data in the same way as the passing tests.
Here is the failing test for KV version 1 secrets engine.
/test/validate-zip-codes/fail-kv1.json
{ "global": { "namespace": { "path": "" }, "request": { "path": "kv1/address", "data": { "zip_code": "1234e" } } }, "test": { "main": false }}
Here is the failing test for KV version 2 secrets engine.
/test/validate-zip-codes/fail-kv2.json
{ "global": { "namespace": { "path": "" }, "request": { "path": "kv2/address", "data": { "data": { "zipcode": "123456" } } } }, "test": { "main": false }}
You can test the policies with the Sentinel Simulator. To test the included test cases against example policies, first change into the directory.
$ cd learn-vault-sentinel-examples
Execute a test on the validate-zip-codes policy.
$ sentinel test -run=zip
Successful output example:
PASS - validate-zip-codes.sentinel PASS - test/validate-zip-codes/fail-kv-v1.json PASS - test/validate-zip-codes/fail-kv-v2.json PASS - test/validate-zip-codes/pass-kv-v1.json PASS - test/validate-zip-codes/pass-kv-v2.json
For verbose output including things the policies print, add the -verbose
flag to these commands.
Set version deletion policy
The policy file is located at learn-vault-sentinel-exampless/validate-delete-version-after-setting.sentinel
.
Here are the contents of the policy; a detailed explanation of the code follows.
validate-delete-version-after-setting.sentinel
# Policy that validates that delete_version_after less than 30 days (720 hours)# This is intended to be used against paths of form <path>/metadata/<secret># for the KV v2 secrets engineimport "strings"parse_duration = func(duration) { # Process hours hours_segment = strings.split(duration, "h") if hours_segment[0] matches "^[0-9]+$" { # Hours were specified hours = int(hours_segment[0]) if length(hours_segment) == 1 { # Minutes and seconds were not specified minutes_segment = "" } else { # Minutes and/or seconds were specified minutes_segment = hours_segment[1] } } else { # Hours were not specified # hours_segment will then be list with single element # equal to original duration hours = 0 minutes_segment = hours_segment[0] } # Process minutes minutes_segment = strings.split(minutes_segment, "m") if minutes_segment[0] matches "^[0-9]+$" { # Minutes were specified minutes = int(minutes_segment[0]) if length(minutes_segment) == 1 { # Seconds were not specified seconds_segment = "" } else { # Seconds were specified seconds_segment = minutes_segment[1] } } else { # Minutes were not specified # minutes_segment will then be list with single element # equal to hours_segment minutes = 0 seconds_segment = minutes_segment[0] } # Process seconds seconds_segment = strings.split(seconds_segment, "s") if seconds_segment[0] matches "^[0-9]+$" { # Seconds were specified seconds = int(seconds_segment[0]) } else { # Seconds were not specified seconds = 0 } # Roll seconds into minutes if seconds >= 60 { minutes_from_seconds = seconds / 60 minutes = minutes + minutes_from_seconds seconds = seconds % 60 } # Roll minutes into hours if minutes >= 60 { hours_from_minutes = minutes / 60 hours = hours + hours_from_minutes minutes = minutes % 60 } print("hours:", hours) print("minutes:", minutes) print("seconds:", seconds) return {"hours": hours, "minutes": minutes, "seconds": seconds}}# Function that validates delete_version_after less than 30 daysvalidate_delete_version_after_setting = func() { # Print some information about the request # Note that these messages will only be printed when the policy is violated print("Namespace path:", namespace.path) print("Request path:", request.path) print("Request data:", request.data) # Only validate against paths of form <segment>/metadata/<segments> if request.path matches "^(.*)\\/metadata\\/(.*)$" and "delete_version_after" in keys(request.data) { parsed_duration = parse_duration(request.data.delete_version_after) hours = parsed_duration["hours"] #hours = int(strings.split(request.data.delete_version_after, "h")[0]) print("hours in validation:", hours) if hours >= 720 { print("Invalid value of delete_version_after") print("It must be under 720 hours") return false } } return true}# Main Ruledelete_version_after_setting_validated = validate_delete_version_after_setting()main = rule { delete_version_after_setting_validated}
This policy code is designed to enforce policy on KV version 2 secrets engine secret versions, which are associated with paths like <path>/metadata/<secret>
. It is considerably more complex than the validate-zip-codes example in that t uses an import, and two fairly complex functions.
Line 6 imports the strings
import to gain additional string handling functions.
The first of the two functions, parse_duration
is defined beginning on line 8. This is a utility function that accepts a string, duration
that holds the value of delete_version_after for the secret version path requested.
Lines 11 through 28 use a regular expression (^[0-9]+$
) to test the segments of the duration to process the hours portion.
Lines 31 through 48 use the same approach to process the minutes.
The seconds are processed on lines 51 through 58.
Seconds at or over a value of 60 are rolled to minutes between lines 61 and 65.
Likewise, minutes at or over a value of 60 are rolled to hours between lines 68 and 72.
Some debugging print statements appear on lines 74 through 76 to print the hours, minutes, and seconds values.
print("hours:", hours)print("minutes:", minutes)print("seconds:", seconds)
Finally on line 79 the hours, minutes, and seconds values are returned by the function.
return {"hours": hours, "minutes": minutes, "seconds": seconds}
The second function, validate_delete_version_after_setting
begins on line 84.
validate_delete_version_after_setting = func() { # Print some information about the request # Note that these messages will only be printed when the policy is violated print("Namespace path:", namespace.path) print("Request path:", request.path) print("Request data:", request.data) # Only validate against paths of form <segment>/metadata/<segments> if request.path matches "^(.*)\\/metadata\\/(.*)$" and "delete_version_after" in keys(request.data) { parsed_duration = parse_duration(request.data.delete_version_after) hours = parsed_duration["hours"] #hours = int(strings.split(request.data.delete_version_after, "h")[0]) print("hours in validation:", hours) if hours >= 720 { print("Invalid value of delete_version_after") print("It must be under 720 hours") return false } } return true}
It begins by using print statements on lines 88 through 90 to print debug information on policy evaluation failure about the Enterprise Namespace path, the request path, and request data.
At line 93, it tests the request data to ensure that the path contains /metadata/
and that there is also a delete_version_after
key present in the request data
.
On line 94, the parse_duration
function is called and the request data delete_version_after
key value is passed. The result is stored in the variable parsed_duration
.
The hours calculated by the parse_duration
function are stored in the hours
variable on line 95, and at line 98, the hours
variable is tested as to whether it is greater or equal to 720. If it is found to be greater than 720, two error lines are printed at lines 99 and 100, and the function returns false at line 101.
Otherwise, the function returns true at line 105.
A global variable, delete_version_after_setting_validated
is set to the return value of the validate_delete_version_after_setting
function at line 110.
Finally, the main
rule is defined on line 111, and contains either the value true
or false
based on the outcome of the validate_delete_version_after_setting function on 112.
Summary
In this tutorial, you learned how to develop advanced Sentinel EGPs that can be used in Vault Enterprise to validate the content of secret fields. In particular, you have explored the development of policies that validate zip codes, and versions of secrets in the Vault KV version 2 secrets engine.
You were also provided information on how to test one of the policies with the
Sentinel Simulator using test cases that were provided in the
learn-vault-sentinel-examples
repository.
The learn-vault-sentinel-examples
folder contains additional Sentinel examples
and their test files. Explore those policies to help guide you in writing more
complex policies with finer control over incoming data.
You can learn more about Sentinel with Vault by exploring the Sentinel HTTP Import tutorial, which covers using the response from an HTTP request to drive policy evaluation.