In a Terraform plan JSON, where do resources declared inside a module appear to a policy rule?
answer
- modules are expanded, not preserved
- one prefix per call in the address
- child_modules nest under planned_values
- resource rules need no module awareness
- the call itself lives in configuration
basics
~20 sThe plan is flattened. Every resource a module declares is expanded into the plan's resource lists under a module-qualified address such as module.network.aws_s3_bucket.flow_logs, so ordinary resource rules fire on module-created resources without knowing modules exist.
solid answer
~40 sTerraform expands modules before it writes the plan, so the plan holds resources, not opaque module objects. Under `planned_values` they nest one level per module call inside `child_modules`, each child carrying an `address` like `module.network`, and every resource address inside it is prefixed with that. In `resource_changes` the list is flat instead, and each entry additionally carries a `module_address`. The practical consequence is that a rule such as "no bucket may be publicly readable" needs no module awareness at all: it iterates resources by type and catches module-generated ones the same way it catches hand-written ones. What is *not* in the expanded output is the call itself — a module's `source` and version constraint live only in the plan's `configuration` section, so a rule about module provenance has to look somewhere else entirely.
code
json · 27 lines{
"planned_values": {
"root_module": {
"resources": [
{
"address": "aws_s3_bucket.app",
"mode": "managed",
"type": "aws_s3_bucket",
"name": "app"
}
],
"child_modules": [
{
"address": "module.network",
"resources": [
{
"address": "module.network.aws_s3_bucket.flow_logs",
"mode": "managed",
"type": "aws_s3_bucket",
"name": "flow_logs"
}
]
}
]
}
}
}go deeper
Be ready to say that the plan is flattened: module resources are expanded and appear in the plan's resource lists with a module-qualified address, so ordinary resource rules already cover them.
Explain the two shapes — the nested child_modules tree under planned_values versus the flat resource_changes list with its module_address field — and say which one you would iterate for a given rule and why.
Show that you know what the expansion costs you operationally: the denial names an address that exists in nobody's local file, and provenance of the call has been erased from the expanded view entirely.
Own the consequence for control design: because expansion is total, you can either inspect everything a module produces or trust modules by origin, and that choice decides who in the organisation is accountable for a bad resource.
### The plan is the assembled result, not the recipe When you write infrastructure as code, a module is a unit of authoring: a directory of resource declarations that a root configuration calls, passes inputs to, and reuses. But a module is not a unit of *planning*. Before Terraform can decide what to create, it resolves every call, substitutes the inputs, and expands the module's declarations into concrete resource instances. The plan document it then emits describes those instances. There is no node in the plan that says "here is a module, treated as a single thing, whose contents you cannot see". This matters enormously for policy gates, because the gate reads the plan document, not your source tree. ### Two views of the same expansion The machine-readable plan carries the expansion in more than one place, and they are shaped differently: - **`planned_values`** is a *tree*. `planned_values.root_module` has a `resources` array for everything the root declared directly, plus a `child_modules` array. Each child module object has an `address` (`module.network`) and its own `resources` array, and can itself contain further `child_modules` for modules it calls. Each resource inside carries a fully qualified `address` — `module.network.aws_s3_bucket.flow_logs` — along with its `type`, `name` and its `values`, the attributes as Terraform expects them to be after apply. - **`resource_changes`** is a *flat list*. Every resource instance in the whole configuration appears once, at the top level, with the same fully qualified `address` and, in addition, a `module_address` field naming the module it came from (empty for root-level resources). So the module structure is not lost — it is encoded in the address, and duplicated as a separate field in the flat list. A rule that wants to walk the tree can recurse `child_modules`; a rule that just wants every resource of a given type is better off iterating the flat list, where no recursion is needed. Module calls that use repetition are visible in the same addresses: a module called with a repetition argument expands to `module.subnets[0]`, `module.subnets[1]`, or with string keys `module.subnets["eu-west-1a"]`, and the resources beneath inherit that prefix. ### What this means for the rules you write The first, most reassuring consequence: **resource rules cover modules for free**. A team that adopts a shared networking module does not escape the encryption rule, the tagging rule or the public-access rule. The rule matches on resource type and attributes, and expanded resources have both. Anyone who tells you a gate must be extended before it can "see inside" modules has misunderstood the artifact. The second consequence is less comfortable: **the gate can no longer tell you which line a human wrote**. A denial that names `module.platform.module.data.aws_s3_bucket.raw` points at a resource declaration that exists in someone else's repository. The file the developer has open contains a `module "platform"` block and a handful of inputs, and nothing that looks like the address in the error. Deciding what to do about that is a genuine design problem for whoever operates the gate, and it starts with recognising that the address prefix is the only breadcrumb back to the call. The third consequence is the one people trip over when they first try to write a provenance control: **the expanded output has erased the call**. `module.network.aws_s3_bucket.flow_logs` tells you a call named `network` exists. It does not tell you where that module came from — which registry namespace, which repository, which version. Those live in the plan's `configuration` section, under the root module's `module_calls`, keyed by call name, alongside a nested `module` object describing what that module itself declares and calls. A rule that wants to require approved modules reads that section; a rule that wants to inspect what got built reads the expanded values. They are two different traversals over one document, and confusing them produces a rule that quietly matches nothing. ### A quick mental model Think of the plan as a bill of materials for the build about to happen. Every screw is listed, including the screws that came pre-assembled inside a sub-assembly — that is why your quality checks reach them. But the bill of materials does not say which supplier the sub-assembly came from or which catalogue revision it is; that is on the order form, filed separately.
- Given one resource entry, how does a rule tell a module-generated bucket from one written in the root configuration?By the address, or by the dedicated field. In the flat `resource_changes` list, a root-level resource has an empty `module_address` while a generated one names its module path. In the `planned_values` tree, anything reached by recursing into `child_modules` came from a call. Both are cheap tests, and either is enough to route a denial message differently for the two cases.
- A module is called once but with a repetition argument over three subnets. What do the addresses look like?The call expands into one child module per instance, and the index or key appears in the address: `module.subnets[0]`, `module.subnets[1]`, or `module.subnets["eu-west-1a"]` for keyed repetition. Resources inherit the whole prefix. A rule matching on resource type is unaffected, but any rule that string-matches an exact module address will miss every instance unless it accounts for the index.
- Does this mean a gate can never treat a module as a trusted, uninspected unit?Not never, but you have to choose it deliberately. The artifact gives you every expanded resource, so skipping them is an explicit decision in the rule — for example, ignoring resources whose module path starts with an approved prefix. That is a real strategy, and its cost is that you are trusting the module's contents on the strength of where it came from.
The plan is the assembled furniture, not the flat-pack box: you can inspect every screw, including the ones that arrived inside a pre-built drawer, but the box's supplier and catalogue number are written on a separate order form.
saying these in an interview costs you the question
- Claims the plan keeps modules as opaque black boxes
- Thinks resource rules must be rewritten to cover modules
- Looks for the module source among expanded resource values
- Assumes a resource address matches a file the developer wrote
- Ignores repetition indexes when matching module addresses