Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”· Microsoft Graph User Terraform Module

Manages the full create/read/update/delete lifecycle of an Entra ID user object (Microsoft Graph v1.0 users endpoint) via a single msgraph_resource.this, targeting microsoft/msgraph 0.3.0.

Terraform msgraph Module Type Resources


🧩 Overview

  • πŸ‘€ Provisions an Entra ID user via POST /users, drift-detects and patches it thereafter, and deletes it on terraform destroy.
  • 🧱 Mirrors the Graph user resource type's documented writable surface as a deeply-typed object-free set of scalar variables.tf inputs β€” every optional property is try(var.x, null)-rendered, so a caller cannot pass a malformed shape and still reach a plan.
  • πŸ”’ Never accepts or emits a real password. Graph requires a passwordProfile.password value at create; this module satisfies that structural requirement with a random_password throwaway value that is invalidated before it can ever be used (see 🧠 Architecture Notes).
  • 🚫 Defaults every new account to accountEnabled = false β€” a newly provisioned user is inert until the identity team deliberately activates it.
  • πŸ†” Its id output is the reference type nearly every other module in this catalog will eventually consume (group membership/ownership, app role assignments, directory role assignments) β€” see πŸ—ΊοΈ Where this fits.

πŸ’‘ Why it matters: this is the foundational identity primitive for the whole msgraph module library. Nearly every other planned module β€” group membership, group ownership, app role assignment, directory role assignment, PIM eligibility β€” references a user by the id this module emits. Getting its secure defaults and its password handling right here means every downstream module inherits a safe, non-secret-leaking foundation.


❀️ 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 β€” it is a standalone, independently-lifecycled Graph entity. It does, however, sit at the root of a real downstream consumer family. The catalog entries below are planned per this module suite's module catalog table; as of this module's authoring, terraform-msgraph-user is the only module directory that exists in this repository (confirmed β€” no other terraform-msgraph-* folders are present), so the downstream boxes below represent future catalog coverage, not published, consumable modules yet.

flowchart LR
 Graph["Microsoft Graph<br/>Entra ID tenant<br/>(POST/GET/PATCH/DELETE /users)"]:::external
 User["terraform-msgraph-user<br/>(this module)"]:::thisModule
 GM["terraform-msgraph-group-membership<br/>(planned catalog entry β€” not yet authored)"]:::sibling
 GO["terraform-msgraph-group-owner<br/>(planned catalog entry β€” not yet authored)"]:::sibling
 ARA["terraform-msgraph-app-role-assignment<br/>(planned catalog entry β€” not yet authored)"]:::sibling
 DRA["terraform-msgraph-directory-role-assignment<br/>(planned catalog entry β€” not yet authored)"]:::sibling

 User -->|"manages lifecycle of"| Graph
 User -->|"id consumed as member principal"| GM
 User -->|"id consumed as owner principal"| GO
 User -->|"id consumed as assignee principal"| ARA
 User -->|"id consumed as role-assignment principal"| DRA

 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

🧬 What this builds

A single keystone plus one non-Graph helper resource β€” no for_each children, no nested composite blocks.

flowchart TD
 subgraph Inputs["Caller inputs"]
 I1["display_name, user_principal_name,<br/>mail_nickname (required)"]:::neutral
 I2["account_enabled, given_name, surname,<br/>mail, usage_location, age_group, etc.<br/>(optional)"]:::neutral
 end

 RP["random_password.initial<br/>hashicorp/random β€” throwaway value,<br/>never a caller input or output"]:::neutral

 This["msgraph_resource.this<br/>url = users<br/>ignore_missing_property = true"]:::thisModule

 subgraph Outputs["Outputs"]
 O1["id (primary)"]:::neutral
 O2["display_name"]:::neutral
 O3["user_principal_name"]:::neutral
 end

 I1 -->|"rendered into body"| This
 I2 -->|"rendered into body via try x null"| This
 RP -->|"result written once into passwordProfile.password"| This
 This -->|"msgraph_resource.this.id"| O1
 This -.->|"echoed from var.display_name"| O2
 This -.->|"echoed from var.user_principal_name"| O3

 classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
 classDef neutral fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

Resource inventory

Resource Type Role
random_password.initial random_password Generates a 32-character throwaway value satisfying Graph's create-time passwordProfile.password requirement β€” never a real credential
msgraph_resource.this msgraph_resource Keystone β€” full create/read/update/delete lifecycle of the Graph user at url = "users"

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
microsoft/msgraph 0.3.0, pinned exactly (pre-1.0 provider β€” no ~> constraint)
hashicorp/random ~> 3.6 β€” companion provider, used solely for the throwaway create-time password
Graph API version v1.0 (users endpoint)
Provider block None in this module β€” the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module

Schema notes that bite

  • This module never accepts or emits a real password β€” by design, not by omission. Graph genuinely requires a passwordProfile.password value at create, and the microsoft/msgraph provider's body argument has no write-only/ephemeral mechanism to route around it (confirmed against the provider's own schema β€” body is a plain Dynamic attribute). random_password.initial exists only to satisfy that structural requirement; passwordProfile.forceChangePasswordNextSignIn is hardcoded true in main.tf (not a variable), invalidating the generated value before it can ever be used to sign in. The identity team must establish the real initial credential out-of-band (SSPR, a Temporary Access Pass, or an admin-driven password reset) after provisioning.
  • account_enabled defaults to false. A newly provisioned user is disabled until the caller explicitly sets account_enabled = true. Combined with the throwaway password above, a freshly created user is inert on both axes (unknown password, forced to change it; disabled) until the identity team deliberately activates it.
  • userPrincipalName's domain must already be a verified domain in the tenant. This module's validation {} block only checks the UPN's shape (allowed local-part characters, a syntactically valid domain suffix) β€” it cannot check at plan time whether that domain is actually present in the tenant's organization.verifiedDomains collection, since that requires a live Graph lookup. An unverified domain passes validate/plan cleanly and fails only at apply.
  • displayName can never be cleared once set. Plan an update, never a removal, if this ever needs to change.
  • accountEnabled is subject to Graph's "sensitive actions" gating on update β€” only specific privileged administrator roles/permissions can change it after creation, independent of the base write permission.
  • ignore_missing_property = true on msgraph_resource.this is what keeps the create-only throwaway password from being silently re-sent (and forceChangePasswordNextSignIn re-armed) on every future apply β€” Graph's GET /users response never echoes passwordProfile back, so the provider treats it as legitimately absent rather than drifted.

πŸ”‘ Graph API Permissions Required

Operation Application permission
Create User.Create (least privilege); broader alternatives: User.ReadWrite.All, Directory.ReadWrite.All
Read User.Read.All
Update User.ReadUpdate.All (confirm current availability at authoring time β€” fallback User.ReadWrite.All)
Delete User.ReadWrite.All β€” the least-privileged and only listed application permission for DELETE /users/{id} (no narrower alternative exists, unlike Create's User.Create). Deleting a user who holds a privileged administrator role additionally requires the calling app to hold a higher-privileged administrator directory role per Graph's "sensitive actions" gating β€” a directory-role prerequisite layered on top of the application permission, not a substitute for it.

All application permissions above require admin consent.

Microsoft Graph Prerequisites

  • Graph API version: v1.0 β€” no beta dependency.
  • License/SKU: none beyond baseline Entra ID.
  • usage_location must be set before a license SKU can be assigned to the user by a separate, not-yet-authored licensing-assignment module (out of this module's scope).
  • customSecurityAttributes requires an additional CustomSecAttributeAssignment.ReadWrite.All permission grant beyond the base user-write permission; this module excludes that property from its schema entirely (see 🧠 Architecture Notes).
  • Admin consent: required for every application permission listed above.

πŸ“ Module Structure

terraform-msgraph-user/
β”œβ”€β”€ providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned exactly at 0.3.0;
β”‚ # hashicorp/random ~> 3.6; no provider {} block for either
β”œβ”€β”€ variables.tf # 20 deeply-typed scalar inputs mirroring the Graph v1.0 user resource type
β”œβ”€β”€ main.tf # random_password.initial + msgraph_resource.this (keystone, url = "users")
β”œβ”€β”€ outputs.tf # id (primary), display_name, user_principal_name
β”œβ”€β”€ README.md # this file β€” includes this module's design intent, permissions, prerequisites,
β”‚ # and provider gotchas; a standalone SCOPE.md file has not yet been promoted
β”‚ # out of it as of this README
└── examples/
 └── basic/
 └── main.tf # smallest real call β€” the four Graph-required properties only

βš™οΈ 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 "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Adele Vance"
  user_principal_name = "AdeleV@contoso.com"
  mail_nickname       = "AdeleV"

  # secure default (account_enabled = false) is left in place deliberately β€” the identity team
  # activates the account out-of-band once onboarding/credential handoff is confirmed.
}

output "user_id" {
  value = module.user.id
}

πŸ”Œ Cross-Module Contract

Consumes: none β€” this module has no upstream dependency on another module in this catalog.

Emits

Output Description Consumed by
id Graph object id (GUID) of the created user β€” primary output Catalog modules that reference a user principal by id: group-membership, group-owner, app-role-assignment, directory-role-assignment. These are planned entries in this module suite's module catalog table β€” not yet authored as .tf files in this repository as of this module's authoring.
display_name The user's displayName, as provided to this module Callers needing a human-readable label for logs, notifications, or downstream naming
user_principal_name The user's userPrincipalName, as provided to this module Callers needing the sign-in identifier, e.g. downstream onboarding/licensing automation outside this module's scope

πŸ“š Example Library

1 Β· Minimal required call
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Adele Vance"
  user_principal_name = "AdeleV@contoso.com"
  mail_nickname       = "AdeleV"
}

ℹ️ Only the four Graph-required properties are set. account_enabled is left at its false secure default; every other optional property is null.

2 Β· Provisioning an already-active account (opting out of the disabled default)
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Nestor Wilke"
  user_principal_name = "NestorW@contoso.com"
  mail_nickname       = "NestorW"
  account_enabled     = true
}

⚠️ Only pass account_enabled = true when the identity team has already confirmed a credential handoff path exists β€” the module's throwaway create-time password is never usable to sign in (forceChangePasswordNextSignIn is always true).

3 Β· Personal directory attributes
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Megan Bowen"
  user_principal_name = "MeganB@contoso.com"
  mail_nickname       = "MeganB"

  given_name = "Megan"
  surname    = "Bowen"
  job_title  = "Director of Finance"
  department = "Finance"
}
4 Β· Contact properties
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Alex Wilber"
  user_principal_name = "AlexW@contoso.com"
  mail_nickname       = "AlexW"

  mail            = "AlexW@contoso.com"
  mobile_phone    = "+1 555 0130"
  office_location = "Building 3, Floor 2"
}
5 Β· Company and employment metadata
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Diego Siciliani"
  user_principal_name = "DiegoS@contoso.com"
  mail_nickname       = "DiegoS"

  company_name       = "Contoso Ltd."
  employee_id        = "E-10492"
  employee_type      = "Employee"
  employee_hire_date = "2026-08-01T00:00:00Z"
}

ℹ️ employee_type is free text β€” Graph does not document it as a closed enum, so this module applies no validation {} block to it.

6 Β· Setting usage_location ahead of a future license assignment
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Isaiah Langer"
  user_principal_name = "IsaiahL@contoso.com"
  mail_nickname       = "IsaiahL"

  usage_location = "US"
}

πŸ’‘ usage_location is required by Graph before a license SKU can be assigned to this user by a separate licensing-assignment module (out of this module's scope) β€” service availability by country/region is a legal requirement, not a Graph technicality.

7 Β· Provisioning a minor account
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Casey Jensen"
  user_principal_name = "CaseyJ@contoso.com"
  mail_nickname       = "CaseyJ"

  age_group                  = "Minor"
  consent_provided_for_minor = "Granted"
}

πŸ”’ Both age_group and consent_provided_for_minor are validated against Graph's documented closed enums (Minor/NotAdult/Adult and Granted/Denied/NotRequired respectively) β€” an out-of-range value fails at plan, before any Graph call is made.

8 Β· Provisioning a federated / hybrid-synced user
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Patti Fernandez"
  user_principal_name = "PattiF@fabrikam.com"
  mail_nickname       = "PattiF"

  on_premises_immutable_id = "a1B2c3D4e5F6g7H8i9J0=="
}

⚠️ on_premises_immutable_id is only meaningful when user_principal_name uses a federated domain synced from on-premises Active Directory. Leave it null (the default) for cloud-only users.

9 Β· Preferred language
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Grady Archie"
  user_principal_name = "GradyA@contoso.com"
  mail_nickname       = "GradyA"

  preferred_language = "en-US"
}
10 Β· Realistic onboarding call combining several optional attributes
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Lynne Robbins"
  user_principal_name = "LynneR@contoso.com"
  mail_nickname       = "LynneR"

  given_name         = "Lynne"
  surname            = "Robbins"
  job_title          = "Retail Manager"
  department         = "Retail"
  company_name       = "Contoso Ltd."
  office_location    = "Store 204"
  mobile_phone       = "+1 555 0199"
  usage_location     = "US"
  employee_id        = "E-20117"
  employee_type      = "Employee"
  employee_hire_date = "2026-09-15T00:00:00Z"
}
11 Β· for_each at scale β€” creating multiple users from a map
variable "new_hires" {
  type = map(object({
    display_name        = string
    user_principal_name = string
    mail_nickname       = string
    department          = string
  }))
}

module "user" {
  source   = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"
  for_each = var.new_hires

  display_name        = each.value.display_name
  user_principal_name = each.value.user_principal_name
  mail_nickname       = each.value.mail_nickname
  department          = each.value.department
}

output "new_hire_ids" {
  value = { for k, m in module.user : k => m.id }
}

πŸ’‘ Keying by a stable map key (e.g. an HR system employee number) rather than count means adding or removing one new hire from var.new_hires never forces Terraform to re-index β€” and therefore never re-plans β€” every other user in the map.

12 Β· Least-privilege permissions callout
# Root module β€” provider auth configured with the narrowest application permission set this module
# actually needs for a create-only pipeline: User.Create (not User.ReadWrite.All or
# Directory.ReadWrite.All). Confirm the running principal's app registration is granted exactly this
# scope, with admin consent, before applying.
provider "msgraph" {}

module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Irvin Sayle"
  user_principal_name = "IrvinS@contoso.com"
  mail_nickname       = "IrvinS"
}

πŸ”’ See πŸ”‘ Graph API Permissions Required β€” a pipeline that only ever creates users needs User.Create, not the broader User.ReadWrite.All/Directory.ReadWrite.All alternatives. Reserve the broader grants for pipelines that also update or delete users.

13 Β· Future-dated hire
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Miriam Graham"
  user_principal_name = "MiriamG@contoso.com"
  mail_nickname       = "MiriamG"

  account_enabled    = false
  employee_hire_date = "2026-11-02T00:00:00Z"
}

ℹ️ employee_hire_date accepts a future timestamp β€” Graph models this as "hired or will start work in a future hire." Leaving account_enabled at its false default here is deliberate: the account can be provisioned ahead of the start date without being sign-in-capable until the identity team activates it.

14 Β· A validation failure this module catches before any Graph call
module "user" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Bad Example"
  user_principal_name = "bad user!!@contoso.com" # fails var.user_principal_name's validation{}
  mail_nickname       = "BadExample"
}

⚠️ This fails terraform validate/plan, not apply β€” user_principal_name's local part contains a space, which is not among the characters Graph allows (A-Z a-z 0-9 '. - _ ! # ^ ~). Compare this to πŸ§ͺ Testing, below: shape-level mistakes like this one are caught for free; a UPN whose domain is unverified in the tenant is not, because that requires a live Graph lookup this module cannot perform at plan time.

15 Β· πŸ—οΈ End-to-end composition β€” a user consumed by downstream catalog modules

This module has no upstream Consumes, so its mandatory end-to-end example instead shows the realistic downstream chain: a user provisioned here, then referenced by id from two catalog modules that are planned but not yet authored as of this README (terraform-msgraph-group-owner and terraform-msgraph-app-role-assignment β€” see this module suite's module catalog table). The shape below is illustrative of the intended contract, not a call against a published module today.

module "engineering_lead" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"

  display_name        = "Adele Vance"
  user_principal_name = "AdeleV@contoso.com"
  mail_nickname       = "AdeleV"
  account_enabled     = true
  job_title           = "Engineering Lead"
  department          = "Engineering"
}

# Illustrative only β€” terraform-msgraph-group-owner is a planned catalog entry, not yet authored.
# module "engineering_group_owner" {
# source = "git::https://github.com/microsoftexpert/terraform-msgraph-group-owner.git?ref=v1.0.0"
#
# group_id = module.engineering_group.id
# principal_id = module.engineering_lead.id
# }

# Illustrative only β€” terraform-msgraph-app-role-assignment is a planned catalog entry, not yet authored.
# module "engineering_lead_app_role" {
# source = "git::https://github.com/microsoftexpert/terraform-msgraph-app-role-assignment.git?ref=v1.0.0"
#
# service_principal_id = module.internal_tools_sp.id
# principal_id = module.engineering_lead.id
# app_role_id = var.internal_tools_admin_role_id
# }

πŸ’‘ Once group-owner and app-role-assignment are authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without any depends_on: both downstream calls take module.engineering_lead.id as an input, so Terraform will not attempt either assignment before the user exists.


πŸ“₯ Inputs

Grouped summary

Group Variables
Required at create display_name, user_principal_name, mail_nickname
Account state account_enabled (default false)
Personal / organizational given_name, surname, job_title, department, company_name, employee_id, employee_type, employee_hire_date, office_location
Contact mail, mobile_phone
Locale / licensing prerequisite preferred_language, usage_location
Hybrid identity on_premises_immutable_id
Minor-account compliance age_group, consent_provided_for_minor
Full variable reference (20 inputs)
Variable Type Default Validation Graph property
display_name string β€” (required) 1–256 characters displayName
user_principal_name string β€” (required) Regex: local part limited to A-Z a-z 0-9 '. - _ ! # ^ ~, syntactically valid domain suffix userPrincipalName
mail_nickname string β€” (required) Non-empty mailNickname
account_enabled bool false none accountEnabled
given_name string null none (documented max 64 chars, not enforced) givenName
surname string null none (documented max 64 chars, not enforced) surname
mail string null none mail
mobile_phone string null none mobilePhone
job_title string null none (documented max 128 chars, not enforced) jobTitle
department string null none (documented max 64 chars, not enforced) department
company_name string null none (documented max 64 chars, not enforced) companyName
employee_id string null ≀ 16 characters employeeId
employee_type string null none β€” free text, not a closed enum employeeType
employee_hire_date string null Must parse as an ISO 8601 / RFC 3339 timestamp (formatdate-checked) employeeHireDate
office_location string null none officeLocation
preferred_language string null none preferredLanguage
usage_location string null Regex: two uppercase letters usageLocation
on_premises_immutable_id string null none onPremisesImmutableId
age_group string null One of "Minor", "NotAdult", "Adult", or null ageGroup
consent_provided_for_minor string null One of "Granted", "Denied", "NotRequired", or null consentProvidedForMinor

Deliberately excluded from this schema (see 🧠 Architecture Notes and 🧱 Design Principles below): passwordProfile.password (secret value β€” never a module input), identities (B2B/B2C-only creation path, out of scope), customSecurityAttributes (open complex type, tenant-specific, requires an additional permission scope beyond this module's base scope), creationType (read-only, computed by Graph).


🧾 Outputs

Output Description Sensitive / excluded?
id Graph object id (GUID) of the created user β€” primary output No
display_name The user's displayName, as provided to this module No
user_principal_name The user's userPrincipalName, as provided to this module No

No output derived from random_password.initial exists anywhere in outputs.tf β€” the throwaway password value is never surfaced, not even marked sensitive = true; it is excluded entirely.


🧠 Architecture Notes

  • random_password.initial is a structural placeholder, not a credential-management feature. It exists because msgraph_resource's body argument has no write-only/ephemeral sub-key support (confirmed against the provider's own schema) and Graph genuinely requires a passwordProfile.password value at create. Its result is written into body.passwordProfile.password exactly once, at create; the value is invalidated for sign-in purposes because passwordProfile.forceChangePasswordNextSignIn is hardcoded true in main.tf, not a variable. The value still lands in Terraform state, identical to any random_password usage elsewhere in the Terraform ecosystem β€” an accepted, industry-standard trade-off, not a violation of this library's "never accept/emit a secret" rule, since the value is never caller-supplied and never module-output.
  • ignore_missing_property = true on msgraph_resource.this is load-bearing, not decorative. Graph's GET /users response never echoes passwordProfile back; without this setting, the provider could attempt to reconcile a "missing" property on every future apply, re-sending the throwaway password and re-arming forceChangePasswordNextSignIn whenever an unrelated property (e.g. jobTitle) changes. This is preferred over lifecycle.ignore_changes, which would also block legitimate future updates to passwordProfile.
  • accountEnabled updates are gated by Graph's "sensitive actions" restrictions independent of the base write permission β€” only specific privileged administrator roles can flip this property after creation. A plan that looks clean can still fail at apply with a 403 if the calling principal lacks that directory role.
  • for_each, never count, is the pattern this module's example library uses for scaling to multiple users (see Example 11) β€” even though this module itself has no internal for_each children, keying by a stable map key at the caller's call site avoids re-indexing every other user when one is added or removed.

🧱 Design Principles

Concern Secure default in this module Opt-out (caller must be explicit)
Account activation (accountEnabled) account_enabled defaults to false β€” new users are provisioned disabled Caller sets account_enabled = true explicitly
Create-time credential (passwordProfile.password) Never accepted as an input, never emitted as an output β€” a hard rule, not a toggle. random_password.initial supplies a throwaway value; forceChangePasswordNextSignIn is hardcoded true None β€” there is no opt-out. The real initial credential is always established out-of-band (SSPR, a Temporary Access Pass, or an admin-driven reset)
customSecurityAttributes Excluded from this module's schema entirely (tenant-specific open complex type; requires an additional permission scope) Not available in this module β€” manage via a dedicated call outside this module's scope
Sensitive outputs No output is ever derived from a credential-bearing property N/A β€” exclusion, not merely sensitive = true

πŸš€ 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/terraform-msgraph-user.git?ref=v1.0.0"

πŸ§ͺ Testing

terraform validate and terraform fmt -check prove the offline contract: every required property is present, every documented closed enum (age_group, consent_provided_for_minor) is restricted to its legal values, and shape-level regexes (user_principal_name's allowed character set, usage_location's two-letter format, employee_hire_date's RFC 3339 parseability) reject obviously malformed input before any Graph call is made.

What this cannot catch: msgraph_resource.this's body argument is a generic map from the provider's own perspective β€” the provider does not know what a Graph user is. Concretely, in this module:

  • usage_location = "XX" satisfies the ^[A-Z]{2}$ shape regex and passes validate/plan cleanly, but "XX" is not a real ISO 3166-1 alpha-2 country code β€” Graph rejects it only at apply, with a 400.
  • user_principal_name = "AdeleV@notaverifieddomain.com" satisfies the UPN shape regex entirely, but fails only at apply if notaverifieddomain.com is not present in the tenant's organization.verifiedDomains collection β€” this cannot be checked at plan time since it requires a live tenant lookup.
  • company_name, given_name, surname, job_title, and department carry documented Graph maximum lengths (64, 64, 64, 128, and 64 characters respectively) that this module does not enforce with a validation {} block β€” an over-length value passes plan and is rejected only at apply.

This module's object-free scalar typing and validation {} blocks are the only thing standing between the caller and a Graph 400 error at apply time for anything beyond these three categories.


πŸ’¬ Example Output

$ terraform output
id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
display_name = "Adele Vance"
user_principal_name = "AdeleV@contoso.com"

No password value is ever present in terraform output, terraform show, or any other output surface of this module.


πŸ” Troubleshooting

Symptom Cause Fix
apply fails on create with a 400 referencing an invalid domain user_principal_name's domain is not a verified domain in the tenant's organization.verifiedDomains collection Add and verify the domain in Entra ID before applying, or change user_principal_name to an already-verified domain
apply fails on an accountEnabled update with a 403 referencing sensitive actions The calling principal is not assigned a directory role Graph requires for its "sensitive actions" gating on accountEnabled Assign the calling app/service principal a sufficiently privileged administrator role, or have a privileged admin perform this specific update
apply fails with a 400 referencing an invalid usageLocation usage_location matched the two-uppercase-letter shape regex but is not a real ISO 3166-1 alpha-2 country code Confirm the value against the current ISO 3166-1 alpha-2 list before setting it β€” this cannot be caught at plan time
Plan shows an unexpected diff touching passwordProfile on an otherwise unrelated change ignore_missing_property missing or the provider version has drifted from the exact 0.3.0 pin Confirm providers.tf pins microsoft/msgraph at exactly 0.3.0 and that ignore_missing_property = true is present on msgraph_resource.this
New user is created but no one can sign in Expected β€” by design. random_password.initial is a throwaway value invalidated by forceChangePasswordNextSignIn = true The identity team must establish the real credential out-of-band (SSPR, a Temporary Access Pass, or an admin-driven reset)
apply is slow to reflect a just-created user in a dependent read (e.g. a data source lookup immediately after) Graph's Entra ID write path is not always immediately read-consistent Re-run plan/apply, or add an explicit wait/retry in the consuming module rather than assuming instant consistency

πŸ”— Related Docs