Skip to content

Repository files navigation

🔷 Microsoft Graph Group Terraform Module

Manages the full create/read/update/delete lifecycle of an Entra ID group object (Microsoft Graph v1.0 groups endpoint) — security groups, Microsoft 365 (unified) groups, and dynamic-membership groups — via a single msgraph_resource.this, targeting microsoft/msgraph0.3.0.

TerraformmsgraphModuleTypeResources


🧩 Overview

  • 🏢 Provisions an Entra ID group via POST /groups, drift-detects and patches it thereafter, and deletes it on terraform destroy. Covers security groups, Microsoft 365 (unified) groups, and dynamic-membership groups through one keystone resource.
  • 🧱 Mirrors the Graph group resource type's documented writable surface as a deeply-typed set of scalar variables.tf inputs — every optional property is rendered through explicit conditionals, so a caller cannot pass a malformed shape and still reach a plan.
  • 🔒 Defaults every new group to a private, non-mail-enabled, non-role-assignable security group (security_enabled = true, mail_enabled = false, visibility = "Private", is_assignable_to_role = false) — see 🧱 Design Principles.
  • 🧬 Derives Graph's raw groupTypes collection from two friendlier inputs, group_type ("security" | "unified") and dynamic_membership_enabled, rather than exposing the raw array directly.
  • 🚫 Owns no for_each child resources despite being catalogued as "composite" in this suite's module suite table — group ownership and membership are independent Graph relationships owned by sibling aggregation modules, never inlined here. See 🧠 Architecture Notes for why this matters operationally.
  • 🆔 Its id output is the reference type the largest share of this catalog's downstream modules will eventually consume — see 🗺️ Where this fits.

💡 Why it matters: a group provisioned without an owner (this module intentionally does not support inline owner binding) becomes, in Graph's own documented behavior, effectively unmodifiable through normal delegated or application flows. This module's contract — and its mandatory end-to-end example below — exists specifically to make chaining an owner assignment immediately after group creation the obvious, natural next step, not an easily-skipped afterthought.


❤️ 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

group has no upstream Consumes — it is a foundational identity entity, like terraform-msgraph-user (the only other module in this repository with real, authored .tf files as of this module's authoring — confirmed by directory listing). Every other module referenced below — terraform-msgraph-group-membership, terraform-msgraph-group-owner, terraform-msgraph-conditional-access-policy, terraform-msgraph-team, terraform-msgraph-app-role-assignment, terraform-msgraph-directory-role-assignment, terraform-msgraph-device-configuration-assignment, and terraform-msgraph-administrative-unit — currently exists only as a SCOPE.md scaffold (confirmed by directory listing: each folder contains only SCOPE.md and no .tf files). The diagram below reflects this suite's module suite table and this module's own SCOPE.md Emits table as planned catalog coverage, not published, consumable modules yet.

flowchart LR
Graph["Microsoft Graph<br/>Entra ID tenant<br/>(POST/GET/PATCH/DELETE /groups)"]:::external
Group["terraform-msgraph-group<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
CAP["terraform-msgraph-conditional-access-policy<br/>(planned catalog entry — not yet authored)"]:::sibling
Team["terraform-msgraph-team<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
DCA["terraform-msgraph-device-configuration-assignment<br/>(planned catalog entry — not yet authored)"]:::sibling
AU["terraform-msgraph-administrative-unit<br/>(planned catalog entry — not yet authored)"]:::sibling
Group -->|"manages lifecycle of"| Graph
Group -->|"id consumed as membership target"| GM
Group -->|"id consumed as ownership target"| GO
Group -->|"id consumed for include/exclude group scoping"| CAP
Group -->|"id consumed as underlying unified group"| Team
Group -->|"id consumed as assignee principal"| ARA
Group -->|"id consumed as role-assignment principal"| DRA
Group -->|"id consumed as assignment target"| DCA
Group -->|"id consumed for scoped-role-member assignment"| AU
classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
classDef sibling fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
classDef external fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

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


🧬 What this builds

A single keystone resource — no for_each children. Per SCOPE.md's Design decisions, this module is "composite (anchor-only, no owned children in this module)": it is the anchor several sibling aggregation modules attach to, but it does not itself render any child resource block in main.tf.

flowchart TD
subgraph Inputs["Caller inputs"]
I1["display_name, mail_nickname<br/>(required)"]:::neutral
I2["mail_enabled, security_enabled,<br/>group_type, dynamic_membership_enabled<br/>(optional, secure defaults)"]:::neutral
I3["membership_rule, membership_rule_processing_state<br/>(optional — dynamic groups only)"]:::neutral
I4["is_assignable_to_role, visibility<br/>(optional, secure defaults)"]:::neutral
I5["assigned_labels<br/>(optional map)"]:::neutral
end
This["msgraph_resource.this<br/>url = groups"]:::thisModule
subgraph Outputs["Outputs"]
O1["id (primary)"]:::neutral
O2["display_name"]:::neutral
O3["mail_nickname"]:::neutral
O4["group_types"]:::neutral
end
I1 -->|"rendered into body"| This
I2 -->|"rendered into body"| This
I3 -->|"rendered into body when dynamic"| This
I4 -->|"rendered into body"| This
I5 -->|"rendered into body via for_each map"| This
This -->|"msgraph_resource.this.id"| O1
This -.->|"echoed from var.display_name"| O2
This -.->|"echoed from var.mail_nickname"| O3
This -.->|"echoed from local.group_types"| O4
classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
classDef neutral fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

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

Resource inventory

ResourceTypeRole
msgraph_resource.thismsgraph_resourceKeystone — full create/read/update/delete lifecycle of the Graph group at url = "groups"

✅ Provider / Versions

ItemValue
Terraform>= 1.12.0
microsoft/msgraph0.3.0, pinned exactly (pre-1.0 provider — no ~> constraint)
Graph API versionv1.0 (groups endpoint)
Provider blockNone in this module — the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module

Schema notes that bite

  • mailEnabled is create-only. Not present in the live "Update group" PATCH-able property table (https://learn.microsoft.com/graph/api/group-update?view=graph-rest-1.0#request-body) — treat it as immutable after creation. The msgraph_resource provider's body is a generic map, so Terraform itself cannot enforce a force-new replacement here; a caller who changes mail_enabled gets a Graph-side error at apply, not a plan-time replacement.
  • mailNickname and securityEnabled are genuinely mutable — RESOLVED, confirmed against a live fetch of the same "Update group" reference this authoring pass. Both appear in its PATCH-able property table with no restriction beyond the standard permission set. Modeled as normal mutable fields, not force-new.
  • mail_enabled = true requires group_type = "unified" — enforced at plan time. Confirmed against https://learn.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-1.0's group-kind support matrix: mail-enabled security groups and distribution groups are read-only via Graph — not creatable through this API. var.mail_enabled's validation {} block catches this before any Graph call.
  • groupTypes is create-only (absent from the PATCH-able property table) and is never exposed directly — it's derived from group_type + dynamic_membership_enabled.
  • isAssignableToRole is settable only at creation, immutable after, and requires the Privileged Role Administrator directory role, RoleManagement.ReadWrite.Directory, and an Entra ID P1 license. Graph's own docs disagree with themselves about the resulting visibility value (one page says "Hidden," the property reference and the worked create example both show "Private") — this module follows "Private" and validates accordingly.
  • visibility = "HiddenMembership" is create-only and Microsoft-365-only. Settable only at creation, only when group_type = "unified", and immutable once set. "Private"/"Public" remain toggleable after creation (except when is_assignable_to_role = true, which forces "Private").
  • membershipRule/membershipRuleProcessingState are a documented Graph-docs inconsistency. Absent from the "Update group" PATCH-able property table, yet Microsoft's own "Manage groups in Microsoft Graph" overview explicitly demonstrates updating a group from static to dynamic membership using both properties. This module treats them as normal mutable optional scalars, following the overview doc.
  • assignedLabels requires Entra ID P1 and is immutable once set for cloud security groups; for Microsoft 365 groups it can be updated later, but only in a delegated scenario with Group.ManageProtection.All — application permissions are not supported for updating this specific property.
  • uniqueName is deliberately out of scope — Graph's own docs disagree across three separate pages about whether it's read-only, write-once-from-null, or required. Not exposed as a variable in this module; see SCOPE.md's Provider gotchas for the full citation trail.

🔑 Graph API Permissions Required

OperationApplication permission
CreateGroup.Create (least privilege); broader: Group.ReadWrite.All, Directory.ReadWrite.All. Creating a group with inline owners/members would additionally require User.Read.All and/or Application.Read.All — not applicable here since inline owner/member binding is out of this module's scope (see 🧠 Architecture Notes).
ReadGroup.Read.All (Graph's own docs list a narrower Group-NestingSupport.ReadWrite.All as "least privileged" for some read/write operations — a documented Graph-docs quirk; Group.Read.All is the practical default)
UpdateGroup.ReadWrite.All
DeleteGroup.ReadWrite.All
Updating isAssignableToRole groupsAdditionally requires RoleManagement.ReadWrite.Directory and the Privileged Role Administrator directory role — layered on top of the application permission above, not a substitute for it
Updating assignedLabels on a cloud security groupGroup.ManageProtection.All (delegated only — application/app-only scenarios are not supported for this specific property update)

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 for standard security/M365 groups. Entra ID P1 required for is_assignable_to_role and assigned_labels. Entra ID P1 is also required per unique user evaluated into a dynamic-membership group (dynamic_membership_enabled = true).
  • Admin consent: required for every application permission listed above.

📁 Module Structure

terraform-msgraph-group/
├── providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned exactly at 0.3.0;
│ # no provider {} block
├── variables.tf # 11 deeply-typed scalar inputs mirroring the Graph v1.0 group resource type
├── main.tf # msgraph_resource.this (keystone, url = "groups") — no for_each children
├── outputs.tf # id (primary), display_name, mail_nickname, group_types
├── README.md # this file
├── SCOPE.md # design intent, in/out of scope, permissions, prerequisites, provider gotchas,
│ # design decisions — the cross-module contract for this module
└── examples/
└── basic/
└── main.tf # smallest real call — the two 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"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Engineering"mail_nickname="engineering"# Every secure default is left in place deliberately: security_enabled = true,# mail_enabled = false, visibility = "Private", is_assignable_to_role = false.
}
output"group_id" {
value=module.group.id
}

⚠️ This module never binds owners inline at creation (Graph supports owners@odata.bind, but this module deliberately excludes it — see 🧠 Architecture Notes). Chain a group-owner module instance immediately after this one in real usage, or the group becomes effectively unmodifiable.


🔌 Cross-Module Contract

Consumes: none — group is a foundational identity module with no upstream dependency on another module in this catalog.

Emits

OutputDescriptionConsumed by
idGraph object id (GUID) of the created group — primary outputgroup-membership, group-owner, conditional-access-policy (include/exclude group scoping), team (provisioned atop an existing unified group), app-role-assignment, directory-role-assignment, device-configuration-assignment, administrative-unit (scoped-role-member assignment). All planned catalog entries — not yet authored as .tf files in this repository as of this module's authoring.
display_nameThe group's displayName, as provided to this moduleCallers needing a human-readable label for logs, notifications, or downstream naming
mail_nicknameThe group's mailNickname, as provided to this moduleRarely consumed downstream; emitted for completeness
group_typesThe group's rendered groupTypes collection (e.g. ["Unified"], [], ["DynamicMembership"])Consuming modules that branch behavior on group kind (e.g. group-membership's member-type validation)

📚 Example Library

1 · Minimal required call
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Engineering"mail_nickname="engineering"
}

ℹ️ Only the two Graph-required properties are set. Every other input is left at its secure default: a private, non-mail-enabled, non-role-assignable security group with static membership.

2 · Explicit security group (spelling out the defaults)
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Finance Approvers"mail_nickname="finance-approvers"group_type="security"security_enabled=truemail_enabled=falsevisibility="Private"
}
3 · Microsoft 365 (unified) group, opting into mail-enabled
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Library Assist"mail_nickname="library-assist"group_type="unified"mail_enabled=true
}

⚠️mail_enabled = true is only accepted when group_type = "unified" — this module's validation {} block rejects mail_enabled = true with any other group_type at plan time, since Graph only supports creating/managing mail-enabled groups as Microsoft 365 groups (mail-enabled security groups and distribution groups are read-only via Graph).

4 · Public visibility (opting out of the private default)
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Company All-Hands Discussion"mail_nickname="all-hands-discussion"group_type="unified"mail_enabled=truevisibility="Public"
}
5 · Hidden-membership Microsoft 365 group (set only at creation)
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Executive Leadership"mail_nickname="exec-leadership"group_type="unified"mail_enabled=truevisibility="HiddenMembership"
}

🔒 visibility = "HiddenMembership" is only accepted when group_type = "unified" — validated at plan time. It is also immutable once set; a later attempt to change it away from "HiddenMembership" fails only at apply, since this module cannot model per-field force-new behavior against a generic body map.

6 · Dynamic-membership Microsoft 365 group
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Marketing Department"mail_nickname="marketing"group_type="unified"mail_enabled=truedynamic_membership_enabled=truemembership_rule="user.department -eq \"Marketing\""
}

💡 membership_rule_processing_state is left null here — main.tf renders it as "On" whenever dynamic_membership_enabled = true and the caller hasn't set an explicit preference.

7 · Dynamic-membership security group, paused on creation
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="US Employees (auto-managed)"mail_nickname="us-employees-auto"dynamic_membership_enabled=truemembership_rule="(user.country -eq \"United States\")"membership_rule_processing_state="Paused"
}

ℹ️ Setting membership_rule_processing_state = "Paused" creates the group with its dynamic rule defined but not yet actively evaluated — useful when a rule needs review before it starts adding or removing real members.

8 · Role-assignable group
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Helpdesk Administrators"mail_nickname="helpdesk-admins"group_type="unified"is_assignable_to_role=true# security_enabled and visibility are left at their defaults (true / "Private") — required by# is_assignable_to_role's validation{} block.
}

🔒 is_assignable_to_role = true requires security_enabled = true and visibility = "Private", enforced by a cross-field validation {} block — Graph itself would otherwise reject an inconsistent combination only at apply. Also requires the Privileged Role Administrator directory role, RoleManagement.ReadWrite.Directory, and an Entra ID P1 license (see Microsoft Graph Prerequisites).

9 · Sensitivity-labeled group
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Confidential Projects"mail_nickname="confidential-projects"group_type="unified"mail_enabled=trueassigned_labels={
confidential ="3f8f2e1a-0000-4c9d-9a1b-1234567890ab"
}
}

ℹ️ assigned_labels maps a caller-chosen key to a Graph sensitivity label's labelId GUID only — displayName is Graph-computed and never a caller input. Requires Entra ID P1. Immutable once set for cloud security groups.

10 · A validation failure this module catches before any Graph call
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Bad Example"mail_nickname="bad-example"mail_enabled=true# fails: group_type defaults to "security", not "unified"
}

⚠️ This fails terraform validate/plan, not applymail_enabled = true requires group_type = "unified". Compare this to 🧪 Testing, below: this specific combination is caught for free; a mail_nickname that collides with an existing group in the tenant is not, because that requires a live Graph lookup this module cannot perform at plan time.

11 · for_each at scale — creating multiple groups from a map
variable"department_groups" {
type=map(object({
display_name =string
mail_nickname =string
}))
}
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"for_each=var.department_groupsdisplay_name=each.value.display_namemail_nickname=each.value.mail_nickname
}
output"department_group_ids" {
value={ fork, minmodule.group:k=>m.id }
}

💡 Keying by a stable map key (e.g. a department code) rather than count means adding or removing one department group from var.department_groups never forces Terraform to re-index — and therefore never re-plans — every other group 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: Group.Create (not Group.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"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Read-Only Reporting"mail_nickname="readonly-reporting"
}

🔒 See 🔑 Graph API Permissions Required — a pipeline that only ever creates groups needs Group.Create, not the broader Group.ReadWrite.All/Directory.ReadWrite.All alternatives. Reserve the broader grants for pipelines that also update or delete groups.

13 · Renaming a group's mail nickname (RESOLVED mutable field)
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Engineering"mail_nickname="engineering-team"# changed from "engineering" — updates in place via PATCH
}

💡 mail_nickname and security_enabled are both confirmed mutable via PATCH (see ✅ Provider / Versions above) — changing either updates the existing group in place rather than replacing it. Contrast this with mail_enabled and group_type (immutable after creation) and visibility (only "HiddenMembership" is create-only).

14 · Combining several optional properties in one realistic call
module"group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Contoso Partners"mail_nickname="contoso-partners"group_type="unified"mail_enabled=truevisibility="Private"assigned_labels={
general ="8a1e4b2c-1111-4c9d-9a1b-abcdef123456"
}
}
15 · 🏗️ End-to-end composition — a group provisioned and immediately given an owner

This module has no upstream Consumes, so its mandatory end-to-end example instead shows the realistic downstream chain that also doubles as the operational mitigation for this module's most important gotcha: a group created here with no inline owner binding must be followed by a group-owner module instance, or it becomes effectively unmodifiable through normal delegated or application flows (see 🧠 Architecture Notes). terraform-msgraph-group-owner, terraform-msgraph-group-membership, and terraform-msgraph-conditional-access-policy are planned but not yet authored as of this README (confirmed by directory listing — each currently has only SCOPE.md/prompt files, no .tf). The shape below is illustrative of the intended contract, not a call against a published module today.

module"engineering_group" {
source="git::https://github.com/microsoftexpert/terraform-msgraph-group.git?ref=v1.0.0"display_name="Engineering"mail_nickname="engineering"group_type="security"
}
# Illustrative only — terraform-msgraph-group-owner is a planned catalog entry, not yet authored.# Chaining this immediately after group creation is MANDATORY in real usage — this module never binds# an owner inline, and an ownerless group becomes effectively unmodifiable.# 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 = var.engineering_lead_user_id# }# Illustrative only — terraform-msgraph-group-membership is a planned catalog entry, not yet authored.# module "engineering_team_members" {# source = "git::https://github.com/microsoftexpert/terraform-msgraph-group-membership.git?ref=v1.0.0"## group_id = module.engineering_group.id# principal_ids = var.engineering_team_user_ids# }# Illustrative only — terraform-msgraph-conditional-access-policy is a planned catalog entry, not yet# authored.# module "block_legacy_auth_for_engineering" {# source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"## display_name = "Block legacy authentication — Engineering"# state = "enabled"# include_group_ids = [module.engineering_group.id]# client_app_types = ["exchangeActiveSync", "other"]# grant_control_operator = "OR"# built_in_controls = ["block"]# }

💡 Once group-owner, group-membership, and conditional-access-policy are authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without any depends_on: all three downstream calls take module.engineering_group.id as an input, so Terraform will not attempt any of them before the group exists. Graph's own eventual-consistency delay after group creation still applies at apply time regardless of Terraform's ordering guarantee (see 🔍 Troubleshooting).


📥 Inputs

Grouped summary

GroupVariables
Required at createdisplay_name, mail_nickname
Group kindmail_enabled (default false), security_enabled (default true), group_type (default "security")
Dynamic membershipdynamic_membership_enabled (default false), membership_rule, membership_rule_processing_state
Role assignabilityis_assignable_to_role (default false)
Visibilityvisibility (default "Private")
Sensitivity labelsassigned_labels (default {})
Full variable reference (11 inputs)
VariableTypeDefaultValidationGraph property
display_namestring— (required)1–256 charactersdisplayName
mail_nicknamestring— (required)1–64 characters; ASCII only, excluding @ \ [ ] ";: < >, SPACEmailNickname
mail_enabledboolfalseMust be false, or group_type = "unified"mailEnabled
security_enabledbooltruenonesecurityEnabled
group_typestring"security"One of "security", "unified"groupTypes (derived)
dynamic_membership_enabledboolfalseMust be false whenever is_assignable_to_role = truegroupTypes (derived — adds "DynamicMembership")
membership_rulestringnullMust be non-empty whenever dynamic_membership_enabled = truemembershipRule
membership_rule_processing_statestringnullOne of "On", "Paused", or nullmembershipRuleProcessingState
is_assignable_to_roleboolfalseRequires security_enabled = true and visibility = "Private" whenever trueisAssignableToRole
visibilitystring"Private"One of "Private", "Public", "HiddenMembership"; "HiddenMembership" requires group_type = "unified"visibility
assigned_labelsmap(string){}none (values are opaque labelId GUIDs)assignedLabels

Deliberately excluded from this schema (see SCOPE.md's "Out of scope" and "Design decisions"): owners@odata.bind/members@odata.bind (separate group-owner/group-membership aggregation modules), uniqueName (Graph's own docs disagree with themselves about its mutability — see ✅ Provider / Versions), description, classification, and other Graph-documented group properties not in this module's authored scope.


🧾 Outputs

OutputDescriptionSensitive / excluded?
idGraph object id (GUID) of the created group — primary outputNo
display_nameThe group's displayName, as provided to this moduleNo
mail_nicknameThe group's mailNickname, as provided to this moduleNo
group_typesThe group's rendered groupTypes collectionNo

No property on the group resource type carries a secret value — there is no excluded/sensitive output category to document for this module (contrast with terraform-msgraph-user, which excludes a create-time password).


🧠 Architecture Notes

  • This module owns no for_each children, despite being catalogued as "composite" in this suite's module suite table. Applying this suite's own composite-vs-aggregation decision test to this entity's actual property set yields zero owned children: group membership and ownership are both independent, addressable Graph relationships (groups/{id}/members/$ref, groups/{id}/owners/$ref) with their own create/delete lifecycle, owned by the sibling group-membership and group-owner aggregation modules — not by this module. SCOPE.md documents this explicitly as "composite (anchor-only, no owned children in this module)." A future maintainer should not expect main.tf to ever grow a for_each child resource block; if group-owned child data ever needs modeling, it belongs in a sibling aggregation module, per this suite's "when in doubt, default to aggregation" guidance.
  • Chaining a group-owner module instance immediately after this module's group resource is mandatory in real usage, not optional. This module intentionally does not support inline owners@odata.bind binding at creation (see 🔌 Cross-Module Contract's "Out of scope" and SCOPE.md's Design decisions) — a deliberate boundary decision, not an oversight. Graph's own documented behavior is that a group created via the Group.Create application permission without specifying an owner becomes effectively unmodifiable through normal delegated or application flows. Example 15's end-to-end composition shows the real dependency chain (module.engineering_group.id flowing into a group-owner module instance) that every real Terraform configuration consuming this module should replicate.
  • mailNickname and securityEnabled are RESOLVED as genuinely mutable, not force-new — confirmed against a fresh, live fetch of the official "Update group" v1.0 reference during this module's authoring (https://learn.microsoft.com/graph/api/group-update?view=graph-rest-1.0#request-body). Both appear in its PATCH-able property table with no restriction beyond the standard permission set. This reverses an earlier draft assumption (default-to-force-new-for-safety) that had been based on unconfirmed community consensus rather than the current Graph reference page — the current variables.tf models both as normal optional(...)-style mutable fields.
  • Two new Graph-docs inconsistencies surfaced this authoring pass, both resolved with an explicit, cited judgment call rather than silently picking a reading: (1) isAssignableToRole = true's resulting visibility — one Graph doc page's prose says "Hidden," but the property reference and the worked create example both show "Private"; this module follows "Private" and validates accordingly. (2) membershipRule/membershipRuleProcessingState update-ability — absent from the canonical "Update group" PATCH-able property table, yet demonstrated as settable in Microsoft's own dynamic-membership overview walkthrough; this module treats both as mutable. See SCOPE.md's Provider gotchas for full citations.
  • for_each, never count, is the pattern this module's example library uses for scaling to multiple groups (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 group when one is added or removed.

🧱 Design Principles

ConcernSecure default in this moduleOpt-out (caller must be explicit)
Group visibilityvisibility defaults to "Private"Caller sets visibility = "Public" explicitly
Group mailEnabled / securityEnabledsecurity_enabled = true, mail_enabled = false — a plain security groupCaller sets group_type = "unified" and mail_enabled = true to opt into a mail-enabled Microsoft 365 group
Role-assignable groupsis_assignable_to_role defaults to falseCaller sets is_assignable_to_role = true explicitly (also requires security_enabled = true, visibility = "Private", Entra ID P1, and a privileged directory role — all validated or documented)
Dynamic membershipdynamic_membership_enabled defaults to false — static membership managed by the sibling group-membership moduleCaller sets dynamic_membership_enabled = true and supplies a membership_rule
Inline owner/member bindingNever exposed — this module always produces a group with no bound owners/members at creationNot available in this module by design; chain a group-owner/group-membership module instance instead (see 🧠 Architecture Notes)
Sensitivity label assignmentDefaults to {} (no labels)Caller supplies assigned_labels explicitly
Sensitive outputsNo property on the group resource type carries a secret value — nothing to excludeN/A

🚀 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-group.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 (group_type, visibility, membership_rule_processing_state) is restricted to its legal values, and every cross-field constraint this module can express in HCL (mail_enabled requiring group_type = "unified"; is_assignable_to_role requiring security_enabled and visibility = "Private"; dynamic_membership_enabled requiring a non-empty membership_rule; visibility = "HiddenMembership" requiring group_type = "unified") rejects an inconsistent call 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 group is. Concretely, in this module:

  • mail_nickname = "already-taken" satisfies every shape/charset validation and passes validate/plan cleanly, but fails only at apply if that mail nickname is already in use by another Microsoft 365 group in the tenant — this requires a live Graph lookup this module cannot perform at plan time.
  • Changing mail_enabled or group_type on an existing group satisfies this module's type system entirely (both are plain scalar variables) but fails only at apply, since both are Graph-documented as create-only/immutable and this module cannot model per-field force-new behavior against a generic body map.
  • A membership_rule with valid Terraform string syntax but invalid Entra ID dynamic-membership rule syntax (e.g. a typo'd property name) passes validate/plan cleanly and is rejected only at apply, since validating dynamic-membership rule grammar would require replicating Graph's own rule parser.
  • assigned_labels values that aren't real labelId GUIDs for sensitivity labels actually published in the tenant pass this module's typing (plain strings) and fail only at apply.

This module's scalar typing, closed-enum validation {} blocks, and cross-field validation {} blocks are the only thing standing between the caller and a Graph 400 error at apply time for anything beyond these four categories.


💬 Example Output

$ terraform output
id = "b320ee12-b1cd-4cca-b648-a437be61c5cd"
display_name = "Engineering"
mail_nickname = "engineering"
group_types = []

🔍 Troubleshooting

SymptomCauseFix
apply fails on create with a 400 referencing mailNicknameThe mail nickname is already in use by another Microsoft 365 group in the tenant, or contains a disallowed character this module's regex validation didn't catch (an edge case in Graph's ASCII exclusion list)Choose a different, tenant-unique mail_nickname; confirm the exact disallowed-character list against the current Graph reference
apply fails on an update touching mailEnabled or groupTypesBoth are Graph-documented as create-only/immutable — this module cannot enforce that as a Terraform-level force-new against a generic body mapDo not change mail_enabled or group_type on an existing group; if the group kind genuinely needs to change, provision a new group and migrate members/owners
A group-owner/group-membership module instance chained immediately after this module's group returns a 400 referencing a nonexistent source objectGraph's documented eventual-consistency delay after group creation — the new group hasn't fully replicated across directory replicas yetRetry after a brief delay; do not assume instant read-after-write consistency for a just-created group
Group is created but no one can manage it afterwardExpected, by design, if no group-owner module instance was chained immediately after — this module never binds an owner inlineAlways chain a group-owner module instance (see Example 15) — a group created via Group.Create without an owner becomes effectively unmodifiable
apply fails with a 403 on is_assignable_to_role = trueThe calling principal isn't assigned the Privileged Role Administrator directory role, or lacks RoleManagement.ReadWrite.DirectoryAssign the calling principal the required directory role and permission before applying
apply fails with a 400 on assigned_labelsMissing Entra ID P1 license, or (for a cloud security group) attempting to change a label that's already immutable once setConfirm Entra ID P1 licensing; for cloud security groups, provision a new group rather than attempting to change an already-set label
Plan shows no diff but the actual tenant group differsThe changed property is one of the properties Graph stores outside its main data store (e.g. allowExternalSenders, autoSubscribeNewMembers) — out of this module's scope entirelyConfirm which properties this module actually manages (see 📥 Inputs); properties outside this module's scope must be managed by a separate call outside this module

🔗 Related Docs

Releases

Packages

Contributors

Languages