Terraform · Policy as Code · DevSecOps
Terraform policy as code: the problem with values known only after apply
A Terraform plan is full of values that do not exist yet. Here is why that breaks most policy checks, and how CEL partial evaluation gives you a third answer instead of a wrong one.
- Author
- Adrian Furo
- Published
- Reading time
- 8 min read
Every Terraform policy tool has to answer an awkward question: what do you do about a value that does not exist yet?
Run terraform plan against a resource that has not been created and large parts of the result are placeholders. The ID is not assigned. An IP that comes from another resource has not been allocated. A name built from a random_id has not been generated. Terraform is explicit about this in the plan JSON, and the CLI prints it as (known after apply).
That is fine when a human reads the plan. It is a problem when a policy engine reads it, because the engine has to return a verdict and it is missing some of the inputs.
I hit this repeatedly while building guardrails for delivery pipelines in regulated environments, which is what eventually led me to write Norn. This post is about that specific problem rather than the tool, because the problem outlives whichever tool you pick.
What the plan actually gives you#
Terraform records unknown values in a parallel structure called after_unknown. It mirrors the shape of after, with true at any path whose value is only known after apply.
Here are two network security rules from a real test fixture:
{
"address": "azurerm_network_security_rule.ssh_open",
"change": {
"actions": ["create"],
"after_unknown": { "id": true }
}
}{
"address": "azurerm_network_security_rule.from_lb",
"change": {
"actions": ["create"],
"after_unknown": { "id": true, "source_address_prefix": true }
}
}Both are creates. Both have an unknown id. The difference is that the second rule also takes its source_address_prefix from somewhere else in the graph, so that field is unknown too.
Now consider a policy that blocks SSH and RDP being reachable from the internet. It cares about access, direction, source_address_prefix and the destination ports.
For the first rule, every field the policy reads is known. The unknown id is irrelevant. The check can be decided.
For the second rule, the policy reads source_address_prefix, which is exactly the field that does not exist yet. The check genuinely cannot be decided.
Two resources that look similar, and the correct answer differs. Any tool that treats unknowns as a single global condition will get one of them wrong.
The three things tools usually do#
Skip the resource. Safe-looking and quietly dangerous. The rule above would be skipped for from_lb, so an actual internet-facing SSH rule can reach apply with a green check next to it. You have not enforced a policy, you have recorded an opinion about the resources that happened to be fully known.
Treat unknown as compliant. Worse, and more common than it should be. A missing key evaluates to null, null is not in your deny list, the expression returns true, and the plan passes.
Treat unknown as a failure. Defensible, and unusable in practice. Half of a greenfield plan is unknown on first create. Fail on every unknown and the pipeline is red constantly, people add exceptions to get work done, and six weeks later the exceptions are the policy.
None of these is really wrong about the resource. They are wrong about the question. The tool is being forced to return pass or fail when the honest answer is "I cannot tell yet".
Partial evaluation gives you a third answer#
The approach I settled on uses CEL, the expression language Kubernetes uses for ValidatingAdmissionPolicy. CEL supports partial evaluation: you can tell the evaluator that certain attributes are unknown, and it will resolve as much of the expression as it can before giving up.
The translation step walks the after_unknown tree and turns every true into a CEL attribute pattern:
// unknownPatterns turns Terraform's after_unknown tree into CEL attribute patterns.
//
// Terraform marks every value that will only be known after apply with `true`
// in after_unknown. Declaring those paths unknown lets CEL's partial evaluation
// decide what it can: `false && <unknown>` is still false, while a result that
// genuinely depends on an unknown value comes back as unknown instead of as a
// wrong pass or a confusing "no such key" error.
func unknownPatterns(root string, afterUnknown any) []*interpreter.AttributePattern {
var out []*interpreter.AttributePattern
var walk func(path []any, v any)
walk = func(path []any, v any) {
switch t := v.(type) {
case bool:
if t {
out = append(out, buildPattern(root, path))
}
case map[string]any:
for k, child := range t {
walk(append(slices.Clone(path), k), child)
}
case []any:
for i, child := range t {
walk(append(slices.Clone(path), int64(i)), child)
}
}
}
walk(nil, afterUnknown)
return out
}Those patterns go into cel.PartialVars, the program is compiled with cel.EvalOptions(cel.OptPartialEval), and the result is checked with types.IsUnknown before anything else.
Why short-circuiting does most of the work#
The part that makes this practical is boolean short-circuiting.
false && unknown is false. CEL knows this, so an expression can resolve even when some of its inputs are missing, as long as the known inputs already determine the outcome.
The SSH policy is a conjunction:
expression: |
!(
after.access == "Allow" &&
after.direction == "Inbound" &&
after.source_address_prefix in ["*", "0.0.0.0/0", "Internet", "Any"] &&
(
after.destination_port_range in ["*", "22", "3389"] ||
(after.destination_port_ranges != null &&
after.destination_port_ranges.exists(p, p in ["22", "3389"]))
)
)If a rule has direction: "Outbound", the second clause is false and the whole conjunction is false regardless of what source_address_prefix turns out to be. The policy passes, correctly, with no need to know the unknown field.
That matters more than it sounds. Most unknowns in a real plan are IDs, ARNs, generated names and computed endpoints that no policy reads. Declaring them unknown costs nothing, because the expressions never touch them. The unknowns that do land inside a policy are a small minority, and those are precisely the ones worth surfacing.
So the two rules from earlier come out as:
FAIL az-nsg-no-public-admin-ports [high/mandatory]
azurerm_network_security_rule.ssh_open (create)
SSH/RDP must not be reachable from the internet; use Bastion or a restricted source range.
UNKNOWN az-nsg-no-public-admin-ports [high/mandatory]
azurerm_network_security_rule.from_lb (create)
SSH/RDP must not be reachable from the internet; use Bastion or a restricted source range. (depends on values known only after apply)One verdict. One honest admission. Neither is a guess.
Deciding what UNKNOWN should cost you#
A third outcome is only useful if you can say what it means for the pipeline, and the right answer differs per policy. A tagging convention that cannot be checked is a shrug. An encryption requirement that cannot be checked on a production storage account is not.
Each policy sets its own behaviour with onUnknown: warn | fail | pass, defaulting to warn. That is separate from the enforcement level, which controls what a genuine failure does:
advisoryreports and never blocksoverridableblocks unless the run explicitly overrides that policy IDmandatoryalways blocks
Keeping the two axes separate matters. "This check is important" and "this check could not be evaluated" are different statements, and collapsing them into one setting is how you end up with the fail-on-everything pipeline that nobody trusts.
What this does not solve#
Being honest about the limits, since the whole point is not guessing.
Norn is early alpha and Azure-first today. The policy format, helpers and outputs may change before a stable v1. Port ranges like 20-25 are not expanded yet, so a rule written that way needs a range helper that does not exist. Cross-resource relationship lookups and a changed("attr") helper are on the roadmap rather than in the binary.
Partial evaluation also does not rescue a policy whose entire subject is unknown. If the field you are checking is computed, you get UNKNOWN, and the only real fixes are to restructure the configuration so the value is known at plan time, or to accept that this particular rule needs a post-apply check instead.
Plan-time policy and admission control are complements, not substitutes. Plan time catches things before anything is created, which is cheaper and earlier. Admission control catches what is actually running, including drift and anything applied outside your pipeline. Teams that pick one and declare the problem solved usually find the gap during an audit.
Where to start#
If you already run policy checks on Terraform plans, the useful exercise is not switching tools. It is finding out what your current setup does with unknowns. Take a plan that creates resources from scratch, look at what after_unknown contains, and check whether the fields your policies read appear in it.
If they do, and your tool still returned a clean pass, then you now know how much that pass was worth.
Norn is on GitHub under Apache-2.0. It runs as a single binary against terraform show -json output and emits SARIF 2.1.0, so findings land in GitHub code scanning alongside everything else.