Manages the full create/read/update/delete lifecycle of a Microsoft Entra ID Conditional Access named location β either an IP-range-based (
ipNamedLocation) or a country/region-based (countryNamedLocation) location definition (Microsoft Graph v1.0identity/conditionalAccess/namedLocationsendpoint) β via a singlemsgraph_resource.this, targetingmicrosoft/msgraph0.3.0.
- π Provisions a Conditional Access named location β a network-based (
ipNamedLocation) or geography-based (countryNamedLocation) rule set β viaPOST /identity/conditionalAccess/namedLocations, drift-detects and patches it thereafter, and deletes it onterraform destroy. - π§ One discriminator input,
location_type("ip"|"country"), gates the polymorphic split rather than two independent optional objects. This mirrors Graph's own@odata.typerequirement one-to-one β see π§ Architecture Notes for the full rationale (a settled design decision, not a judgment call left open at authoring time). - π The
@odata.typediscriminant is force-new, enforced with aterraform_data+lifecycle.replace_triggered_bymechanism keyed onlocation_typealone β flipping"ip"β"country"forces a clean plan-time replacement; every other change (adding an IP range, renaming the location, changingcountriesAndRegions) remains an ordinary in-placePATCH. - π
ipRangesis a full-replace field on update, not a merge. Graph's own "Update ipNamedlocation" reference states verbatim that retaining an existing range requires re-including it;main.tfre-renders the caller's complete list on every apply β never a partial diff. - ποΈ Delete-while-referenced is allowed, not blocked. Unlike
authentication-strength-policy(which Graph documents as delete-blocked while a Conditional Access policy references it), Graph documents no equivalent block for a named location β deleting one still referenced by a live policy succeeds and leaves a dangling id, surfacing later as a Graph error on the policy side. A 30-day soft-delete/restore window is the recovery safety net β see π Troubleshooting. - π Its
idoutput is the reference typeconditional-access-policy'sconditions.locations.includeLocations/excludeLocations(rendered from itsnamed_location_idsinput) consumes.
π‘ Why it matters: named locations are the network/geography building block every location-aware Conditional Access policy scopes against. Getting the polymorphic split, the force-new discriminant, and the full-replace
ipRangesbehavior right here is what keeps a caller from either corrupting an existing location's range list on an unrelated update, or hitting a confusing apply-time Graph rejection when trying to convert one subtype into another in place.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
This module has no upstream Consumes of its own β it's a foundational entity, like group or
service-principal. Its one real downstream consumer is conditional-access-policy, which takes this
module's id as an entry in its named_location_ids input
(confirmed against tf-mod-msgraph-conditional-access-policy/SCOPE.md's "Consumes" table: named_location_ids | list(string) | tf-mod-msgraph-named-location). As of this module's authoring,
tf-mod-msgraph-conditional-access-policy has a written SCOPE.md but no authored .tf files yet
(confirmed β its directory contains only SCOPE.md) β this is the one edge the
family DAG shows, by design, rather than inventing others.
flowchart LR
NL["tf-mod-msgraph-named-location<br/>(this module)"]:::thisModule
CAP["tf-mod-msgraph-conditional-access-policy<br/>(planned catalog entry β SCOPE.md written,<br/>not yet authored as.tf files)"]:::sibling
Graph["Microsoft Graph<br/>Entra ID tenant<br/>(POST/GET/PATCH/DELETE<br/>/identity/conditionalAccess/namedLocations)"]:::external
NL -->|"manages lifecycle of"| Graph
NL -->|"id consumed as named_location_ids entry"| CAP
classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
classDef sibling fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
classDef external fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Validated via the Mermaid Chart MCP before embedding (valid: true).
A single keystone resource plus one built-in-provider helper resource that implements force-new behavior β
no for_each children, no owned nested composite blocks.
flowchart TD
subgraph Inputs["Caller inputs"]
I1["display_name (required)"]:::neutral
I2["location_type: 'ip' | 'country' (discriminator, force-new)"]:::neutral
I3["ip_location (required when location_type = 'ip')"]:::neutral
I4["country_location (required when location_type = 'country')"]:::neutral
end
TDT["terraform_data.location_type_trigger<br/>(input = location_type)"]:::neutral
This["msgraph_resource.this<br/>url = identity/conditionalAccess/namedLocations"]:::thisModule
subgraph Outputs["Outputs"]
O1["id (primary)"]:::neutral
O2["display_name"]:::neutral
end
I1 -->|"rendered into body.displayName"| This
I2 -->|"rendered into body odata.type discriminant"| This
I2 -->|"tracked for replace_triggered_by"| TDT
I3 -->|"rendered into body.ipRanges / isTrusted when type = ip"| This
I4 -->|"rendered into body.countriesAndRegions / countryLookupMethod / includeUnknownCountriesAndRegions when type = country"| This
TDT -.->|"replace_triggered_by on odata.type change"| This
This -->|"msgraph_resource.this.id"| O1
This -.->|"echoed from var.display_name"| O2
classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
classDef neutral fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Validated via the Mermaid Chart MCP before embedding (valid: true).
Resource inventory
| Resource | Type | Role |
|---|---|---|
msgraph_resource.this |
msgraph_resource |
Keystone β full create/read/update/delete lifecycle of the Graph named location at url = "identity/conditionalAccess/namedLocations" |
terraform_data.location_type_trigger |
terraform_data (Terraform's own built-in provider) |
Tracks var.location_type alone so lifecycle.replace_triggered_by forces a clean plan-time replacement only when the immutable @odata.type discriminant actually changes β not on every update |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
microsoft/msgraph |
0.3.0, pinned exactly (pre-1.0 provider β no ~> constraint) |
| Graph API version | v1.0 (identity/conditionalAccess/namedLocations endpoint) |
| Provider block | None in this module β the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module |
Schema notes that bite
- The
@odata.typediscriminant is immutable β no documented conversion path betweenipNamedLocationandcountryNamedLocation. This module enforces that as a real force-new viaterraform_data.location_type_trigger+lifecycle.replace_triggered_by, keyed onlocation_typealone β changing the nestedip_location/country_locationcontents does not trigger this; only flippinglocation_typeitself does. ipRangesis a full-replace field, not a merge, on update. Graph's "Update ipNamedlocation" reference states verbatim: to retain an existingipRange, include it again alongside any new ones; to remove one, exclude it.main.tfalways renders the completevar.ip_location.ip_rangeslist β there is no partial add/remove path in this module or in Graph itself.- Each
ipRangeselement carries its own@odata.type(#microsoft.graph.iPv4CidrRange/#microsoft.graph.iPv6CidrRange) β not flattened into an untyped string list.range_type("IPv4"|"IPv6") drives which discriminant is rendered for that element. - A related type,
compliantNetworkNamedLocation, isbeta-only and explicitly out of scope. This module targets only the two v1.0 subtypes (ipNamedLocation,countryNamedLocation). - Deleting a named location that a live Conditional Access policy still references is not blocked by
Graph. This is a genuine asymmetry with
authentication-strength-policy(which Graph does document as delete-blocked while referenced) β see π Troubleshooting. - A location marked
is_trusted = truecannot be deleted until the trusted designation is removed first (a real, documented Graph restriction β the only documented delete restriction for this entity). - Neither Microsoft Learn reference page documents a maximum length for
displayNameon this entity (unlike, e.g.,group's documented 256-character cap) β this module enforces only non-empty; no invented upper bound.
| Operation | Application permission |
|---|---|
| Read | Policy.Read.All |
| Create / Update / Delete | Policy.Read.All AND Policy.ReadWrite.ConditionalAccess (both required together β confirmed against the live "Create namedLocation," "Update ipNamedlocation," and "Delete ipNamedLocation" Graph reference pages) |
All application permissions above require admin consent. Delegated access additionally requires the Conditional Access Administrator or Security Administrator directory role.
- Graph API version: v1.0 β no beta dependency for this module's two in-scope subtypes. (A related type,
compliantNetworkNamedLocation, isbeta-only and out of scope β see β Provider / Versions.) - License/SKU: Entra ID P1 (baseline Conditional Access licensing).
- Admin consent: required for
Policy.ReadWrite.ConditionalAccess.
tf-mod-msgraph-named-location/
βββ providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned exactly at 0.3.0;
β # no provider {} block
βββ variables.tf # 4 deeply-typed inputs mirroring the Graph v1.0 ipNamedLocation /
β # countryNamedLocation resource types (discriminator-input pattern)
βββ main.tf # msgraph_resource.this (keystone) + terraform_data.location_type_trigger
β # (force-new mechanism for the @odata.type discriminant)
βββ outputs.tf # id (primary), display_name
βββ README.md # this file
βββ examples/
βββ basic/
βββ main.tf # smallest real call β an ip-type named location with one IPv4 range
βΉοΈ This is a standalone module per this suite's SCOPE.md standard β its scope is lightweight and remains embedded in the scaffold rather than promoted to a separate
SCOPE.mdfile, because nothing in the authored.tffiles diverged from that embedded scope during initial authoring (the discriminator-input pattern, the delete-while-referenced finding, and the full-replaceipRangesbehavior all landed exactly as written there β see the scaffold's appended "Implementation confirmation" note).
terraform {
required_version = ">= 1.12.0"
required_providers {
msgraph = {
source = "microsoft/msgraph"
version = "0.3.0"
}
}
}
# Auth, tenant, and API version are configured here, by the caller β never inside this module.
provider "msgraph" {}
module "corporate_egress" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Corporate VPN Egress"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "203.0.113.0/24" }
]
}
}
output "corporate_egress_id" {
value = module.corporate_egress.id
}Consumes
| Input | Type | Source module |
|---|---|---|
| (none) | β | named-location is a foundational entity with no upstream Consumes of its own in this catalog. |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Graph object id (GUID) of the created named location β primary output | conditional-access-policy's named_location_ids input (planned catalog entry β not yet authored as of this README; see πΊοΈ Where this fits) |
display_name |
The location's displayName, as provided to this module |
Callers needing a human-readable label for logs or downstream naming |
1 Β· Minimal IP-based named location
module "office_ip_range" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Corporate Office β IPv4"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "198.51.100.0/24" }
]
}
}βΉοΈ
is_trustedis left at its Graph-documented default (false) β an untrusted, plain IP-range location.
2 Β· Minimal country-based named location
module "sanctioned_countries" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "High-Risk Countries"
location_type = "country"
country_location = {
countries_and_regions = ["KP", "IR"]
}
}βΉοΈ
country_lookup_methoddefaults to"clientIpAddress"andinclude_unknown_countries_and_regionsdefaults tofalseβ both Graph-documented defaults, encoded explicitly in this module's schema.
3 Β· Trusted IP location
module "trusted_hq_network" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Trusted HQ Network"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "192.0.2.0/24" }
]
is_trusted = true
}
}
β οΈ A location markedis_trusted = truecannot be deleted until the trusted designation is removed first β a real, documented Graph restriction (see π Troubleshooting). This is the only documented delete restriction for this entity.
4 Β· Multiple IPv4 ranges
module "branch_offices" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Branch Office Networks"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "203.0.113.0/24" },
{ range_type = "IPv4", cidr_address = "198.51.100.128/25" },
{ range_type = "IPv4", cidr_address = "192.0.2.64/26" },
]
}
}5 Β· Mixed IPv4 and IPv6 ranges
module "dual_stack_network" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Dual-Stack Corporate Network"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "12.34.221.11/22" },
{ range_type = "IPv6", cidr_address = "2001:0:9d38:90d6::/63" },
]
is_trusted = false
}
}βΉοΈ Each element carries its own
range_typediscriminant, mirroring Graph's per-element@odata.typerequirement (#microsoft.graph.iPv4CidrRange/#microsoft.graph.iPv6CidrRange) β this module never flattensipRangesinto an untyped string list.
6 Β· Country location including unknown countries/regions
module "geo_fence_with_unknowns" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Geo-Fence β Named Countries Plus Unresolved IPs"
location_type = "country"
country_location = {
countries_and_regions = ["US", "CA", "GB"]
include_unknown_countries_and_regions = true
}
}βΉοΈ
include_unknown_countries_and_regions = trueincludes IP addresses that Graph can't map to a specific country or region in this named location.
7 Β· Country location with an explicit lookup method
module "gps_based_geo_fence" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Geo-Fence β Authenticator App GPS"
location_type = "country"
country_location = {
countries_and_regions = ["US", "XK"]
country_lookup_method = "authenticatorAppGps"
}
}
β οΈ "authenticatorAppGps"is not yet supported in the Microsoft Cloud for US Government. This library is out of scope for national clouds per this suite's Region/Instance/Scope section β no cloud-conditional logic is added here; confirm applicability to your tenant's cloud before using this value.
8 Β· π Updating an existing IP location β the full-replace ipRanges gotcha
Start with a single range:
module "expanding_network" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Expanding Office Network"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "203.0.113.0/24" }
]
}
}Later, to add a second range, re-supply the complete list, including the original entry:
module "expanding_network" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Expanding Office Network"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "203.0.113.0/24" }, # must be repeated β omitting this removes it
{ range_type = "IPv4", cidr_address = "198.51.100.0/24" }, # the new range
]
}
}
β οΈ Graph's "Update ipNamedlocation" reference states verbatim that to retain an existingipRangeyou must add it again alongside any new ones; to remove one, exclude it. This module'smain.tfre-renders the entireip_rangeslist on every apply β omitting"203.0.113.0/24"from the second call above would remove it, not merge additively.
9 Β· A validation failure this module catches before any Graph call (mismatched discriminator)
module "broken_location" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Broken Example"
location_type = "ip"
country_location = { # fails β country_location must be null when location_type = "ip"
countries_and_regions = ["US"]
}
}
β οΈ This failsterraform plan(variable validations that reference other variables are evaluated once concrete values are known β see π§ͺ Testing), notapplyβcountry_location'svalidation {}block requires it to benullwheneverlocation_type != "country", andip_locationmust be set instead.
10 Β· A validation failure this module catches before any Graph call (malformed CIDR)
module "broken_cidr" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Broken CIDR Example"
location_type = "ip"
ip_location = {
ip_ranges = [
{ range_type = "IPv4", cidr_address = "not-an-ip-address" } # fails β not IPv4-CIDR-shaped
]
}
}
β οΈ ip_location'svalidation {}block requires everyIPv4-typedcidr_addressto match an IPv4-CIDR-shaped regex (e.g."1.2.3.4/32") β a typo like this is caught atplan, notapply.
11 Β· for_each at scale β creating multiple named locations from a map
variable "regional_offices" {
type = map(object({
display_name = string
cidr_ranges = list(string)
}))
}
module "office_locations" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
for_each = var.regional_offices
display_name = each.value.display_name
location_type = "ip"
ip_location = {
ip_ranges = [for cidr in each.value.cidr_ranges : { range_type = "IPv4", cidr_address = cidr }]
}
}
output "office_location_ids" {
value = { for k, m in module.office_locations : k => m.id }
}π‘ Keying by a stable map key (e.g. an internal office code) rather than
countmeans adding or removing one regional office fromvar.regional_officesnever re-indexes β and therefore never re-plans β every other named location in the map.
12 Β· Least-privilege permissions callout
# Root module β provider auth configured with exactly the two application permissions this module
# needs, together: Policy.Read.All AND Policy.ReadWrite.ConditionalAccess. Confirm the running
# principal's app registration is granted both, with admin consent, before applying.
provider "msgraph" {}
module "vpn_egress" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "VPN Egress Range"
location_type = "ip"
ip_location = {
ip_ranges = [{ range_type = "IPv4", cidr_address = "203.0.113.128/25" }]
}
}π See π Graph API Permissions Required β both
Policy.Read.AllandPolicy.ReadWrite.ConditionalAccessare required together for every write operation; neither alone is sufficient.
13 Β· Trusted vs. untrusted operational pattern
module "trusted_datacenter" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Trusted Datacenter Egress"
location_type = "ip"
ip_location = {
ip_ranges = [{ range_type = "IPv4", cidr_address = "192.0.2.0/24" }]
is_trusted = true
}
}
module "untrusted_guest_wifi" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Guest Wi-Fi (Untrusted)"
location_type = "ip"
ip_location = {
ip_ranges = [{ range_type = "IPv4", cidr_address = "198.51.100.0/24" }]
is_trusted = false
}
}βΉοΈ A Conditional Access policy commonly excludes MFA for
trustedlocations and requires it everywhere else β this module only manages the location'sisTrustedflag itself, not any policy logic that consumes it.
14 Β· Realistic combined call β dual-region country geo-fence
module "restricted_regions" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Restricted Sign-In Regions"
location_type = "country"
country_location = {
countries_and_regions = ["RU", "KP", "IR", "SY", "CU"]
country_lookup_method = "clientIpAddress"
include_unknown_countries_and_regions = true
}
}15 Β· ποΈ End-to-end composition β a named location's id feeding conditional-access-policy
tf-mod-msgraph-conditional-access-policy has a written SCOPE.md (confirmed: its Consumes table lists
named_location_ids | list(string) | tf-mod-msgraph-named-location) but no authored .tf files yet as
of this README β the module call below is illustrative of the intended contract, not a call against a
published module today. The commented-out block shows exactly how this module's downstream consumer is
meant to wire this module's id output once conditional-access-policy is built, per this catalog's
Consumes/Emits convention (see this suite's "Worked cross-module composition example").
module "untrusted_foreign_countries" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Untrusted Foreign Countries"
location_type = "country"
country_location = {
countries_and_regions = ["RU", "KP", "IR"]
}
}
module "corporate_vpn_range" {
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"
display_name = "Corporate VPN Egress"
location_type = "ip"
ip_location = {
ip_ranges = [{ range_type = "IPv4", cidr_address = "203.0.113.0/24" }]
is_trusted = true
}
}
# Illustrative only β tf-mod-msgraph-conditional-access-policy is a planned catalog entry with a
# written SCOPE.md, not yet authored as.tf files as of this README.
# module "block_risky_countries" {
# source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-conditional-access-policy.git?ref=v1.0.0"
#
# display_name = "Block Sign-In From Risky Countries"
# state = "enabled"
# named_location_ids = [module.untrusted_foreign_countries.id]
# #... conditions.users, conditions.applications, grantControls, etc.
# }
output "untrusted_foreign_countries_id" {
value = module.untrusted_foreign_countries.id
}
output "corporate_vpn_range_id" {
value = module.corporate_vpn_range.id
}π‘ Once
conditional-access-policyis authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without anydepends_on: the policy'snamed_location_idsinput takesmodule.untrusted_foreign_countries.idas a real output reference, so Terraform will not attempt to create the policy before the named location exists. If the named location is ever destroyed while a live policy still references it, Graph allows the delete (see π Troubleshooting) β remove the id from the consuming policy'snamed_location_idsfirst as safe operational practice.
Grouped summary
| Group | Variables |
|---|---|
| Required at create | display_name, location_type |
Subtype-specific (exactly one required, gated by location_type) |
ip_location, country_location |
Full variable reference (4 inputs)
| Variable | Type | Default | Validation | Graph property |
|---|---|---|---|---|
display_name |
string |
β (required) | Non-empty | displayName |
location_type |
string |
β (required) | One of "ip", "country" |
Drives @odata.type |
ip_location |
object({ ip_ranges = list(object({ range_type, cidr_address })), is_trusted = optional(bool, false) }) |
null |
Non-null iff location_type = "ip"; β₯1 ip_ranges entry; range_type one of "IPv4"/"IPv6"; cidr_address CIDR-shaped per range_type |
ipRanges, isTrusted |
country_location |
object({ countries_and_regions = list(string), country_lookup_method = optional(string, "clientIpAddress"), include_unknown_countries_and_regions = optional(bool, false) }) |
null |
Non-null iff location_type = "country"; β₯1 countries_and_regions entry, each a 2-letter code; country_lookup_method one of "clientIpAddress"/"authenticatorAppGps" |
countriesAndRegions, countryLookupMethod, includeUnknownCountriesAndRegions |
Deliberately excluded from this schema (read-only/computed on both ipNamedLocation and
countryNamedLocation β never settable inputs): id (surfaces only as msgraph_resource.this.id),
createdDateTime, modifiedDateTime.
| Output | Description | Sensitive / excluded? |
|---|---|---|
id |
Graph object id (GUID) of the created named location β primary output | No |
display_name |
The location's displayName, as provided to this module |
No |
No output is derived from a credential-bearing property β no property on either Graph resource type in scope here carries a secret value, so there is nothing secret-shaped to exclude at the output stage.
- The discriminator-input pattern is a settled design decision, not a judgment call. One
location_typevariable ("ip"|"country") gates which ofip_location/country_locationapplies, rather than two independent optional objects with a cross-object "exactly one is set" check. This mirrors the real Graph@odata.typediscriminant one-to-one, ties force-new behavior to a single variable'svalidation {}block, and makes the illegal state (both set, or neither set) harder to construct in the first place β this suite's "make the type the contract" principle applied to a polymorphic Graph type. bodyis always the same object shape on every apply β never a ternary between two differently-shaped object literals. Every leaf inmain.tf'sbodymap is acondition ? value: nullternary (the same null-conditional pattern already used throughout this catalog for optional properties), which sidesteps any reliance on Terraform's object-type-unification behavior for genuinely dissimilar object shapes.- The
@odata.typediscriminant is force-new viaterraform_data.location_type_trigger+lifecycle.replace_triggered_by, keyed onlocation_typealone. This is the same mechanismtf-mod-msgraph-app-role-assignmentuses for its own force-new fields βterraform_datais part of Terraform's own built-in provider (seeproviders.tf's header comment). Keying the trigger onlocation_typealone (not the wholeip_location/country_locationobject) is deliberate: ordinary in-place updates β adding an IP range, renaming the location, changingcountriesAndRegionsβ remain normalPATCHes and do not force replacement; only an actual subtype flip does. ipRangesis re-rendered in full on every apply. There is no partial add/remove path in this module or in Graph itself β see β Provider / Versions and π Example Library #8.- This module has no keystone-owned
for_eachchildren β it's a genuinely single-resource standalone module (plus theterraform_datahelper). The example library's scaling pattern (Example 11) is a caller-sidefor_eachover the module call itself, keyed by a stable map key, nevercount. - No eventual-consistency delay is documented specific to this entity β unlike, e.g., service principal creation immediately following application creation.
| Concern | Secure default in this module | Opt-out (caller must be explicit) |
|---|---|---|
IP location trust (isTrusted) |
ip_location.is_trusted defaults to false β a Graph-documented default this module preserves rather than overriding |
Caller sets is_trusted = true explicitly |
Country lookup method (countryLookupMethod) |
country_location.country_lookup_method defaults to "clientIpAddress" β Graph's own documented default |
Caller sets country_lookup_method = "authenticatorAppGps" explicitly (not yet supported in the Microsoft Cloud for US Government) |
Unknown-country inclusion (includeUnknownCountriesAndRegions) |
country_location.include_unknown_countries_and_regions defaults to false β Graph's own documented default |
Caller sets include_unknown_countries_and_regions = true explicitly |
| Polymorphic subtype selection | No implicit default β location_type is required with no default, forcing every caller to make an explicit, auditable choice between "ip" and "country" |
N/A β there is no permissive "either" default; the caller must choose |
| Sensitive outputs | No output is ever derived from a credential-bearing property (none exists on this entity) | N/A β not applicable to this entity |
terraform init -backend=false
terraform validate
terraform fmt -checkPin every real consumption of this module to an explicit tag, never a branch:
source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-named-location.git?ref=v1.0.0"terraform validate and terraform fmt -check prove the offline contract: location_type is restricted to
"ip"/"country", ip_location/country_location are shape-checked against their respective object
schemas, every ip_ranges[*].range_type is "IPv4"/"IPv6", every cidr_address is CIDR-shaped for its
declared type, and every countries_and_regions entry is a two-letter code β all before any Graph call is
made.
Cross-variable validations (the location_type β ip_location/country_location consistency checks) are
evaluated once concrete values are known β at plan, not necessarily at a bare terraform validate run with
no variable values supplied. This is standard, documented Terraform behavior (custom validation {}
blocks referencing other variables cannot be conclusively evaluated against unknown placeholder values),
not a defect in this module's schema β confirmed during authoring: a bare terraform validate on this
module with no concrete inputs reports success regardless of correctness, while terraform plan against
concrete (even literal, no-provider-call-required) values correctly raises the validation error. A caller
running plan/apply from a real root module β which is every real usage of this module β gets the
plan-time catch as designed.
What this cannot catch, even at plan: msgraph_resource.this's body argument is a generic map from
the provider's own perspective β the provider does not know what a Graph ipNamedLocation or
countryNamedLocation is. Concretely, in this module:
- A syntactically valid but non-existent country code (a two-letter string that isn't an actual ISO 3166-2
code) satisfies the regex and passes
plancleanly, but is rejected only atapply. - A syntactically valid CIDR range that doesn't correspond to any network the caller actually controls passes
plancleanly β this module has no way to verify real-world routability or ownership. - Deleting a location while a live Conditional Access policy still references it is not blocked by this module (nor by Graph) β see π Troubleshooting.
- Deleting a location with
is_trusted = truefails only atapplywith a Graph-side error; this module applies no plan-time guard for it, since whether a location is currently trusted in a live tenant is not somethingplancan observe from configuration alone.
This module's typed inputs and validation {} blocks are the only thing standing between the caller and a
Graph 400 error at apply time for anything beyond these categories.
$ terraform output
id = "0854951d-5fc0-4eb1-b392-9b2c9d7949c2"
display_name = "Corporate VPN Egress"
No credential value is ever present in terraform output, terraform show, or any other output surface of
this module β no property on either Graph resource type in scope here carries one.
| Symptom | Cause | Fix |
|---|---|---|
A Conditional Access policy update or read later fails with Graph error 1040: NamedLocation with id... does not exist in the directory |
A named location that policy referenced was deleted β Graph does not block deleting a named location still referenced by a live policy (a confirmed asymmetry with authentication-strength-policy, which Graph does block) |
Remove the id from every consuming conditional-access-policy's locations block before destroying the named location, as safe operational practice; if already deleted, restore it within Entra's 30-day soft-delete window or update the policy to remove the dangling reference |
apply fails on delete with an error referencing the trusted designation |
The location has isTrusted = true β Graph documents that trusted locations can't be deleted without first removing the trusted designation |
Update the location to is_trusted = false, apply, then destroy |
plan/apply fails with "var.ip_location must be set (non-null) when var.location_type = "ip"..." (or the country_location equivalent) |
location_type and the corresponding nested object are mismatched or the nested object was omitted |
Set exactly the nested object matching location_type (ip_location for "ip", country_location for "country") and leave the other null |
A cidr_address value is rejected at plan |
The value doesn't match the CIDR shape this module expects for its declared range_type |
Use IPv4 CIDR notation (e.g. "1.2.3.4/32") for "IPv4" entries, or IPv6 CIDR notation (e.g. "2001:db8::/32") for "IPv6" entries |
| Plan shows this named location will be replaced (destroy + recreate) instead of updated | var.location_type changed (an "ip" β "country" flip) |
Expected β the @odata.type discriminant is documented immutable. Confirm the subtype change is intentional; there is no in-place conversion path in Graph |
| Adding a new IP range appears to remove an existing one | var.ip_location.ip_ranges was re-supplied without the original entries |
ipRanges is a full-replace field on update β always supply the complete list, including ranges you want to keep (see π Example Library #8) |
A bare terraform validate (no variables supplied) reports success even for an input combination you know is invalid |
Custom validation {} blocks that reference other variables are only evaluated once concrete values are known β validate alone, with unknown placeholder values, cannot conclusively evaluate them |
Run terraform plan (even without applying) against concrete inputs to exercise these checks β see π§ͺ Testing |
- Graph API reference β
ipNamedLocationresource type: https://learn.microsoft.com/graph/api/resources/ipnamedlocation?view=graph-rest-1.0 - Graph API reference β
countryNamedLocationresource type: https://learn.microsoft.com/graph/api/resources/countrynamedlocation?view=graph-rest-1.0 - Graph API reference β
namedLocationbase resource type: https://learn.microsoft.com/graph/api/resources/namedlocation?view=graph-rest-1.0 - Graph API reference β Create namedLocation (permissions table,
@odata.typerequirement): https://learn.microsoft.com/graph/api/conditionalaccessroot-post-namedlocations?view=graph-rest-1.0 - Graph API reference β Update ipNamedlocation (full-replace
ipRangesbehavior): https://learn.microsoft.com/graph/api/ipnamedlocation-update?view=graph-rest-1.0 - Graph API reference β Delete ipNamedLocation (permissions table): https://learn.microsoft.com/graph/api/ipnamedlocation-delete?view=graph-rest-1.0
- Graph API reference β
ipRangeabstract resource type: https://learn.microsoft.com/graph/api/resources/iprange?view=graph-rest-1.0 - Microsoft Learn β Conditional Access: Network assignment (trusted-location delete restriction, 30-day soft-delete/restore window): https://learn.microsoft.com/entra/identity/conditional-access/concept-assignment-network#trusted-locations
- Microsoft Learn β Conditional Access authentication strengths (the delete-block asymmetry cited above): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-strength-advanced-options
- Microsoft Q&A β dangling named-location reference on a live Conditional Access policy after delete: https://learn.microsoft.com/answers/a/1872896
- Provider docs β
microsoft/msgraph0.3.0: https://registry.terraform.io/providers/microsoft/msgraph/0.3.0/docs - Sibling module (planned, not yet authored) β
tf-mod-msgraph-conditional-access-policy'sSCOPE.md - This module's design record β embedded in the scaffold (a standalone
SCOPE.mdfile has not been promoted out of it β see π Module Structure)