How would you gate a Terraform plan so every module call uses an approved source at a pinned version?
answer
- provenance is not in the expanded output
- configuration section, not planned values
- allowlist the source by prefix
- exact version, not a floating range
- nested calls the caller cannot change
basics
~20 sRead the plan's configuration section rather than its expanded resources: walk each entry under the root module's module_calls, match the source string against an allowlist the platform team owns, and require an exact version rather than a floating range or a mutable reference.
solid answer
~50 sProvenance is not answerable from expanded resources — expansion erases the call — so the rule traverses the plan's `configuration` section, where `module_calls` keeps each call's `source` and its version constraint as written. Two predicates do the work: the source must match an approved namespace or repository prefix the platform team publishes, and a version must be present and exact, not a range like `>= 2.0` and not a branch or a moving reference, because both can resolve to different code tomorrow. Then decide the depth you enforce at. Each entry also carries a nested `module` object describing the calls that module itself makes, so you can see the whole tree, but a caller cannot change what an approved module calls internally — usually you enforce on the root's own calls and hold module authors to the same rule in their own repositories.
go deeper
Know that the module's origin and version are recorded in the plan's configuration section, not among the resources a module expands into, and that a rule reading the wrong section quietly finds nothing.
Be ready to describe both predicates concretely: prefix-matching the source against an approved namespace, and requiring an exact immutable version rather than a range or a branch, plus how you treat local paths that have no version.
Show judgment about depth and placement — enforcing on the root's own calls where the developer can act, and holding module authors to the same rule in their own release pipeline instead of blocking a caller for code they do not own.
Own the limits: this control moves review effort from thousands of resources onto a registry your team must staff, and it is only as strong as your module review and the speed at which fixed versions reach the callers still pinned to old ones.
### The control, stated plainly "Only approved modules, at a pinned version." It is a provenance control, not a configuration control: it says nothing about whether a bucket is encrypted, only about where the code that creates the bucket came from and whether you can name exactly which revision of it ran. Organisations adopt it because it converts an unbounded review problem — every resource every team writes — into a bounded one: review the modules, then require that everything comes through them. ### Why the expanded view cannot answer it A plan flattens modules into concrete resources addressed like `module.network.aws_s3_bucket.flow_logs`. That address proves a call named `network` exists. It does not say the module came from your internal registry rather than a stranger's repository, and it certainly does not carry a version. Any attempt to write this rule over the planned resource list fails silently: the fields it needs are not there, so the rule matches nothing and reports green. A gate that passes because it is looking in the wrong place is worse than no gate, because someone will cite it in a review. ### Where to look instead The plan's `configuration` section preserves the configuration as written, before expansion. Under the root module it carries `module_calls`, an object keyed by the call name. Each entry records the module's `source`, the version constraint when the call sets one, the expressions passed as inputs, and a nested `module` object holding that module's own resources and its own `module_calls` — so the entire call tree is reachable from the top. ### The two predicates **Approved source.** Maintain an allowlist, and match the source string against it by prefix, not by exact equality — an internal registry namespace, or the organisation's repository host and path. Prefix matching is the point that gets botched: a substring test lets a lookalike host pass, and an exact-match list needs editing for every new module. Decide explicitly what a local relative path means. A call into a directory inside the same repository has no version at all, so a naive pinning check either crashes on the missing field or waves it through; treat local paths as their own case and rule on them deliberately — often permitted, because the code is in the same reviewed repository as the call. **Pinned version.** The interesting judgment is what "pinned" means. A constraint that permits a range means today's plan and next month's plan can run different code from the same unchanged file — the gate approved something it cannot name. So require an exact version, and treat a branch name, a moving tag, or a default-branch reference as unpinned even though it looks specific. What you are really asking for is immutability: given the call, can I fetch exactly the code that ran? This is also what makes an audit trail possible later — the same reason a control that says "a version is set" is weaker than it sounds. ### Depth: whose calls do you rule on? Because the nested `module` objects expose calls made by modules further down, you can enforce provenance at any depth. You usually should not enforce it at every depth in the caller's pipeline. If an approved module internally calls something outside the allowlist, the developer whose plan you just blocked cannot fix it — they do not own that code. The workable split is: enforce on the root configuration's own calls in the application team's gate, and enforce the same rule on module repositories' own calls in the module release pipeline, where the person who can fix it is the person who gets the failure. Same rule, two places, addressed to whoever can act. ### What the control proves, and what it does not It proves you know which artifact ran. It does not prove the module is safe, secure or correct — that is what module review, and the resource-level rules that still run over the expanded output, are for. Nor does a green result prove that the approved module was used *for everything*: a team can call approved modules and still declare raw resources beside them, so provenance is a complement to resource rules, never a replacement. The strength of the control is entirely a function of how well the allowlisted modules are reviewed and how quickly a fixed version reaches the callers pinned to the old one. ### Practical notes Because `source` and version constraints are static configuration, they are known at plan time and never appear as values resolved only during apply — this is one of the few rules on an infrastructure plan that never has to reason about missing data. And the rule should identify the offending call by its name and source in the denial, since the call name is what the developer can find in their own file.
- Why evaluate the plan for this rather than parsing the configuration files directly?Either can work, since a module source has to be a literal string in the code. The plan is usually preferred because it is the artifact the rest of the gate already reads, it exposes calls made by modules deeper in the tree in the same document, and it is produced by the tool itself rather than by a second parser that can disagree with what actually ran.
- A team pins a module to a Git branch instead of a release. Does that satisfy a pinning check?No. A branch is a moving pointer: the same unchanged call resolves to different code after the next merge, so you cannot say which code your gate approved. Require an immutable reference — a release version or a specific commit — and treat branch and default-branch references as unpinned in the rule, not as a warning.
- What does an approved-source-and-pinned-version pass fail to tell you?That anything is safe. It tells you which artifact ran, nothing about what that artifact creates. Teams can also call approved modules and declare raw resources alongside them, so the plan can be fully compliant on provenance and still contain an unencrypted bucket. Keep the resource-level rules running over the expanded output as well.
saying these in an interview costs you the question
- Tries to read a module source from expanded resource attributes
- Accepts a version range as a pin
- Treats a branch or default-branch reference as pinned
- Assumes an approved module is therefore a safe module
- Substring-matches the source, letting a lookalike host pass
- Blocks the caller for an unapproved call inside someone else's module