Skip to content

microsoftexpert/tf-mod-msgraph-named-location

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”· Microsoft Graph Named Location Terraform Module

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.0 identity/conditionalAccess/namedLocations endpoint) β€” via a single msgraph_resource.this, targeting microsoft/msgraph 0.3.0.

Terraform msgraph Module Type Resources


🧩 Overview

  • 🌍 Provisions a Conditional Access named location β€” a network-based (ipNamedLocation) or geography-based (countryNamedLocation) rule set β€” via POST /identity/conditionalAccess/namedLocations, drift-detects and patches it thereafter, and deletes it on terraform destroy.
  • 🧭 One discriminator input, location_type ("ip" | "country"), gates the polymorphic split rather than two independent optional objects. This mirrors Graph's own @odata.type requirement 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.type discriminant is force-new, enforced with a terraform_data + lifecycle.replace_triggered_by mechanism keyed on location_type alone β€” flipping "ip" ↔ "country" forces a clean plan-time replacement; every other change (adding an IP range, renaming the location, changing countriesAndRegions) remains an ordinary in-place PATCH.
  • πŸ” ipRanges is 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.tf re-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 id output is the reference type conditional-access-policy's conditions.locations.includeLocations/excludeLocations (rendered from its named_location_ids input) 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 ipRanges behavior 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.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


πŸ—ΊοΈ Where this fits

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
Loading

Validated via the Mermaid Chart MCP before embedding (valid: true).


🧬 What this builds

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
Loading

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

βœ… Provider / Versions

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.type discriminant is immutable β€” no documented conversion path between ipNamedLocation and countryNamedLocation. This module enforces that as a real force-new via terraform_data.location_type_trigger + lifecycle.replace_triggered_by, keyed on location_type alone β€” changing the nested ip_location/country_location contents does not trigger this; only flipping location_type itself does.
  • ipRanges is a full-replace field, not a merge, on update. Graph's "Update ipNamedlocation" reference states verbatim: to retain an existing ipRange, include it again alongside any new ones; to remove one, exclude it. main.tf always renders the complete var.ip_location.ip_ranges list β€” there is no partial add/remove path in this module or in Graph itself.
  • Each ipRanges element 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, is beta-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 = true cannot 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 displayName on this entity (unlike, e.g., group's documented 256-character cap) β€” this module enforces only non-empty; no invented upper bound.

πŸ”‘ Graph API Permissions Required

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.

Microsoft Graph Prerequisites

  • Graph API version: v1.0 β€” no beta dependency for this module's two in-scope subtypes. (A related type, compliantNetworkNamedLocation, is beta-only and out of scope β€” see βœ… Provider / Versions.)
  • License/SKU: Entra ID P1 (baseline Conditional Access licensing).
  • Admin consent: required for Policy.ReadWrite.ConditionalAccess.

πŸ“ Module Structure

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.md file, because nothing in the authored .tf files diverged from that embedded scope during initial authoring (the discriminator-input pattern, the delete-while-referenced finding, and the full-replace ipRanges behavior all landed exactly as written there β€” see the scaffold's appended "Implementation confirmation" note).


βš™οΈ Quick Start

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
}

πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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_trusted is 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_method defaults to "clientIpAddress" and include_unknown_countries_and_regions defaults to false β€” 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 marked is_trusted = true cannot 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_type discriminant, mirroring Graph's per-element @odata.type requirement (#microsoft.graph.iPv4CidrRange / #microsoft.graph.iPv6CidrRange) β€” this module never flattens ipRanges into 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 = true includes 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 existing ipRange you must add it again alongside any new ones; to remove one, exclude it. This module's main.tf re-renders the entire ip_ranges list 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 fails terraform plan (variable validations that reference other variables are evaluated once concrete values are known β€” see πŸ§ͺ Testing), not apply β€” country_location's validation {} block requires it to be null whenever location_type != "country", and ip_location must 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's validation {} block requires every IPv4-typed cidr_address to match an IPv4-CIDR-shaped regex (e.g. "1.2.3.4/32") β€” a typo like this is caught at plan, not apply.

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 count means adding or removing one regional office from var.regional_offices never 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.All and Policy.ReadWrite.ConditionalAccess are 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 trusted locations and requires it everywhere else β€” this module only manages the location's isTrusted flag 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-policy is authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without any depends_on: the policy's named_location_ids input takes module.untrusted_foreign_countries.id as 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's named_location_ids first as safe operational practice.


πŸ“₯ Inputs

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.


🧾 Outputs

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.


🧠 Architecture Notes

  • The discriminator-input pattern is a settled design decision, not a judgment call. One location_type variable ("ip" | "country") gates which of ip_location/country_location applies, rather than two independent optional objects with a cross-object "exactly one is set" check. This mirrors the real Graph @odata.type discriminant one-to-one, ties force-new behavior to a single variable's validation {} 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.
  • body is always the same object shape on every apply β€” never a ternary between two differently-shaped object literals. Every leaf in main.tf's body map is a condition ? value: null ternary (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.type discriminant is force-new via terraform_data.location_type_trigger + lifecycle.replace_triggered_by, keyed on location_type alone. This is the same mechanism tf-mod-msgraph-app-role-assignment uses for its own force-new fields β€” terraform_data is part of Terraform's own built-in provider (see providers.tf's header comment). Keying the trigger on location_type alone (not the whole ip_location/country_location object) is deliberate: ordinary in-place updates β€” adding an IP range, renaming the location, changing countriesAndRegions β€” remain normal PATCHes and do not force replacement; only an actual subtype flip does.
  • ipRanges is 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_each children β€” it's a genuinely single-resource standalone module (plus the terraform_data helper). The example library's scaling pattern (Example 11) is a caller-side for_each over the module call itself, keyed by a stable map key, never count.
  • No eventual-consistency delay is documented specific to this entity β€” unlike, e.g., service principal creation immediately following application creation.

🧱 Design Principles

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

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin 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"

πŸ§ͺ Testing

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 plan cleanly, but is rejected only at apply.
  • A syntactically valid CIDR range that doesn't correspond to any network the caller actually controls passes plan cleanly β€” 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 = true fails only at apply with 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 something plan can 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.


πŸ’¬ Example Output

$ 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.


πŸ” Troubleshooting

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

πŸ”— Related Docs

Packages

 
 
 

Contributors

Languages