skip to content

A Terraform plan rule matching only the `create` action let a database retention downgrade ship. Why?

level: middleimportance: must knowfreq 62%

answer

  1. the filter failed, not the comparison
  2. actions is an array of verbs
  3. in-place edits have their own verb
  4. creates are not the only way
  5. skip no-op, read and delete

basics

~20 s

Lowering a backup window on an existing database is planned as an in-place edit, so change.actions is ["update"], not ["create"]. The filter never matched, so the rule never ran. Match when the actions array contains create or update.

solid answer

~40 s

`change.actions` is an array of verbs, and an in-place edit is `["update"]` — a filter looking for `create` simply never selects that entry, so the gate is green because it never looked. The fix is to fire when the array *contains* `create` or `update`; that also covers replacements, which are encoded as the pair `["delete", "create"]`, or `["create", "delete"]` under create-before-destroy ordering. Two things to get right while you are in there: test membership rather than comparing against a literal array, because the replacement pair's order is not fixed; and do not index `change.after` without knowing the action, since it is null for a `["delete"]` entry and a rule body that cannot resolve a value produces no result rather than a denial. Keep skipping `no-op` deliberately, so untouched resources stay out of scope.

code

json · 19 lines
json
{
  "resource_changes": [
    { "address": "aws_db_instance.orders",
      "type": "aws_db_instance",
      "change": {
        "actions": ["update"],
        "before": { "backup_retention_period": 30, "deletion_protection": true },
        "after":  { "backup_retention_period": 1,  "deletion_protection": true }
      } },
    { "address": "aws_db_instance.reporting",
      "type": "aws_db_instance",
      "change": {
        "actions": ["delete", "create"],
        "before": { "backup_retention_period": 14 },
        "after":  { "backup_retention_period": 14 },
        "replace_paths": [["engine_version"]]
      } }
  ]
}

go deeper

for a junior

Know that the plan records an action for each resource and that editing an existing resource is not the same action as creating one. Being able to name create, update, delete and no-op is enough at this level.

for a middle

Explain the full encoding, including that actions is an array and a replacement is a two-verb pair whose order depends on create-before-destroy. Be ready to write the membership test that covers creates, updates and both replacement orderings.

for a senior

Demonstrate the diagnosis order: did the filter select the resource, did the rule resolve a value, is the comparison right. Explain why an unresolved value yields silence rather than a denial, and add a fixture so the regression cannot recur unseen.

for a principal

Own the fact that gates fail open in this class of bug. Argue for testing rules against plans they must reject, and for treating a rule with no denial fixtures as unverified rather than working.

## The action vocabulary Every entry in a Terraform plan's `resource_changes` array has a `change.actions` field, and it is an **array of strings**, not a single verb. The values you will meet: | `actions` | meaning | |---|---| | `["no-op"]` | resource is managed but this run does nothing to it | | `["create"]` | resource does not exist yet and will be created | | `["read"]` | a data source that will be read during apply | | `["update"]` | attributes change in place; the resource keeps its identity | | `["delete"]` | resource is destroyed and not recreated | | `["delete", "create"]` | replacement: destroy the old object, then create the new one | | `["create", "delete"]` | replacement with create-before-destroy ordering | A rule that fires only when the array contains `create` therefore covers creations and both orderings of a replacement — and misses every in-place edit. That is exactly how a retention downgrade ships past a gate: lowering a backup window on an existing database does not destroy anything, so Terraform plans it as `["update"]`, the filter does not match, and the rule never runs. The gate is green because it never looked, not because the change was safe. ## The correct filter For a rule about the *end state* of anything this run touches, match when `actions` contains `create` **or** contains `update`. Equivalently: skip entries whose actions are exactly `["no-op"]`, `["read"]` or `["delete"]`, and evaluate the rest. Both formulations catch replacements, because a replacement pair always contains `create`. Two traps live in that sentence: **Do not pattern-match the array by equality.** A rule written against `["delete", "create"]` will not match `["create", "delete"]`, and which one you get depends on whether the resource has create-before-destroy lifecycle behaviour. Test for *membership* in the array, never for a specific array literal, and never assume a fixed length. **Do not index `change.after` without knowing the action.** For a `["delete"]` entry, `after` is `null`. Reaching into it for an attribute produces a missing value, and in most policy languages a rule body that cannot resolve a value does not evaluate to false — it produces no result at all, so the deny simply never fires. Undefined is not false. A rule that crashes is loud; a rule that quietly evaluates to nothing is the dangerous one, because it looks exactly like a pass. The same asymmetry applies to `before`, which is `null` on a create. ## Why `no-op` is skipped deliberately Skipping `no-op` is not laziness; it is the scoping decision that makes the gate survivable. The plan lists every managed resource, including ones nobody has touched in years. A rule that judges them all blocks an engineer for infrastructure that is not in their diff and not theirs to fix. Filtering on the action confines the gate to the blast radius of the change under review: new databases must be right, edited databases must be right, and the estate's existing debt is handled by some other mechanism rather than by ambushing whoever next edits a tag. ## Diagnosing this class of bug When a change that obviously violates a rule ships anyway, work backwards through three questions in order: 1. **Did the rule see the resource at all?** Print or assert the set of addresses the filter selected. Nine times out of ten the answer is "no", and the filter is why. 2. **If it saw it, did it read a value?** A missing attribute — wrong key name, wrong nesting, `after` being null — resolves to nothing, and the rule body silently produces no result. 3. **Only then, is the comparison itself wrong?** Threshold direction, unit mismatch, string versus number. Most policy authors start at step three and lose an afternoon there. Filters fail far more often than comparisons. ## Regression-proofing the fix Once you widen the filter to include `update`, write the case down as a fixture: a small plan document containing one `["update"]` entry that lowers the retention window, asserted to produce a denial. Action-filter regressions are invisible in production — the gate stays green — so the only thing that catches them is a test that feeds the rule a plan it is supposed to reject. ## What people get wrong - Assuming only a creation can introduce a violation. - Writing `actions == ["create"]` instead of testing membership. - Assuming a replacement is reported as an `update`. - Reading `change.after` for a resource being destroyed. - Assuming `no-op` entries are absent from the array, and so writing a rule that also fires on untouched infrastructure.

  • How is a replacement encoded, and why does that matter to your filter?
    As a two-element array — `["delete", "create"]` normally, or `["create", "delete"]` when the resource is configured to be created before the old one is destroyed. A rule that compares actions against one literal array misses the other ordering, so always test whether the array contains a verb rather than matching the array itself.
  • Why must a rule check the action before reading change.after?
    For a delete, `after` is null, so reaching into it for an attribute yields a missing value. In most policy languages a rule body that cannot resolve a value produces no result at all rather than false, so the deny silently never fires and the run looks like a pass. The same applies to `before` on a create.
  • Should the rule fire on a no-op entry?
    No, and that silence is deliberate. Untouched resources appear in the array with `actions: ["no-op"]`, and judging them would block an engineer for infrastructure that is not in their diff. Existing non-conforming resources are handled by a separate mechanism, not by ambushing whoever next edits a tag.
  • How would you stop this bug from coming back?
    Add a fixture: a small plan document with one `["update"]` entry that lowers the retention window, asserted to produce a denial. Action-filter regressions are invisible in production because the gate stays green, so the only thing that catches them is a test that feeds the rule a plan it must reject.

saying these in an interview costs you the question

  • Believes only a creation can introduce a violation
  • Compares actions against the literal array ["create"]
  • Assumes a replacement is reported as an update
  • Indexes change.after without checking for a delete
  • Thinks an unresolved value makes the rule evaluate to false
  • Assumes no-op entries are absent from resource_changes

context