Manages a single Microsoft Entra Conditional Access policy (
identity/conditionalAccess/policies) as deeply-typed, secure-by-default Terraform, rendering the entireconditions/grantControlsstructure inline and surfacing Graph's cross-field business rules at plan time. Built on themicrosoft/msgraphprovider, pinned exactly at 0.3.0, targeting Graph v1.0.
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.thisatidentity/conditionalAccess/policies, full create/read/update/delete lifecycle. - π§± The entire
conditions/grantControlsstructure, 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
enabledForReportingButNotEnforcedstate; the caller must typestate = "enabled"to enforce. - π§ͺ Six cross-field business rules as plan-time
preconditionblocks β Graph enforces these server-side; this module surfaces them atplaninstead of as a Graph 400 atapply. - π 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
msgraphprovider'sbodyis a generic, untyped map β it will happilyplana typo'd property, a wrong-typed value, or an illegal cross-field combination and fail only atapplyagainst a live tenant. This module's typedobjectschema and sixpreconditionblocks are the only thing standing between a caller and a Graph 400 (or, worse, a policy that silently locks people out).
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
This module 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;
βΉοΈ Sibling availability at time of writing.
group,user,application,service-principal, anddirectory-role-assignmentare published modules in this catalog.named-locationandauthentication-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.idoutput contract; until they ship, pass those ids as literals or from your own resources.
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;
Resource inventory
| Address | Type | Count | Notes |
|---|---|---|---|
msgraph_resource.this |
msgraph_resource |
1 | Keystone. conditions / grantControls rendered inline in body; six lifecycle preconditions. |
| 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
- π‘οΈ
statedefaults to report-only, deliberately.enabledForReportingButNotEnforcedis an hardening choice β Graph documents no default for this required property. A near-empty call never silently enforces a live access decision. You must typestate = "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-timeprecondition, not by the type β passing both, or neither, type-checks but fails at plan. - π
passwordChange/riskRemediationcarry five extra rules (mutual exclusivity, mandatory pairings, operator =AND, and a restricted-conditions requirement). All arepreconditionblocks β see Architecture Notes. - π«
authenticationStrengthandmfaare mutually exclusive. An authentication strength subsumes the standalonemfacontrol; setting both fails at plan. - π§¬
riskRemediationis an evolvable-enum member appended afterunknownFutureValue. Themsgraphprovider does not send thePrefer: include-unknown-enum-membersheader (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.0builtInControlsare accepted; only this member carries the caveat. See Troubleshooting. - π³
sign_in_risk_levels/user_risk_levelsneed Entra ID P2. A policy with risk conditions plans clean but fails atapplyin a tenant without P2 Identity Protection. - π§Ύ
clientAppTypesdoes not offer the deprecatedeasUnsupported.exchangeActiveSynccovers EAS-supported and unsupported platforms. - π§© Flags enums are comma-joined for you.
guestOrExternalUserTypesandauthenticationFlows.transferMethodsare Graph flag enums serialized as a single comma-delimited string; the module accepts a friendlylist(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. - π³οΈ
sessionControlsis a known gap in v1 of this module β not rendered. Because it is out of scope,grant_controlsis effectively required (a policy with neither has nothing to enforce; a plan-time guard catches this).
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.
- Graph API version: v1.0 (stable β
insiderRiskLevels,servicePrincipalRiskLevels, andauthenticationFlowsare 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_levelsorconditions.user_risk_levelsis non-empty. Plans clean without P2; fails atapplyin a tenant lacking it. - Admin consent: required for the application permissions above.
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
# 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"]
}
}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 |
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"]
}
}π
stateis left at its secure default (enabledForReportingButNotEnforced). The policy is created and reports impact, but enforces nothing until you setstate = "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"]
}
}π‘
exchangeActiveSynccovers both EAS-supported and EAS-unsupported platforms β the deprecatedeasUnsupportedvalue 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
applyin 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_locationsaccept 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_idrendersgrantControls.authenticationStrength = { id =... }and subsumes the standalonemfacontrol.
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)
}
}
β οΈ ApasswordChangepolicy must includeuser_risk_levels, target all applications with no exclusions, useconditions.users(notclient_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)
}
}π
passwordChangeandriskRemediationcan never appear in the same policy (rule 1).riskRemediationrequiresauthentication_strength_idandoperator = "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"]
}
}βΉοΈ
platformsinclude/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_typesis 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 bothusersandclient_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, andauthentication_strength_idare 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.
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
}| 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.
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.
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_usersinto every enforced policy yourself (example 14).
# 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 settingstate = "enabled".
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 validateconfirms thevariables.tftypes are internally consistent, thatmain.tfreferences resolve, and that thevalidation {}enum blocks and the sixpreconditionexpressions type-check. - β
terraform validatecannot confirm the resultingbodyis a payload Graph will accept. From the provider's perspectivebodyis a generic map β a malformed-but-well-typed payload passesvalidateand fails only with a Graph 400 atapply. This module'sobjecttyping is the only thing standing between the caller and that 400. β οΈ validatedoes not evaluate the preconditions β onlyplandoes. Avalidaterun passes even when a precondition would fail; the boolean is evaluated duringplan/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 atapply. Treat the preconditions as a fast, local first line of defense, not a guarantee of Graph acceptance.
$ terraform output
id = "8f9a1c2d-3e4b-5a6f-9c8d-1b2a3c4d5e6f"
display_name = "Require MFA β pilot group"
state = "enabledForReportingButNotEnforced"| 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 |
- Graph API reference β conditionalAccessPolicy resource type Β· conditionalAccessConditionSet Β· conditionalAccessGrantControls
- Provider β
microsoft/msgraphon the Terraform Registry - Sibling modules β
terraform-msgraph-named-location(planned),terraform-msgraph-authentication-strength-policy(planned),terraform-msgraph-group,terraform-msgraph-user,terraform-msgraph-application,terraform-msgraph-service-principal,terraform-msgraph-directory-role-assignment - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."