Manages the full create/read/update/delete lifecycle of an Entra ID
groupobject (Microsoft Graph v1.0groupsendpoint) — security groups, Microsoft 365 (unified) groups, and dynamic-membership groups — via a singlemsgraph_resource.this, targetingmicrosoft/msgraph0.3.0.
- 🏢 Provisions an Entra ID group via
POST /groups, drift-detects and patches it thereafter, and deletes it onterraform destroy. Covers security groups, Microsoft 365 (unified) groups, and dynamic-membership groups through one keystone resource. - 🧱 Mirrors the Graph
groupresource type's documented writable surface as a deeply-typed set of scalarvariables.tfinputs — every optional property is rendered through explicit conditionals, so a caller cannot pass a malformed shape and still reach aplan. - 🔒 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
groupTypescollection from two friendlier inputs,group_type("security"|"unified") anddynamic_membership_enabled, rather than exposing the raw array directly. - 🚫 Owns no
for_eachchild 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
idoutput 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.
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!
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
Validated via the Mermaid Chart MCP before embedding (valid: true).
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
Validated via the Mermaid Chart MCP before embedding (valid: true).
Resource inventory
| Resource | Type | Role |
|---|---|---|
msgraph_resource.this | msgraph_resource | Keystone — full create/read/update/delete lifecycle of the Graph group at url = "groups" |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
microsoft/msgraph | 0.3.0, pinned exactly (pre-1.0 provider — no ~> constraint) |
| Graph API version | v1.0 (groups endpoint) |
| Provider block | None in this module — the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module |
Schema notes that bite
mailEnabledis 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. Themsgraph_resourceprovider'sbodyis a generic map, so Terraform itself cannot enforce a force-new replacement here; a caller who changesmail_enabledgets a Graph-side error atapply, not a plan-time replacement.mailNicknameandsecurityEnabledare 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 = truerequiresgroup_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'svalidation {}block catches this before any Graph call.groupTypesis create-only (absent from the PATCH-able property table) and is never exposed directly — it's derived fromgroup_type+dynamic_membership_enabled.isAssignableToRoleis 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 resultingvisibilityvalue (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 whengroup_type = "unified", and immutable once set."Private"/"Public"remain toggleable after creation (except whenis_assignable_to_role = true, which forces"Private").membershipRule/membershipRuleProcessingStateare 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.assignedLabelsrequires 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 withGroup.ManageProtection.All— application permissions are not supported for updating this specific property.uniqueNameis 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; seeSCOPE.md's Provider gotchas for the full citation trail.
| Operation | Application permission |
|---|---|
| Create | Group.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). |
| Read | Group.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) |
| Update | Group.ReadWrite.All |
| Delete | Group.ReadWrite.All |
Updating isAssignableToRole groups | Additionally 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 group | Group.ManageProtection.All (delegated only — application/app-only scenarios are not supported for this specific property update) |
All application permissions above require admin consent.
- 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_roleandassigned_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.
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
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 supportsowners@odata.bind, but this module deliberately excludes it — see 🧠 Architecture Notes). Chain agroup-ownermodule instance immediately after this one in real usage, or the group becomes effectively unmodifiable.
Consumes: none — group is a foundational identity module with no upstream dependency on another
module in this catalog.
Emits
| Output | Description | Consumed by |
|---|---|---|
id | Graph object id (GUID) of the created group — primary output | group-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_name | The group's displayName, as provided to this module | Callers needing a human-readable label for logs, notifications, or downstream naming |
mail_nickname | The group's mailNickname, as provided to this module | Rarely consumed downstream; emitted for completeness |
group_types | The 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) |
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 = trueis only accepted whengroup_type = "unified"— this module'svalidation {}block rejectsmail_enabled = truewith any othergroup_typeatplantime, 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 whengroup_type = "unified"— validated atplantime. It is also immutable once set; a later attempt to change it away from"HiddenMembership"fails only atapply, since this module cannot model per-field force-new behavior against a genericbodymap.
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_stateis leftnullhere —main.tfrenders it as"On"wheneverdynamic_membership_enabled = trueand 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 = truerequiressecurity_enabled = trueandvisibility = "Private", enforced by a cross-fieldvalidation {}block — Graph itself would otherwise reject an inconsistent combination only atapply. 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_labelsmaps a caller-chosen key to a Graph sensitivity label'slabelIdGUID only —displayNameis 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 failsterraform validate/plan, notapply—mail_enabled = truerequiresgroup_type = "unified". Compare this to 🧪 Testing, below: this specific combination is caught for free; amail_nicknamethat collides with an existing group in the tenant is not, because that requires a live Graph lookup this module cannot perform atplantime.
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
countmeans adding or removing one department group fromvar.department_groupsnever 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 broaderGroup.ReadWrite.All/Directory.ReadWrite.Allalternatives. 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_nicknameandsecurity_enabledare both confirmed mutable via PATCH (see ✅ Provider / Versions above) — changing either updates the existing group in place rather than replacing it. Contrast this withmail_enabledandgroup_type(immutable after creation) andvisibility(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, andconditional-access-policyare authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without anydepends_on: all three downstream calls takemodule.engineering_group.idas 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 atapplytime regardless of Terraform's ordering guarantee (see 🔍 Troubleshooting).
Grouped summary
| Group | Variables |
|---|---|
| Required at create | display_name, mail_nickname |
| Group kind | mail_enabled (default false), security_enabled (default true), group_type (default "security") |
| Dynamic membership | dynamic_membership_enabled (default false), membership_rule, membership_rule_processing_state |
| Role assignability | is_assignable_to_role (default false) |
| Visibility | visibility (default "Private") |
| Sensitivity labels | assigned_labels (default {}) |
Full variable reference (11 inputs)
| Variable | Type | Default | Validation | Graph property |
|---|---|---|---|---|
display_name | string | — (required) | 1–256 characters | displayName |
mail_nickname | string | — (required) | 1–64 characters; ASCII only, excluding @ \ [ ] ";: < >, SPACE | mailNickname |
mail_enabled | bool | false | Must be false, or group_type = "unified" | mailEnabled |
security_enabled | bool | true | none | securityEnabled |
group_type | string | "security" | One of "security", "unified" | groupTypes (derived) |
dynamic_membership_enabled | bool | false | Must be false whenever is_assignable_to_role = true | groupTypes (derived — adds "DynamicMembership") |
membership_rule | string | null | Must be non-empty whenever dynamic_membership_enabled = true | membershipRule |
membership_rule_processing_state | string | null | One of "On", "Paused", or null | membershipRuleProcessingState |
is_assignable_to_role | bool | false | Requires security_enabled = true and visibility = "Private" whenever true | isAssignableToRole |
visibility | string | "Private" | One of "Private", "Public", "HiddenMembership"; "HiddenMembership" requires group_type = "unified" | visibility |
assigned_labels | map(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.
| Output | Description | Sensitive / excluded? |
|---|---|---|
id | Graph object id (GUID) of the created group — primary output | No |
display_name | The group's displayName, as provided to this module | No |
mail_nickname | The group's mailNickname, as provided to this module | No |
group_types | The group's rendered groupTypes collection | No |
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).
- This module owns no
for_eachchildren, 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 siblinggroup-membershipandgroup-owneraggregation modules — not by this module.SCOPE.mddocuments this explicitly as "composite (anchor-only, no owned children in this module)." A future maintainer should not expectmain.tfto ever grow afor_eachchild 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-ownermodule instance immediately after this module's group resource is mandatory in real usage, not optional. This module intentionally does not support inlineowners@odata.bindbinding at creation (see 🔌 Cross-Module Contract's "Out of scope" andSCOPE.md's Design decisions) — a deliberate boundary decision, not an oversight. Graph's own documented behavior is that a group created via theGroup.Createapplication 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.idflowing into agroup-ownermodule instance) that every real Terraform configuration consuming this module should replicate. mailNicknameandsecurityEnabledare 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 currentvariables.tfmodels both as normaloptional(...)-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 resultingvisibility— 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/membershipRuleProcessingStateupdate-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. SeeSCOPE.md's Provider gotchas for full citations. for_each, nevercount, is the pattern this module's example library uses for scaling to multiple groups (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 group when one is added or removed.
| Concern | Secure default in this module | Opt-out (caller must be explicit) |
|---|---|---|
| Group visibility | visibility defaults to "Private" | Caller sets visibility = "Public" explicitly |
Group mailEnabled / securityEnabled | security_enabled = true, mail_enabled = false — a plain security group | Caller sets group_type = "unified" and mail_enabled = true to opt into a mail-enabled Microsoft 365 group |
| Role-assignable groups | is_assignable_to_role defaults to false | Caller 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 membership | dynamic_membership_enabled defaults to false — static membership managed by the sibling group-membership module | Caller sets dynamic_membership_enabled = true and supplies a membership_rule |
| Inline owner/member binding | Never exposed — this module always produces a group with no bound owners/members at creation | Not available in this module by design; chain a group-owner/group-membership module instance instead (see 🧠 Architecture Notes) |
| Sensitivity label assignment | Defaults to {} (no labels) | Caller supplies assigned_labels explicitly |
| Sensitive outputs | No property on the group resource type carries a secret value — nothing to exclude | N/A |
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-group.git?ref=v1.0.0"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 passesvalidate/plancleanly, but fails only atapplyif 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 atplantime.- Changing
mail_enabledorgroup_typeon an existing group satisfies this module's type system entirely (both are plain scalar variables) but fails only atapply, since both are Graph-documented as create-only/immutable and this module cannot model per-field force-new behavior against a genericbodymap. - A
membership_rulewith valid Terraform string syntax but invalid Entra ID dynamic-membership rule syntax (e.g. a typo'd property name) passesvalidate/plancleanly and is rejected only atapply, since validating dynamic-membership rule grammar would require replicating Graph's own rule parser. assigned_labelsvalues that aren't reallabelIdGUIDs for sensitivity labels actually published in the tenant pass this module's typing (plain strings) and fail only atapply.
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.
$ terraform output
id = "b320ee12-b1cd-4cca-b648-a437be61c5cd"
display_name = "Engineering"
mail_nickname = "engineering"
group_types = []
| Symptom | Cause | Fix |
|---|---|---|
apply fails on create with a 400 referencing mailNickname | The 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 groupTypes | Both are Graph-documented as create-only/immutable — this module cannot enforce that as a Terraform-level force-new against a generic body map | Do 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 object | Graph's documented eventual-consistency delay after group creation — the new group hasn't fully replicated across directory replicas yet | Retry 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 afterward | Expected, by design, if no group-owner module instance was chained immediately after — this module never binds an owner inline | Always 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 = true | The calling principal isn't assigned the Privileged Role Administrator directory role, or lacks RoleManagement.ReadWrite.Directory | Assign the calling principal the required directory role and permission before applying |
apply fails with a 400 on assigned_labels | Missing Entra ID P1 license, or (for a cloud security group) attempting to change a label that's already immutable once set | Confirm 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 differs | The changed property is one of the properties Graph stores outside its main data store (e.g. allowExternalSenders, autoSubscribeNewMembers) — out of this module's scope entirely | Confirm which properties this module actually manages (see 📥 Inputs); properties outside this module's scope must be managed by a separate call outside this module |
- Graph API reference —
groupresource type: https://learn.microsoft.com/graph/api/resources/group?view=graph-rest-1.0 - Graph API reference — create group: https://learn.microsoft.com/graph/api/group-post-groups?view=graph-rest-1.0
- Graph API reference — update group (PATCH-able property table): https://learn.microsoft.com/graph/api/group-update?view=graph-rest-1.0
- Graph API reference — manage groups overview (group-kind support matrix, dynamic membership): https://learn.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-1.0
- Graph API reference —
assignedLabelresource type: https://learn.microsoft.com/graph/api/resources/assignedlabel?view=graph-rest-1.0 - Provider docs —
microsoft/msgraph0.3.0: https://registry.terraform.io/providers/microsoft/msgraph/0.3.0/docs - This module's design record —
SCOPE.md - Sibling module (real, published) —
terraform-msgraph-user - Sibling modules (planned catalog entries — SCOPE.md only as of this README) —
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,terraform-msgraph-administrative-unit