Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”· Microsoft Graph Conditional Access Policy Terraform Module

Manages a single Microsoft Entra Conditional Access policy (identity/conditionalAccess/policies) as deeply-typed, secure-by-default Terraform, rendering the entire conditions / grantControls structure inline and surfacing Graph's cross-field business rules at plan time. Built on the microsoft/msgraph provider, pinned exactly at 0.3.0, targeting Graph v1.0.

Terraform microsoft/msgraph module type resources secure default


🧩 Overview

This module manages one Conditional Access (CA) policy β€” the Entra ID access-control keystone that evaluates sign-in conditions at token issuance and applies grant controls.

  • 🎯 One keystone resource β€” msgraph_resource.this at identity/conditionalAccess/policies, full create/read/update/delete lifecycle.
  • 🧱 The entire conditions / grantControls structure, inline β€” applications, users XOR client applications, client app types, platforms, locations, devices, sign-in/user/service-principal/insider risk levels, authentication flows, and the grant controls (operator, built-in controls, authentication strength, terms of use, custom controls).
  • πŸ›‘οΈ Report-only by default β€” an empty-ish call is created in enabledForReportingButNotEnforced state; the caller must type state = "enabled" to enforce.
  • πŸ§ͺ Six cross-field business rules as plan-time precondition blocks β€” Graph enforces these server-side; this module surfaces them at plan instead of as a Graph 400 at apply.
  • πŸ”’ No secrets β€” every reference (named locations, authentication strength policy, applications, groups, users, roles, service principals) is an opaque GUID consumed by id; nothing secret-bearing enters or leaves the module.

πŸ’‘ Why it matters: the msgraph provider's body is a generic, untyped map β€” it will happily plan a typo'd property, a wrong-typed value, or an illegal cross-field combination and fail only at apply against a live tenant. This module's typed object schema and six precondition blocks are the only thing standing between a caller and a Graph 400 (or, worse, a policy that silently locks people out).


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


πŸ—ΊοΈ Where this fits

This module is the consumer at the end of several Consumes-by-id relationships. Every id it references is produced by a sibling module and wired in through Terraform's dependency graph β€” so the referenced entity is always created before the policy that points at it.

flowchart LR
 nl["terraform-msgraph-named-location"]
 asp["terraform-msgraph-authentication-strength-policy"]
 grp["terraform-msgraph-group"]
 usr["terraform-msgraph-user"]
 app["terraform-msgraph-application"]
 sp["terraform-msgraph-service-principal"]
 dra["terraform-msgraph-directory-role-assignment"]
 cap["terraform-msgraph-conditional-access-policy<br/>(this module)"]

 nl -->|"conditions.locations.includeLocations"| cap
 asp -->|"grantControls.authenticationStrength.id"| cap
 grp -->|"conditions.users.includeGroups"| cap
 usr -->|"conditions.users.includeUsers"| cap
 app -->|"conditions.applications.includeApplications"| cap
 sp -->|"conditions.clientApplications.includeServicePrincipals"| cap
 dra -->|"conditions.users.includeRoles"| cap

 classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
 classDef sibling fill:#005A9E,stroke:#003A6B,color:#ffffff;
 classDef ext fill:#E8EEF4,stroke:#B0C4DE,color:#1a1a1a;

 class cap this;
 class nl,asp sibling;
 class grp,usr,app,sp,dra ext;
Loading

ℹ️ Sibling availability at time of writing. group, user, application, service-principal, and directory-role-assignment are published modules in this catalog. named-location and authentication-strength-policy (shown in Microsoft dark blue β€” the two Conditional-Access-domain siblings) are planned β€” scaffolded but not yet authored. The composition examples below use their intended .id output contract; until they ship, pass those ids as literals or from your own resources.


🧬 What this builds

One keystone resource with the full policy body rendered inline β€” there are no for_each child resources, because Graph models conditions / grantControls as inline structure on the policy itself, not as separately addressable sub-resources.

flowchart LR
 subgraph in["Module inputs"]
 v1["display_name"]
 v2["state"]
 v3["conditions"]
 v4["grant_controls"]
 end
 this["msgraph_resource.this<br/>url = identity/conditionalAccess/policies<br/>keystone β€” no for_each children"]
 subgraph inline["Inline body β€” conditionalAccessPolicy"]
 b1["conditions<br/>applications Β· users XOR clientApplications Β· clientAppTypes<br/>platforms Β· locations Β· devices Β· authenticationFlows<br/>signInRiskLevels Β· userRiskLevels Β· servicePrincipalRiskLevels Β· insiderRiskLevels"]
 b2["grantControls<br/>operator Β· builtInControls Β· authenticationStrength<br/>customAuthenticationFactors Β· termsOfUse"]
 b3["lifecycle: 6 preconditions<br/>cross-field business rules"]
 end
 subgraph out["Outputs"]
 o1["id"]
 o2["display_name"]
 o3["state"]
 end
 v1 --> this
 v2 --> this
 v3 --> this
 v4 --> this
 this --> b1
 this --> b2
 this --> b3
 this --> o1
 this --> o2
 this --> o3
 classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
 classDef sibling fill:#005A9E,stroke:#003A6B,color:#ffffff;
 classDef ext fill:#E8EEF4,stroke:#B0C4DE,color:#1a1a1a;
 class this this;
 class b1,b2,b3 sibling;
 class v1,v2,v3,v4,o1,o2,o3 ext;
Loading

Resource inventory

Address Type Count Notes
msgraph_resource.this msgraph_resource 1 Keystone. conditions / grantControls rendered inline in body; six lifecycle preconditions.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider microsoft/msgraph, pinned exactly 0.3.0 (pre-1.0 β€” every minor ships breaking schema changes)
Graph API version v1.0 (relative url, never a hardcoded /v1.0/ prefix)
Provider block None in this module β€” the caller configures provider "msgraph" {} and authentication in the root module

Schema notes that bite

  • πŸ›‘οΈ state defaults to report-only, deliberately. enabledForReportingButNotEnforced is an hardening choice β€” Graph documents no default for this required property. A near-empty call never silently enforces a live access decision. You must type state = "enabled".
  • βš–οΈ Exactly one of conditions.users / conditions.client_applications. Terraform's type system cannot express "exactly one of two optional objects", so this is enforced by a plan-time precondition, not by the type β€” passing both, or neither, type-checks but fails at plan.
  • πŸ”— passwordChange / riskRemediation carry five extra rules (mutual exclusivity, mandatory pairings, operator = AND, and a restricted-conditions requirement). All are precondition blocks β€” see Architecture Notes.
  • 🚫 authenticationStrength and mfa are mutually exclusive. An authentication strength subsumes the standalone mfa control; setting both fails at plan.
  • 🧬 riskRemediation is an evolvable-enum member appended after unknownFutureValue. The msgraph provider does not send the Prefer: include-unknown-enum-members header (confirmed against the provider schema β€” it exposes no such option), so a tenant read could in principle omit/misrepresent this one member and produce a spurious plan diff. All eight documented v1.0 builtInControls are accepted; only this member carries the caveat. See Troubleshooting.
  • πŸ’³ sign_in_risk_levels / user_risk_levels need Entra ID P2. A policy with risk conditions plans clean but fails at apply in a tenant without P2 Identity Protection.
  • 🧾 clientAppTypes does not offer the deprecated easUnsupported. exchangeActiveSync covers EAS-supported and unsupported platforms.
  • 🧩 Flags enums are comma-joined for you. guestOrExternalUserTypes and authenticationFlows.transferMethods are Graph flag enums serialized as a single comma-delimited string; the module accepts a friendly list(string) and joins it.
  • ♻️ No force-new / immutable properties. Every managed property (displayName, state, conditions, grantControls) is updatable in place via PATCH β€” updates never force a replacement.
  • πŸ•³οΈ sessionControls is a known gap in v1 of this module β€” not rendered. Because it is out of scope, grant_controls is effectively required (a policy with neither has nothing to enforce; a plan-time guard catches this).

πŸ”‘ Graph API Permissions Required

Least-privilege application permissions (client-credentials flow β€” the pipeline default):

Operation Application permission
Read (drift detection) Policy.Read.All
Create / Update / Delete Policy.Read.All and Policy.ReadWrite.ConditionalAccess together

ℹ️ A documented higher-privilege alternative for write is Application.Read.All + Policy.ReadWrite.ConditionalAccess. Prefer the least-privilege pair above.

⚠️ Known Graph consent issue: creating/updating a CA policy may prompt for consent to permissions beyond the documented minimum. See Graph known issues β€” Conditional Access requires consent to additional permission and the Troubleshooting table.

Delegated access additionally requires the Security Administrator or Conditional Access Administrator directory role.


Microsoft Graph Prerequisites

  • Graph API version: v1.0 (stable β€” insiderRiskLevels, servicePrincipalRiskLevels, and authenticationFlows are already v1.0, not beta).
  • License / SKU β€” two tiers, distinct:
  • Entra ID P1 β€” baseline for Conditional Access itself.
  • Entra ID P2 (Identity Protection) β€” additionally required whenever conditions.sign_in_risk_levels or conditions.user_risk_levels is non-empty. Plans clean without P2; fails at apply in a tenant lacking it.
  • Admin consent: required for the application permissions above.

πŸ“ Module Structure

terraform-msgraph-conditional-access-policy/
β”œβ”€β”€ providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned 0.3.0; no provider block
β”œβ”€β”€ variables.tf # deeply-typed conditions / grant_controls object schemas + enum validation
β”œβ”€β”€ main.tf # keystone msgraph_resource.this; inline body renderer; 6 lifecycle preconditions
β”œβ”€β”€ outputs.tf # id, display_name, state
β”œβ”€β”€ README.md # this file
β”œβ”€β”€ SCOPE.md # cross-module contract, design decisions, resolved gotchas
└── examples/
 └── basic/
 └── main.tf # smallest real call β€” require MFA for a group, report-only

βš™οΈ Quick Start

# The caller configures the provider + authentication in the root module β€” never inside this module.
provider "msgraph" {}

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

  display_name = "Require MFA β€” pilot group"

  # state omitted β†’ defaults to "enabledForReportingButNotEnforced" (report-only, secure default).

  conditions = {
    # applications omitted β†’ defaults to include_applications = ["All"] (target all cloud apps).
    users = {
      include_groups = [var.pilot_group_object_id]
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

πŸ”Œ Cross-Module Contract

Consumes β€” all by id, never reached into directly:

Input Type Source module Rendered into
named location ids list(string) terraform-msgraph-named-location (planned) conditions.locations.include_locations / exclude_locations
authentication strength policy id string terraform-msgraph-authentication-strength-policy (planned) grant_controls.authentication_strength_id
application (client) ids list(string) terraform-msgraph-application conditions.applications.include_applications / exclude_applications
group ids list(string) terraform-msgraph-group conditions.users.include_groups / exclude_groups
user ids list(string) terraform-msgraph-user conditions.users.include_users / exclude_users
service principal ids list(string) terraform-msgraph-service-principal conditions.client_applications.include_service_principals / exclude_service_principals
directory role ids list(string) terraform-msgraph-directory-role-assignment conditions.users.include_roles / exclude_roles

Emits β€” id first:

Output Description Consumed by
id Graph object id (GUID) of the policy Terminal β€” no downstream module currently consumes a CA policy id; operational reporting
display_name The policy's displayName Operational reporting
state Enforcement state as last applied Operational reporting

πŸ“š Example Library

1 Β· Minimal β€” require MFA for a pilot group (report-only)
module "require_mfa" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require MFA β€” pilot group"

  conditions = {
    users = {
      include_groups = [var.pilot_group_object_id]
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

πŸ”’ state is left at its secure default (enabledForReportingButNotEnforced). The policy is created and reports impact, but enforces nothing until you set state = "enabled".

2 Β· Enabling enforcement β€” the secure-default opt-out
module "require_mfa_enforced" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require MFA β€” all users (enforced)"
  state        = "enabled" # deliberate opt-out of the report-only secure default

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids # never lock yourself out β€” see example 14
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

⚠️ Validate impact in report-only mode first. state = "enabled" makes a live access-control decision the instant it applies (evaluated at each token issuance).

3 Β· Block legacy authentication
module "block_legacy_auth" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Block legacy authentication"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    # Legacy auth surfaces as these two client app types.
    client_app_types = ["exchangeActiveSync", "other"]
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

πŸ’‘ exchangeActiveSync covers both EAS-supported and EAS-unsupported platforms β€” the deprecated easUnsupported value is intentionally not offered by this module.

4 Β· Require MFA for risky sign-ins (Entra ID P2)
module "risky_signin_mfa" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require MFA for medium/high sign-in risk"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    sign_in_risk_levels = ["high", "medium"] # requires Entra ID P2 Identity Protection at apply
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

πŸ’³ This plans clean without P2 but fails at apply in a tenant lacking Identity Protection. See Troubleshooting.

5 Β· Require MFA for risky users (Entra ID P2)
module "risky_user_mfa" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require MFA for high user risk"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    user_risk_levels = ["high"] # requires Entra ID P2
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}
6 Β· Block access except from trusted named locations
module "block_untrusted_locations" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Block access from untrusted locations"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    locations = {
      include_locations = ["All"]
      exclude_locations = ["AllTrusted"] # or specific named-location ids from terraform-msgraph-named-location
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

ℹ️ include_locations / exclude_locations accept named-location ids or the literals "All" / "AllTrusted".

7 Β· Require an authentication strength (phishing-resistant MFA)
module "require_phishing_resistant" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require phishing-resistant MFA β€” admins"
  state        = "enabled"

  conditions = {
    users = {
      include_roles = var.privileged_role_template_ids
      exclude_users = var.break_glass_user_ids
    }
  }

  grant_controls = {
    operator                   = "AND"
    authentication_strength_id = var.phishing_resistant_strength_id # from terraform-msgraph-authentication-strength-policy
    # NOTE: do NOT also set built_in_controls = ["mfa"] β€” an authentication strength and mfa are mutually
    # exclusive and this combination fails at plan (precondition rule 5).
  }
}

πŸ”’ authentication_strength_id renders grantControls.authenticationStrength = { id =... } and subsumes the standalone mfa control.

8 Β· Self-remediate user risk with a password change (passwordChange + mfa + AND)
module "user_risk_password_change" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "High user risk β€” require secure password change"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    user_risk_levels = ["high"] # mandatory for a passwordChange policy
    # No other conditions may be set (rule 4). applications defaults to ["All"]; client_app_types stays ["all"].
  }

  grant_controls = {
    operator          = "AND"                     # mandatory for passwordChange (rule 2)
    built_in_controls = ["passwordChange", "mfa"] # passwordChange must pair with mfa (rule 2)
  }
}

⚠️ A passwordChange policy must include user_risk_levels, target all applications with no exclusions, use conditions.users (not client_applications), and contain no other condition. All of this is enforced at plan by precondition rule 4.

9 Β· Require risk remediation (riskRemediation + authenticationStrength + AND)
module "require_risk_remediation" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "High user risk β€” require remediation"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    user_risk_levels = ["high", "medium"]
  }

  grant_controls = {
    operator                   = "AND" # mandatory for riskRemediation (rule 3)
    built_in_controls          = ["riskRemediation"]
    authentication_strength_id = var.phishing_resistant_strength_id # mandatory pairing (rule 3)
  }
}

πŸ”’ passwordChange and riskRemediation can never appear in the same policy (rule 1). riskRemediation requires authentication_strength_id and operator = "AND" (rule 3).

10 Β· Require compliant OR domain-joined device
module "require_managed_device" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require managed device"
  state        = "enabled"

  conditions = {
    users = {
      include_groups = [var.workforce_group_id]
      exclude_users  = var.break_glass_user_ids
    }
  }

  grant_controls = {
    operator          = "OR" # satisfy EITHER control
    built_in_controls = ["compliantDevice", "domainJoinedDevice"]
  }
}
11 Β· Scope by client app type and device platform
module "mobile_browser_mfa" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "MFA for browser access on mobile platforms"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
    client_app_types = ["browser"]
    platforms = {
      include_platforms = ["iOS", "android"]
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

ℹ️ platforms include/exclude values: android, iOS, windows, windowsPhone, macOS, linux, all, unknownFutureValue.

12 Β· Target guests and external users (B2B)
module "guest_mfa" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require MFA for B2B guests"
  state        = "enabled"

  conditions = {
    users = {
      include_guests_or_external_users = {
        guest_or_external_user_types = ["b2bCollaborationGuest", "b2bCollaborationMember"]
        external_tenants = {
          membership_kind = "all"
        }
      }
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

πŸ’‘ guest_or_external_user_types is a Graph flags enum; supply it as a friendly list and the module joins it into the comma-delimited string Graph expects. Legal values: none, internalGuest, b2bCollaborationGuest, b2bCollaborationMember, b2bDirectConnectUser, otherExternalUser, serviceProvider.

13 Β· Workload identities β€” block risky service principals
module "workload_identity_risk" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Block high-risk workload identities"
  state        = "enabled"

  conditions = {
    # client_applications instead of users β€” exactly one of the two (precondition rule 6).
    client_applications = {
      include_service_principals = var.monitored_service_principal_ids
    }
    service_principal_risk_levels = ["high"] # note: smaller enum than user risk β€” no "hidden"
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

⚠️ Setting both users and client_applications (or neither) fails at plan β€” Graph requires exactly one.

14 Β· Always exclude break-glass / emergency-access accounts
# A house pattern, not a special feature: every enforced policy above threads var.break_glass_user_ids
# into exclude_users so a misconfigured policy can never lock every administrator out at once.
variable "break_glass_user_ids" {
  type        = list(string)
  description = "Object ids of the tenant's emergency-access accounts, excluded from every enforced policy."
}

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

  display_name = "Baseline β€” require MFA (with break-glass exclusion)"
  state        = "enabled"

  conditions = {
    users = {
      include_users = ["All"]
      exclude_users = var.break_glass_user_ids
    }
  }

  grant_controls = {
    operator          = "OR"
    built_in_controls = ["mfa"]
  }
}

πŸ”’ Keep break-glass accounts out of every enforced policy and monitor their sign-ins separately. This module does not add the exclusion for you β€” wire it in explicitly.

15 Β· πŸ—οΈ End-to-end composition β€” named-location + authentication-strength-policy + group wired into this module
# A security group whose members the policy targets (published module).
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"
}

# A named location the policy scopes to (PLANNED sibling β€”.id is the intended contract).
module "branch_office" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-named-location.git?ref=v1.0.0"

  display_name = "Branch office egress"
  ip_ranges    = ["203.0.113.0/24"]
}

# A custom authentication strength the policy requires (PLANNED sibling β€”.id is the intended contract).
module "phishing_resistant" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-authentication-strength-policy.git?ref=v1.0.0"

  display_name         = "Phishing-resistant MFA"
  allowed_combinations = ["fido2", "windowsHelloForBusiness", "x509CertificateMultiFactor"]
}

# This module consumes all three by id β€” Terraform's dependency graph creates them first, with no depends_on.
module "engineering_strong_auth" {
  source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

  display_name = "Require phishing-resistant MFA β€” Engineering from branch office"
  state        = "enabled"

  conditions = {
    users = {
      include_groups = [module.engineering_group.id] # conditions.users.includeGroups
      exclude_users  = var.break_glass_user_ids
    }
    locations = {
      include_locations = [module.branch_office.id] # conditions.locations.includeLocations
    }
  }

  grant_controls = {
    operator                   = "AND"
    authentication_strength_id = module.phishing_resistant.id # grantControls.authenticationStrength.id
  }
}

πŸ’‘ Notice the ordering Terraform enforces without any depends_on: include_groups, include_locations, and authentication_strength_id are populated from real module output references, so the group, named location, and authentication strength are all created before the policy that references them. If the group is ever replaced, the policy's plan correctly picks up the new id.


πŸ“₯ Inputs

Grouped summary

Group Variables
Identity & state display_name (required), state (secure default enabledForReportingButNotEnforced)
Conditions conditions β€” applications, users XOR client_applications, client_app_types, platforms, locations, devices, sign_in_risk_levels, user_risk_levels, service_principal_risk_levels, insider_risk_levels, authentication_flows
Grant controls grant_controls β€” operator, built_in_controls, authentication_strength_id, custom_authentication_factors, terms_of_use
Full object schemas
variable "display_name" { type = string } # required, 1–256 chars

variable "state" {
  type    = string
  default = "enabledForReportingButNotEnforced" # "enabled" | "disabled" | "enabledForReportingButNotEnforced"
}

variable "conditions" {
  type = object({
    applications = optional(object({
      include_applications = optional(list(string), ["All"]) # app ids, or "All"/"Office365"/"MicrosoftAdminPortals"
      exclude_applications = optional(list(string), [])
      application_filter   = optional(object({ mode = string, rule = string })) # mode: "include"|"exclude"
      include_user_actions = optional(list(string), [])                         # only urn:user:registersecurityinfo / urn:user:registerdevice
    }), {})

    users = optional(object({
      include_users  = optional(list(string), []) # user ids, or "None"/"All"/"GuestsOrExternalUsers"
      exclude_users  = optional(list(string), [])
      include_groups = optional(list(string), [])
      exclude_groups = optional(list(string), [])
      include_roles  = optional(list(string), [])
      exclude_roles  = optional(list(string), [])
      include_guests_or_external_users = optional(object({
        guest_or_external_user_types = list(string) # flags enum (see legal values)
        external_tenants             = optional(object({ membership_kind = string, members = optional(list(string), []) }))
      }))
      exclude_guests_or_external_users = optional(object({
        guest_or_external_user_types = list(string)
        external_tenants             = optional(object({ membership_kind = string, members = optional(list(string), []) }))
      }))
    }))

    client_applications = optional(object({
      include_service_principals = optional(list(string), []) # sp ids, or "ServicePrincipalsInMyTenant"
      exclude_service_principals = optional(list(string), [])
      service_principal_filter   = optional(object({ mode = string, rule = string }))
    }))

    client_app_types = optional(list(string), ["all"]) # all|browser|mobileAppsAndDesktopClients|exchangeActiveSync|easSupported|other

    platforms = optional(object({
      include_platforms = optional(list(string), []) # android|iOS|windows|windowsPhone|macOS|linux|all|unknownFutureValue
      exclude_platforms = optional(list(string), [])
    }))

    locations = optional(object({
      include_locations = optional(list(string), []) # named-location ids, or "All"/"AllTrusted"
      exclude_locations = optional(list(string), [])
    }))

    devices = optional(object({
      device_filter = object({ mode = string, rule = string })
    }))

    sign_in_risk_levels           = optional(list(string), [])                            # low|medium|high|hidden|none|unknownFutureValue (P2)
    user_risk_levels              = optional(list(string), [])                            # low|medium|high|hidden|none|unknownFutureValue (P2)
    service_principal_risk_levels = optional(list(string), [])                            # low|medium|high|none|unknownFutureValue (no "hidden")
    insider_risk_levels           = optional(string)                                      # SCALAR: minor|moderate|elevated|unknownFutureValue
    authentication_flows          = optional(object({ transfer_methods = list(string) })) # none|deviceCodeFlow|authenticationTransfer|unknownFutureValue
  })
}

variable "grant_controls" {
  type = object({
    operator                      = optional(string, "OR")     # "AND" | "OR"
    built_in_controls             = optional(list(string), []) # block|mfa|compliantDevice|domainJoinedDevice|approvedApplication|compliantApplication|passwordChange|riskRemediation
    custom_authentication_factors = optional(list(string), [])
    terms_of_use                  = optional(list(string), [])
    authentication_strength_id    = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Sensitive
id Graph object id (GUID) of the policy β€” primary output No
display_name The policy's displayName No
state Enforcement state as last applied No

πŸ”’ No secret-bearing output exists β€” the CA policy resource type carries no credential; every reference is an opaque GUID.


🧠 Architecture Notes

Composite, rendered inline (no for_each children). Graph models conditions / grantControls as inline structure on the policy body β€” there is no separately addressable Graph sub-resource for a policy's condition block β€” so this module has one keystone resource and zero child resources. Optional nested structures are rendered with per-field conditional-null so the provider drops absent keys.

The six cross-field business rules, as lifecycle preconditions. Graph enforces these server-side; this module surfaces each at plan time. Every rule is computed purely from input variables, which is what a precondition needs to evaluate before the resource exists:

# Rule How it is enforced
6 Exactly one of conditions.users / conditions.client_applications precondition: users_set != client_apps_set
1 passwordChange and riskRemediation never combined precondition: !(has_password_change && has_risk_remediation)
2 passwordChange requires mfa and operator = "AND" precondition: !has_password_change || (has_mfa && operator == "AND")
3 riskRemediation requires authentication_strength_id and operator = "AND" precondition: !has_risk_remediation || (has_auth_strength && operator == "AND")
5 authentication_strength and mfa mutually exclusive precondition: !(has_auth_strength && has_mfa)
4 passwordChange/riskRemediation policy: must include user_risk_levels, target all apps with no exclusions, use users, and set no other condition precondition: !is_pw_or_risk || (has_user_risk_levels && apps_all_no_exclusions && users_set && !extra_conditions_present)

A seventh convenience guard requires grant_controls to be non-empty (at least one built-in control or an authentication strength), since sessionControls is out of scope and a policy with nothing to enforce is rejected by Graph.

No force-new properties. displayName, state, conditions, and grantControls are all PATCH-updated in place β€” a change never triggers replacement. templateId, createdDateTime, and modifiedDateTime are Graph read-only and are not managed.

Evaluation is at token issuance, not retroactive. A user added to an included group is not subject to the policy until they obtain a new token β€” an operational timing characteristic, not a Terraform or Graph write-consistency delay.


🧱 Design Principles

Secure-by-default for this entity β€” the empty(-ish) call produces the safe resource. Each default and its opt-out:

Concern Secure default Opt-out (type extra characters)
Policy state enabledForReportingButNotEnforced (report-only) state = "enabled"
User scope conditions.users.include_users defaults to [] β€” never ["All"] Caller sets include_users = ["All"] / groups / roles explicitly
Application scope include_applications = ["All"] (standard CA baseline; a control policy narrows via grant controls) Caller scopes to specific application ids
Grant operator "OR" Caller sets "AND"
Secrets Excluded entirely β€” no secret-bearing property exists on this entity N/A (hard rule)
Sensitive outputs None emitted N/A

πŸ”’ Break-glass exclusion is a house pattern, not a default this module injects β€” thread exclude_users into every enforced policy yourself (example 14).


πŸš€ Runbook

# From the module directory (offline proof gate β€” no plan/apply against a live tenant):
terraform init -backend=false
terraform validate
terraform fmt -check

# From a caller root module, always pin the tag β€” never a branch:
# source = "git::https://github.com/microsoftexpert/terraform-msgraph-conditional-access-policy.git?ref=v1.0.0"

⚠️ Roll out enforced policies through report-only first, and use the Conditional Access What If tool to preview impact before setting state = "enabled".


πŸ§ͺ Testing

The authoring proof gate is static: terraform init -backend=false, terraform validate, terraform fmt -check. Understand precisely what each does and does not cover here:

  • βœ… terraform validate confirms the variables.tf types are internally consistent, that main.tf references resolve, and that the validation {} enum blocks and the six precondition expressions type-check.
  • ❌ terraform validate cannot confirm the resulting body is a payload Graph will accept. From the provider's perspective body is a generic map β€” a malformed-but-well-typed payload passes validate and fails only with a Graph 400 at apply. This module's object typing is the only thing standing between the caller and that 400.
  • ⚠️ validate does not evaluate the preconditions β€” only plan does. A validate run passes even when a precondition would fail; the boolean is evaluated during plan/apply, which for this provider needs a configured tenant. (During authoring, the six rules' logic was verified independently by replicating each condition expression in an isolated, provider-free harness driven by good and bad inputs β€” every rule fired on its targeted violation and every valid combination passed.)
  • ⚠️ The preconditions catch only what Terraform can see at plan time from the caller's own inputs. They are not a substitute for the live Graph 400 a genuinely malformed combination would still produce β€” if Graph adds or tightens a policy rule in the future and a precondition here is incomplete relative to it, the failure reappears at apply. Treat the preconditions as a fast, local first line of defense, not a guarantee of Graph acceptance.

πŸ’¬ Example Output

$ terraform output
id = "8f9a1c2d-3e4b-5a6f-9c8d-1b2a3c4d5e6f"
display_name = "Require MFA β€” pilot group"
state = "enabledForReportingButNotEnforced"

πŸ” Troubleshooting

Symptom Cause Fix
apply prompts for / fails on consent to permissions beyond the documented minimum Known Graph issue β€” CA policy create/update may require additional consent Grant admin consent for Policy.Read.All + Policy.ReadWrite.ConditionalAccess (and Application.Read.All if the higher-privilege path is used); re-run
Policy with sign_in_risk_levels / user_risk_levels plans clean but apply fails Tenant lacks Entra ID P2 (Identity Protection); risk-based conditions require it Assign P2 to the tenant, or remove the risk-level conditions. The failure message is not always self-explanatory β€” this is the usual cause
A user added to an included group isn't blocked/challenged immediately Policy is evaluated at token issuance, not retroactively Expected. Enforcement applies on the user's next token acquisition; not a module bug
Deleting a referenced authentication strength policy fails Graph blocks deleting an authentication strength while a CA policy references it (docs) Remove authentication_strength_id from this policy (or destroy this policy) first, then delete the strength
Deleting a referenced named location succeeds but the policy later errors Named locations have no delete-block; the delete leaves a dangling id, and Graph error 1040: NamedLocation with id... does not exist surfaces later on policy read/update (Q&A) Remove the id from conditions.locations before deleting the named location. A 30-day soft-delete window is the recovery net. Structure module composition so the policy update runs before the named-location destroy
Spurious plan diff on riskRemediation in built_in_controls The provider does not send Prefer: include-unknown-enum-members; riskRemediation is the sole enum member appended after unknownFutureValue and may not round-trip cleanly on read Known caveat, narrow in scope. If it recurs, confirm the value in the portal; the value is legal and accepted on write
Plan fails with a precondition error (RULE...) A cross-field business rule is violated (see Architecture Notes) Read the error β€” it names the exact variables and the required combination. Fix the input; do not work around it, Graph enforces the same rule

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."

Releases

Packages

Contributors

Languages