Allowed Regions
Adds a validation block to the module's region variable, so a region outside your organization's allowlist is rejected during terraform validate or plan.
When to use this rule
Use this when: Your organization is limited to specific regions by contract, residency, or policy, and you want that limit enforced where Terraform is run rather than only at the account boundary.
Do not use this when: The module takes no region variable — it inherits the provider's region — and you have not set allow_unset. The rule cannot constrain a value the module never accepts.
Why this rule exists
Where infrastructure runs is a contractual question before it is a technical one. A customer contract, a data residency clause, or a regulator's ruling can limit an estate to named regions, and the usual enforcement point is a Service Control Policy — which lives in the AWS account, far from the person writing the Terraform. That distance is the problem: a developer discovers the constraint when an apply fails against a policy they cannot read, after the plan looked fine.
This rule moves the constraint into the module itself. The allowlist your organization configured travels with the module source, so a disallowed region is rejected on the developer's own machine with a message naming the regions they may use. The SCP remains the backstop it should be; the rule is what stops a wrong region reaching it.
Affected resources
| Resource | Service | Why |
|---|---|---|
aws_* | Any AWS resource | the rule constrains the module's region variable, not one resource type |
What this rule does
Adds a validation block to the module's region variable. This one is enforced on your side: the assertion ships inside the module source, so a value your organization has not allowed is rejected when you run Terraform, before anything is created.
Terraform checks a variable validation when it evaluates that variable, and where that happens depends on how the module is used. Consumed as a child module — the normal case for a module you pull by source — the check runs at terraform validate, whether the region comes from the parent's argument or from the module's own default. Run as the root module, terraform validate does not evaluate input variables at all: neither terraform.tfvars, nor -var, nor the default is checked there, and every one of them surfaces at terraform plan instead. A parent argument Terraform cannot resolve yet - one derived from another resource - is unknown at validate time and is checked when it becomes known. Either way the value never reaches apply, but a clean terraform validate on a root module is not proof that anything was checked. That split was measured on Terraform 1.15.2; Terraform has moved variable-validation evaluation before, so confirm it against the version you run before building a gate on it.
Existing HCL is never rewritten — the block is only added. Applying this to a module that already carries the identical validation changes nothing.
Before and after
Before (upstream module):
variable "region" {
type = string
default = null
}After (with Allowed Regions applied):
variable "region" {
type = string
default = null
validation {
condition = var.region == null ? false : contains(["eu-west-1", "eu-central-1"], var.region)
error_message = <<EOF
Variable "region" must be one of the regions allowed by your organization: ["eu-west-1", "eu-central-1"]. An empty or unknown value is rejected.
EOF
}
}That is the real output, indentation included — the message is a heredoc, not a quoted string, and the module's closing brace ends up indented. The region list is whatever your organization configured; the two above are an example.
The module's arguments and outputs are unchanged. What changes is which values it accepts: the added validation rejects a value outside the allowlist, including a null default that used to be usable. That is the point of the rule, but it does mean a module can stop accepting an input it accepted before.
Known limits
- Only the module's region variable. A resource that hardcodes a region inline, or a nested provider block naming one, is not reached.
- A CI stage that runs only
terraform validateis not a gate for this rule when the module under test is the root module. Root input variables are not evaluated at validate time, so a disallowed region passes that stage and fails later atplan. If you rely on this rule to stop a deployment, putterraform planin the pipeline. terraform validateis not a complete gate. Consumed as a child module it does check the region, but run as the root module it does not evaluate input variables at all, and the failure moves toterraform plan.- An empty allowlist enforces nothing. With no regions configured the rule injects nothing and is recorded as
not_configuredrather than enforced, because a build cannot tell an organization that wants nothing enforced from one that has not configured the rule yet. Rejecting an empty allowlist is config admission's job, not the build's. allow_unsetchanges two things, and the second is easy to miss. If the target variable is absent, nothing is injected at all - no region is checked, and the rule is recorded as waived. If the variable is present, the validation is injected, but the emitted condition then accepts anullregion alongside the allowed ones. Only in that second case is a wrong region still rejected.- Only
.tfand.tofufiles are read. Modules written in JSON syntax (.tf.json,.tofu.json) are left untouched. - Only the module's own directory and its first-level
modules/*submodules are processed. Modules that this module calls by source URL are separate downloads and are handled on their own request.
Compliance framework support
This rule is not a compliance control. It supports these framework objectives as an operational safeguard:
| Framework | Controls | Role |
|---|---|---|
| NIST SP 800-53 Rev 5 | SA-9(5) | Moves the approved-location constraint into the module source, so a disallowed location fails before provisioning rather than at the account boundary |
Default configuration
These are the values the rule ships with. They are not settable from a module source URL: ?rules= carries rule names only, so a per-request enable uses exactly the defaults below. Your organization sets them per rule, in its Baseline or in a ruleset, from the Operational Rules page; see Baseline rules and rulesets.
This rule does nothing until your organization configures it
Parameters never travel in the URL, so a per-request ?rules= enable of this rule runs with an empty allowlist — and an empty allowlist injects nothing and enforces nothing. The build still runs and still writes a manifest; what is missing is the validation block, and the manifest records the rule as not_configured rather than enforced. The rule acts only on the regions your organization has configured, and on an organization's host a ?rules= override is rejected outright rather than quietly ignored.
| Parameter | Type | Default | Description |
|---|---|---|---|
regions | list(string) | [] | Allowed AWS regions (enumerated, no globs). Empty = unconfigured: the rule injects nothing and enforces nothing |
allow_unset | bool | false | Accept a null region (provider-inherited). Unenforced by consent - it does not permit a wrong region |
variable_name | string | "region" | Name of the module variable carrying the region |
resource_types | list(string) | ["aws_*"] | Resource types this rule applies to. A module with none is not applicable |
How to enable
On a framework host, per request:
Add ?rules=variable_allowed_regions to your HTTPS module source. Use the full namespaced id. A bare variable_allowed_regions matches no rule: it is skipped server-side and your terraform init still succeeds, without the rule.
module "example" {
source = "https://soc2.compliance.tf/terraform-aws-modules/s3-bucket/aws?version=5.0.0&rules=variable_allowed_regions"
}On your organization's host:
Add the rule to the organization's Baseline, which applies to every module served from the bare organization form <alias>.compliance.tf/<namespace>/<name>/<provider>, or to a named ruleset that one module selects with ?ruleset=<name> on the HTTPS form of that address. A project environment (<alias>.compliance.tf/<project>/<env>/<module>) serves the rules frozen in its bound snapshot instead: there the rule goes into the config draft and reaches the environment by promotion. On both organization forms ?rules= is refused with a 400 — including the subtractive - prefix — rather than dropped, so you cannot mistake an ignored override for an applied one. Change the configuration, not the URL. See Baseline rules and rulesets for the configuration flow, and registry resolution for how the address forms differ.
How to check it worked
Read compliancetf-manifest.json in the module you received. Its entry for this rule carries an outcome: enforced means the validation was injected, not_configured means the allowlist was empty so nothing was injected, not_applicable means nothing matched - either no resource of a targeted type, or the module already carried the identical validation, and waived_by_consent means allow_unset accepted an absent target. Do not infer enforcement from a successful terraform init - a rule that enforces nothing also inits cleanly.
Failure modes
| Scenario | Result |
|---|---|
| A caller passes a region outside the allowlist | Consumed as a child module - the normal case - terraform validate fails with a message naming the regions your organization allows. If the module is your root module, validate does not evaluate input variables at all and the failure moves to terraform plan. Nothing is created either way. |
| The module's own default is a disallowed region | As a child module this is caught at terraform validate like any other value. As the root module it surfaces at terraform plan, because validate does not evaluate root input variables. The apply never runs. |
The module has no region variable and allow_unset is false | The rule errors rather than shipping a module it cannot constrain. |
The module has no region variable and allow_unset is true | No validation is injected, and the run is recorded as waived_by_consent rather than silently skipped. Your organization has consented to a provider-inherited region. |
| The module already carries an identical validation | No second block is added and the rule is recorded as not_applicable, because nothing matched that it still had to change. Applying the rule twice produces the same module. |
| The module already validates the same variable, but with a different condition | Both validations are kept - only an identical condition is treated as already applied - and Terraform requires a value to satisfy every one of them. If the upstream condition and your organization's allowlist have no region in common, the module accepts nothing and cannot be used until one of them changes. |
| A configured region value contains a Terraform interpolation | The rule errors and the build fails. Nothing is injected and the module is left unchanged; the guard runs before any HCL is rendered, so nothing is ever partially emitted. |
| The module contains no resource of a targeted type - a pure wrapper of other modules, say | The rule never reaches the module and is recorded as not_applicable. Nothing is injected and nothing is reported as wrong, so this is the case most easily mistaken for enforcement. |
Terraform and OpenTofu compatible
This rule works with both Terraform (1.x+) and OpenTofu (1.6+). It reads ordinary HCL, and anything it changes stays ordinary HCL, so the module behaves the same under either tool.
Help us improve this page
Operational Rules are a new feature. We'd love your feedback on this rule page — what's useful, what's missing, what's confusing. Share feedback.