Skip to content

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/msgraph0.3.0.

TerraformmsgraphModuleTypeResources


🧩 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

ResourceTypeRole
random_password.initialrandom_passwordGenerates a 32-character throwaway value satisfying Graph's create-time passwordProfile.password requirement — never a real credential
msgraph_resource.thismsgraph_resourceKeystone — full create/read/update/delete lifecycle of the Graph user at url = "users"

✅ Provider / Versions

ItemValue
Terraform>= 1.12.0
microsoft/msgraph0.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 versionv1.0 (users endpoint)
Provider blockNone 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

OperationApplication permission
CreateUser.Create (least privilege); broader alternatives: User.ReadWrite.All, Directory.ReadWrite.All
ReadUser.Read.All
UpdateUser.ReadUpdate.All (confirm current availability at authoring time — fallback User.ReadWrite.All)
DeleteUser.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

OutputDescriptionConsumed by
idGraph object id (GUID) of the created user — primary outputCatalog 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_nameThe user's displayName, as provided to this moduleCallers needing a human-readable label for logs, notifications, or downstream naming
user_principal_nameThe user's userPrincipalName, as provided to this moduleCallers 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_hiresdisplay_name=each.value.display_nameuser_principal_name=each.value.user_principal_namemail_nickname=each.value.mail_nicknamedepartment=each.value.department
}
output"new_hire_ids" {
value={ fork, minmodule.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=falseemployee_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 applyuser_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=truejob_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

GroupVariables
Required at createdisplay_name, user_principal_name, mail_nickname
Account stateaccount_enabled (default false)
Personal / organizationalgiven_name, surname, job_title, department, company_name, employee_id, employee_type, employee_hire_date, office_location
Contactmail, mobile_phone
Locale / licensing prerequisitepreferred_language, usage_location
Hybrid identityon_premises_immutable_id
Minor-account complianceage_group, consent_provided_for_minor
Full variable reference (20 inputs)
VariableTypeDefaultValidationGraph property
display_namestring— (required)1–256 charactersdisplayName
user_principal_namestring— (required)Regex: local part limited to A-Z a-z 0-9 '. - _ ! # ^ ~, syntactically valid domain suffixuserPrincipalName
mail_nicknamestring— (required)Non-emptymailNickname
account_enabledboolfalsenoneaccountEnabled
given_namestringnullnone (documented max 64 chars, not enforced)givenName
surnamestringnullnone (documented max 64 chars, not enforced)surname
mailstringnullnonemail
mobile_phonestringnullnonemobilePhone
job_titlestringnullnone (documented max 128 chars, not enforced)jobTitle
departmentstringnullnone (documented max 64 chars, not enforced)department
company_namestringnullnone (documented max 64 chars, not enforced)companyName
employee_idstringnull≤ 16 charactersemployeeId
employee_typestringnullnone — free text, not a closed enumemployeeType
employee_hire_datestringnullMust parse as an ISO 8601 / RFC 3339 timestamp (formatdate-checked)employeeHireDate
office_locationstringnullnoneofficeLocation
preferred_languagestringnullnonepreferredLanguage
usage_locationstringnullRegex: two uppercase lettersusageLocation
on_premises_immutable_idstringnullnoneonPremisesImmutableId
age_groupstringnullOne of "Minor", "NotAdult", "Adult", or nullageGroup
consent_provided_for_minorstringnullOne of "Granted", "Denied", "NotRequired", or nullconsentProvidedForMinor

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

OutputDescriptionSensitive / excluded?
idGraph object id (GUID) of the created user — primary outputNo
display_nameThe user's displayName, as provided to this moduleNo
user_principal_nameThe user's userPrincipalName, as provided to this moduleNo

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

ConcernSecure default in this moduleOpt-out (caller must be explicit)
Account activation (accountEnabled)account_enabled defaults to false — new users are provisioned disabledCaller 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 trueNone — 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)
customSecurityAttributesExcluded 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 outputsNo output is ever derived from a credential-bearing propertyN/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

SymptomCauseFix
apply fails on create with a 400 referencing an invalid domainuser_principal_name's domain is not a verified domain in the tenant's organization.verifiedDomains collectionAdd 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 actionsThe calling principal is not assigned a directory role Graph requires for its "sensitive actions" gating on accountEnabledAssign 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 usageLocationusage_location matched the two-uppercase-letter shape regex but is not a real ISO 3166-1 alpha-2 country codeConfirm 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 changeignore_missing_property missing or the provider version has drifted from the exact 0.3.0 pinConfirm 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 inExpected — by design. random_password.initial is a throwaway value invalidated by forceChangePasswordNextSignIn = trueThe 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-consistentRe-run plan/apply, or add an explicit wait/retry in the consuming module rather than assuming instant consistency

🔗 Related Docs

Releases

Packages

Contributors

Languages