skip to content

Your Sentinel rule blocks destroying resources tagged data-class=production — why does reading only the planned values miss them?

level: middleimportance: should knowfreq 54%

answer

  1. planned values describe what survives
  2. a delete has no after
  3. prior attributes live in before
  4. replace is delete plus create
  5. untouched resources are a state question

basics

~20 s

A resource being destroyed has no after values, and it is absent from the plan's planned end state entirely. Its tags live in the change's before values, taken from prior state — so the rule must read before, not after.

solid answer

~40 s

Planned values describe what the workspace will look like after apply. A resource being destroyed will not be there, so iterating them can never find it. The evidence is in `resource_changes`: filter on entries whose `actions` contain `"delete"`, then read `change.before`, which carries the resource's attributes as recorded in prior state — including the `data-class` tag. `change.after` is null for a pure delete, which is why an after-only rule reports zero violations and looks like it is working. Filtering on `"delete"` also catches a replace, since a replace is a delete and a create for the same address. And if the rule must also cover resources this run does not touch at all, that is a state question, not a plan question: only the state import lists them.

code

sentinel · 11 lines
sentinel
import "tfplan/v2" as tfplan

destroyed = filter tfplan.resource_changes as _, rc {
    rc.change.actions contains "delete"
}

main = rule {
    all destroyed as _, rc {
        rc.change.before.tags["data-class"] is not "production"
    }
}

go deeper

for a junior

Remember that a plan holds both a before and an after for each change, and that a deletion has only a before. That single fact explains most rules that appear to pass while catching nothing.

for a middle

Be ready to walk through resource_changes: the actions list, what before and after hold for each action, and why a replace shows up as a delete plus a create.

for a senior

Demonstrate that you verify a rule fires on a real destroy plan before trusting it, and that you can say whether the requirement is a constraint on changes or on standing inventory.

for a principal

Own the fragility: a control keyed on a classification tag is only as strong as the tag, so the removal of that tag needs its own guard or the whole control can be disarmed by a routine change.

## Why an "after"-shaped rule cannot see a destroy The rule is simple to state: nothing tagged `data-class = production` may be destroyed or replaced by a run. It is easy to write in a way that silently passes forever. ### Two views of the same plan A Terraform plan offers a policy two different shapes of data. `planned_values` is the **intended end state** — the resources that will exist once apply finishes, with their attribute values. It is convenient and it is the natural thing to iterate. It also, by construction, contains nothing about deletions: a resource being removed is precisely a resource that will not exist afterwards. `resource_changes` is the **diff**. Each entry names an address and carries a `change` object with: - `actions` — one of `["no-op"]`, `["create"]`, `["read"]`, `["update"]`, `["delete"]`, or a delete and a create together when the resource must be replaced; - `before` — the attributes as recorded in prior state; - `after` — the attributes the run intends to produce. For a create, `before` is null. For a pure delete, `after` is null. For an update or a replace, both are populated. So a rule that iterates `planned_values`, or that reads `change.after.tags`, is asking the plan a question the plan answers with silence. Zero violations is not evidence of compliance here; it is evidence the rule looked in the wrong place. ### Reading the prior tag The correct move is to filter `resource_changes` down to changes whose `actions` contain `"delete"`, and read the tag from `change.before`. Prior state is what tells you the resource was labelled production before anyone proposed removing it. Filtering on `"delete"` rather than on the exact list `["delete"]` matters. A replace is expressed as a delete plus a create for the same address; with `create_before_destroy` the two appear in the other order. Matching the exact single-element list quietly exempts every replace, which is often the more dangerous operation — a replaced database is a destroyed database with a new one standing where it was. ### Where undefined bites Attribute access on a resource that has no tags at all yields Sentinel's `undefined` rather than an empty map, and comparisons involving `undefined` are themselves `undefined` rather than false. A rule that indexes `change.before.tags["data-class"]` without a guard will behave unpredictably across a mixed plan. Guard the access — check the value `is not undefined` before comparing, or decide explicitly that an untagged resource is treated as unclassified and allowed. ### The part the plan cannot answer One more limit is worth internalising. `resource_changes` is scoped to what this run touches. If the requirement is "the workspace must never *contain* an unprotected production resource", including ones that no run has proposed changing for months, then the plan cannot answer at all and the rule has to read the state import instead. Deciding which of those two questions you are actually enforcing — a constraint on *changes* or a constraint on *inventory* — is the design decision hiding inside a one-line requirement. ### A related trap If a prior apply already stripped the `data-class` tag, prior state no longer carries it, and a rule keyed on that tag can no longer recognise the resource. That is why teams that gate on a classification tag usually also gate on its removal: the label is the thing the whole control rests on, so an unguarded change to the label quietly disarms every rule downstream of it.

  • How does this rule treat a resource that is being replaced rather than destroyed outright?
    A replace carries both a delete and a create in its `actions`, so a `contains "delete"` filter catches it. That is usually what you want: replacing a stateful resource destroys the original. If you deliberately want to permit replacement, match on the exact single-element delete list instead, and be explicit in the rule's message about why.
  • The resource is not part of this run at all, but you still want the rule to notice it. Where do you look?
    The state import. `resource_changes` is scoped to what the run touches, so an untouched resource has no entry. Reading state turns a constraint on changes into a constraint on inventory, which will also flag things nobody proposed touching — a different and usually noisier rule.
  • What happens if the resource has no tags map at all?
    The attribute access yields undefined rather than an empty map, and comparing undefined does not give you false. Guard the access explicitly and decide what an untagged resource means: treating "unclassified" as allowed is a defensible choice, but it should be a written decision rather than an accident of how the expression evaluates.

saying these in an interview costs you the question

  • Assumes every resource change has an after object
  • Iterates planned values, which omit destroyed resources
  • Treats a replace as an update rather than a delete
  • Reports zero violations as proof the rule works
  • Indexes a tags map without guarding for undefined

context