Using Hashicorp Sentinel with Terraform modules

I’m using submodules in my terraform code. The problem that I’m running into is that in the config, which I am using to associate a subnet and the azurerm_subnet_route_table_association, is no longer referring to the subnet as it did in my tests which don’t use modules. What I’m getting instead is a “var.subnet_id”. I’m hoping there’s a simple solution that I’m just not seeing.

Section of mock in simple test:

"azurerm_subnet_route_table_association.association1": {
        "address": "azurerm_subnet_route_table_association.association1",
        "config": {
            "route_table_id": {
                "references": [
                    "azurerm_route_table.route_table1",
                ],
            },
            "subnet_id": {
                "references": [
                    "azurerm_subnet.subnet1",
                ],
            },
        },
        "count":               {},
        "depends_on":          [],
        "for_each":            {},
        "mode":                "managed",
        "module_address":      "",
        "name":                "association1",
        "provider_config_key": "azurerm",
        "provisioners":        [],
        "type":                "azurerm_subnet_route_table_association",
    },

Section of terraform:

resource "azurerm_subnet_route_table_association" "association1" {
  subnet_id      = azurerm_subnet.subnet1.id
  route_table_id = azurerm_route_table.route_table1.id
}

Section of mock with modules:

resources = 
...
"module.dmz_nsg.azurerm_subnet_network_security_group_association.gateway_nsg_to_subnet": {
        "address": "module.dmz_nsg.azurerm_subnet_network_security_group_association.gateway_nsg_to_subnet",
        "config": {
            "network_security_group_id": {
                "references": [
                    "azurerm_network_security_group.gateway_subnet_nsg",
                ],
            },
            "subnet_id": {
                "references": [
                    "var.subnet_id",
                ],
            },
        },
        "count":               {},
        "depends_on":          [],
        "for_each":            {},
        "mode":                "managed",
        "module_address":      "module.dmz_nsg",
        "name":                "gateway_nsg_to_subnet",
        "provider_config_key": "module.dmz_nsg:azurerm",
        "provisioners":        [],
        "type":                "azurerm_subnet_network_security_group_association",
    },
...
variables = 
...
"dmz_subnet_name": {
        "default":        null,
        "description":    "",
        "module_address": "",
        "name":           "dmz_subnet_name",
    },
...
"module.dmz_route_table:subnet_id": {
        "default":        null,
        "description":    "",
        "module_address": "module.dmz_route_table",
        "name":           "subnet_id",
    },
...
outputs = 
...
"module.dmz_subnet:subnet_id": {
        "depends_on":     [],
        "description":    "",
        "module_address": "module.dmz_subnet",
        "name":           "subnet_id",
        "sensitive":      false,
        "value": {
            "references": [
                "azurerm_subnet.subnet",
            ],
        },
    },
...
module_calls = 
...
"dmz_route_table": {
        "config": {
            "location": {
                "references": [
                    "var.location",
                ],
            },
            "rg_name": {
                "references": [
                    "var.rg_name",
                ],
            },
            "rt_name": {
                "references": [
                    "var.dmz_rt_name",
                ],
            },
            "standard_tags": {
                "references": [
                    "var.standard_tags",
                ],
            },
            "subnet_id": {
                "references": [
                    "module.dmz_subnet.subnet_id",
                ],
            },
        },
        "count": {},
        "depends_on": [
            "module.dmz_subnet",
        ],
        "for_each":           {},
        "module_address":     "",
        "name":               "dmz_route_table",
        "source":             "./modules/route_table",
        "version_constraint": "",
    },
    "dmz_subnet": {
        "config": {
            "prefixes": {
                "references": [
                    "var.dmz_prefixes",
                ],
            },
            "rg_name": {
                "references": [
                    "var.rg_name",
                ],
            },
            "subnet_name": {
                "references": [
                    "var.dmz_subnet_name",
                ],
            },
            "vnet_name": {
                "references": [
                    "module.hub_virtual_network.hub_vnet_name",
                ],
            },
        },
        "count": {},
        "depends_on": [
            "module.hub_virtual_network",
        ],
        "for_each":           {},
        "module_address":     "",
        "name":               "dmz_subnet",
        "source":             "./modules/subnet",
        "version_constraint": "",
    },

main.tf

...
module "dmz_subnet" {
    source              = "./modules/subnet"
    subnet_name         = var.dmz_subnet_name
    rg_name             = var.rg_name
    vnet_name           = module.hub_virtual_network.hub_vnet_name
    prefixes            = var.dmz_prefixes
    depends_on = [
        module.hub_virtual_network,
    ]
}
...
module "dmz_route_table" {
    source              = "./modules/route_table"
    location            = var.location
    rg_name             = var.rg_name
    rt_name             = var.dmz_rt_name
    subnet_id           = module.dmz_subnet.subnet_id
    standard_tags       = var.standard_tags
    depends_on = [
        module.dmz_subnet,
    ]
}
...

modules/subnet/main.tf

resource "azurerm_subnet" "subnet" {
    name                 = var.subnet_name
    resource_group_name  = var.rg_name
    virtual_network_name = var.vnet_name
    address_prefixes     = var.prefixes
}

modules/route_table/main.tf

resource "azurerm_route_table" "route_table" {
    name                = var.rt_name
    location            = var.location
    resource_group_name = var.rg_name
    tags                = var.standard_tags
}

resource "azurerm_subnet_route_table_association" "rt_association" {
    subnet_id      = var.subnet_id
    route_table_id = azurerm_route_table.route_table.id
}

Hi @wblanchard-concurren ,

You did not ask a question or provide a Sentinel policy, but I think your situtation is the following:

You have a Sentinel policy that worked correctly with the Sentinel CLI and maybe also in Terraform Cloud or Terraform Enterprise against a Terraform configuration in which the azurerm_subnet_route_table_association, azurerm_route_table, and azurerm_subnet resources were created in the root module. But when you create some or all of those resources in non-root modules in which the subnet ID is passed to a module that creates the route table and route table association, the policy no longer works. And the reason the policy no longer works is that the tfconfig/v2 mock in the latter case now has “var.subnet_id” under subnet_id.references instead of “azurerm_subnet.subnet1”. In other words, it refers to a variable passed to the module instead of to the actual subnet ID of the azurerm_subnet resource the variable is set to. I think you are implicitly asking how you can write your Sentinel policy so that it will work with the second type of configuration in which nested non-root modules are used and references to variables are given instead of references directly to resource attributes.

Please let us know if that above summary is correct.

Assuming it is correct, I will explain why the tfconfig/v2 mock looks different when you use modules. It is because your Terraform code within the dmz_route_table module refers to “var.subnet_id” in the azurerm_subnet_route_table_association resource that has label “rt_association”. That is a reference to the subnet_id variable passed in the module call to the dmz_route_table module from your root module. When everything was in a single root module, your Terraform code instead referred directly to “azurerm_subnet.subnet1.id”.

The tfconfig/v2 import gives Sentinel and you a representation of the Terraform code, but it is not attempt to resolve the values of references within the code. So, since your two use cases use different code (one referring to a variable passed into a module and one referring directly to a resource attribute), the tfconfig/v2 mocks naturally give different expressions. In contrast, the tfplan/v2 import gives you the values of various expressions if they can be determined from the plan and the current state. (Note that computed (“known after apply”) values cannot be determined by the tfplan/v2 import.

So, now the question is: how can you write your Sentinel policy to deal with this difference in behavior?

Unfortunately, I cannot answer that question at this time without seeing your policy and knowing what you are actually trying to do with Sentinel. So, I suggest you respond by providing your entire policy and explaining (if comments within it do not already do so) what your ultimate objective is.

The answer to the above question might be to add some code that will cross-reference the subnet_id variable of the dmz_route_table against the dmz_route_table module_call in the tfconfig/v2 import which does show that subnet_id variable in that module call is set to “module.dmz_subnet.subnet_id”. However, depending on what your policy does in the case of a single module, you might then need to cross-reference this against the output module.dmz_subnet.subnet_id in the tfplan/v2 import.

However, it is also possible (depending on your ultimate objective) that your policy might be better written using only the tfplan/v2 import.

Unfortunately, working with the tfconfig/v2 import can be complex because of the many ways in which one piece of Terraforn code can refer to other constructs within that code including variables and resource attributes. Wherever possible, it is better to just use the tfplan/v2 import instead of the tfconfig/v2 import. However, using the latter is sometimes required. And it might be required to meet your objective. I’ll be better able to answer that after seeing your policy and your explanation of your objective.

Roger Berlind
Global Technology Specialist

Hey @rberlind,

I was able to get it to work following your example and using your tfconfig-functions find_variables_in_modules function. Thank you for all your assistance.

Glad to hear that I was able to help,
Roger