Manages the full create/read/update/delete lifecycle of an Entra ID
userobject (Microsoft Graph v1.0usersendpoint) via a singlemsgraph_resource.this, targetingmicrosoft/msgraph0.3.0.
- 👤 Provisions an Entra ID user via
POST /users, drift-detects and patches it thereafter, and deletes it onterraform destroy. - 🧱 Mirrors the Graph
userresource type's documented writable surface as a deeply-typedobject-free set of scalarvariables.tfinputs — every optional property istry(var.x, null)-rendered, so a caller cannot pass a malformed shape and still reach aplan. - 🔒 Never accepts or emits a real password. Graph requires a
passwordProfile.passwordvalue at create; this module satisfies that structural requirement with arandom_passwordthrowaway 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
idoutput 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
msgraphmodule library. Nearly every other planned module — group membership, group ownership, app role assignment, directory role assignment, PIM eligibility — references a user by theidthis module emits. Getting its secure defaults and its password handling right here means every downstream module inherits a safe, non-secret-leaking foundation.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn:linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee:buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
This module has no upstream Consumes — 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
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
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" |
| 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.passwordvalue at create, and themicrosoft/msgraphprovider'sbodyargument has no write-only/ephemeral mechanism to route around it (confirmed against the provider's own schema —bodyis a plainDynamicattribute).random_password.initialexists only to satisfy that structural requirement;passwordProfile.forceChangePasswordNextSignInis hardcodedtrueinmain.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_enableddefaults tofalse. A newly provisioned user is disabled until the caller explicitly setsaccount_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'svalidation {}block only checks the UPN's shape (allowed local-part characters, a syntactically valid domain suffix) — it cannot check atplantime whether that domain is actually present in the tenant'sorganization.verifiedDomainscollection, since that requires a live Graph lookup. An unverified domain passesvalidate/plancleanly and fails only atapply.displayNamecan never be cleared once set. Plan an update, never a removal, if this ever needs to change.accountEnabledis 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 = trueonmsgraph_resource.thisis what keeps the create-only throwaway password from being silently re-sent (andforceChangePasswordNextSignInre-armed) on every futureapply— Graph'sGET /usersresponse never echoespasswordProfileback, so the provider treats it as legitimately absent rather than drifted.
| 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.
- Graph API version: v1.0 — no beta dependency.
- License/SKU: none beyond baseline Entra ID.
usage_locationmust 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).customSecurityAttributesrequires an additionalCustomSecAttributeAssignment.ReadWrite.Allpermission 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.
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
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
}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 |
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_enabledis left at itsfalsesecure default; every other optional property isnull.
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 passaccount_enabled = truewhen the identity team has already confirmed a credential handoff path exists — the module's throwaway create-time password is never usable to sign in (forceChangePasswordNextSignInis alwaystrue).
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_typeis free text — Graph does not document it as a closed enum, so this module applies novalidation {}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_locationis 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_groupandconsent_provided_for_minorare validated against Graph's documented closed enums (Minor/NotAdult/AdultandGranted/Denied/NotRequiredrespectively) — an out-of-range value fails atplan, 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_idis only meaningful whenuser_principal_nameuses a federated domain synced from on-premises Active Directory. Leave itnull(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
countmeans adding or removing one new hire fromvar.new_hiresnever 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 broaderUser.ReadWrite.All/Directory.ReadWrite.Allalternatives. 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_dateaccepts a future timestamp — Graph models this as "hired or will start work in a future hire." Leavingaccount_enabledat itsfalsedefault 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 failsterraform validate/plan, notapply—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 atplantime.
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-ownerandapp-role-assignmentare authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without anydepends_on: both downstream calls takemodule.engineering_lead.idas an input, so Terraform will not attempt either assignment before the user exists.
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).
| 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.
random_password.initialis a structural placeholder, not a credential-management feature. It exists becausemsgraph_resource'sbodyargument has no write-only/ephemeral sub-key support (confirmed against the provider's own schema) and Graph genuinely requires apasswordProfile.passwordvalue at create. Itsresultis written intobody.passwordProfile.passwordexactly once, at create; the value is invalidated for sign-in purposes becausepasswordProfile.forceChangePasswordNextSignInis hardcodedtrueinmain.tf, not a variable. The value still lands in Terraform state, identical to anyrandom_passwordusage 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 = trueonmsgraph_resource.thisis load-bearing, not decorative. Graph'sGET /usersresponse never echoespasswordProfileback; without this setting, the provider could attempt to reconcile a "missing" property on every futureapply, re-sending the throwaway password and re-armingforceChangePasswordNextSignInwhenever an unrelated property (e.g.jobTitle) changes. This is preferred overlifecycle.ignore_changes, which would also block legitimate future updates topasswordProfile.accountEnabledupdates 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. Aplanthat looks clean can still fail atapplywith a 403 if the calling principal lacks that directory role.for_each, nevercount, is the pattern this module's example library uses for scaling to multiple users (see Example 11) — even though this module itself has no internalfor_eachchildren, keying by a stable map key at the caller's call site avoids re-indexing every other user when one is added or removed.
| 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 |
terraform init -backend=false
terraform validate
terraform fmt -checkPin every real consumption of this module to an explicit tag, never a branch:
source="git::https://github.com/microsoftexpert/terraform-msgraph-user.git?ref=v1.0.0"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 passesvalidate/plancleanly, but"XX"is not a real ISO 3166-1 alpha-2 country code — Graph rejects it only atapply, with a 400.user_principal_name = "AdeleV@notaverifieddomain.com"satisfies the UPN shape regex entirely, but fails only atapplyifnotaverifieddomain.comis not present in the tenant'sorganization.verifiedDomainscollection — this cannot be checked atplantime since it requires a live tenant lookup.company_name,given_name,surname,job_title, anddepartmentcarry documented Graph maximum lengths (64, 64, 64, 128, and 64 characters respectively) that this module does not enforce with avalidation {}block — an over-length value passesplanand is rejected only atapply.
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.
$ 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.
| 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 |
- Graph API reference —
userresource type: https://learn.microsoft.com/graph/api/resources/user?view=graph-rest-1.0 - Graph API reference — create user: https://learn.microsoft.com/graph/api/user-post-users?view=graph-rest-1.0
- Graph API reference — delete user (permissions table): https://learn.microsoft.com/graph/api/user-delete?view=graph-rest-1.0#permissions
- Graph API reference —
passwordProfileresource type: https://learn.microsoft.com/graph/api/resources/passwordprofile?view=graph-rest-1.0 - Graph API reference — who can perform sensitive actions: https://learn.microsoft.com/graph/api/resources/users?view=graph-rest-1.0#who-can-perform-sensitive-actions
- Provider docs —
microsoft/msgraph0.3.0: https://registry.terraform.io/providers/microsoft/msgraph/0.3.0/docs - This module's design record — documented inline in this README (a standalone
SCOPE.mdfile has not yet been promoted out of it as of this README)