Skip to content

Repository files navigation

Terraform Entra ID Conditional Access

Conditional access as code, built so a bad apply cannot lock you out of your own tenant: break-glass exclusions injected into every policy, report-only by default, and enforcement as a recorded decision.

CI Release Terraform Registry License


Overview

Conditional access is the containment lever for an identity incident, and it is also the only configuration in a tenant that a single clean apply can use to deny everybody access to everything, including the pipeline that applied it. Recovery from that is a support case.

Wrapping azuread_conditional_access_policy in a module is therefore not about saving keystrokes. It is about making the two mistakes that cause lockouts structurally impossible.

The two safety rails

Break-glass exclusions are injected, not validated. break_glass_group_object_ids is appended to the excluded groups of every policy the module creates. Not checked for, not warned about: added. You cannot forget it on the one policy where forgetting it matters. The exclusion is merged with whatever the policy already excludes and deduplicated, so listing a break-glass group explicitly is harmless. It is required as soon as any policy is declared, and the error says why.

Report-only is the default. state defaults to enabledForReportingButNotEnforced, so a new policy writes what it would have done to the sign-in logs and affects nobody. Enforcement is the deliberate act: set state = "enabled" and name the policy's key in enforcement_acknowledged, or a check block reports it. That records who decided to start denying sign-ins, in the pull request that did it, rather than leaving it to be inferred from a state field later.

Composition, so no id is copied by hand

grant_controls.authentication_strength_key names a key in authentication_strength_policies, and conditions.locations.included_location_keys / excluded_location_keys name keys in named_locations. Both resolve to the object ids of resources this module created, which is what orders them correctly without a hand-maintained depends_on. Cross-variable validation catches a key that does not exist at plan time. The raw included_locations and excluded_locations lists still take the literals All and AllTrusted, or ids from elsewhere.

Shape rules enforced at plan

The Graph API rejects these and the provider does not, so without the module they fail at apply: included_applications and included_user_actions together (or neither), a policy that includes nobody, a policy with neither grant nor session controls, grant_controls with nothing to grant, and a block control combined with any other control, which the API treats as a contradiction because block is the whole decision.

Three advisory checks

  • Unacknowledged enforcement. A policy set to enabled whose key is not in enforcement_acknowledged.
  • Tenant-wide block. block plus all applications plus all users plus a client app type covering interactive sign-in. That last clause is what makes the check worth having: Microsoft's recommended legacy-authentication block is block plus all applications plus all users, and it is safe precisely because client_app_types narrows it to exchangeActiveSync and other. Flagging that would flag the most standard policy in existence.
  • Broad trusted locations. A trusted named location satisfies an except-from-a-trusted-location control, so the range is the size of the bypass. Warns above a /16.

There is deliberately no check for using the built-in mfa control instead of an authentication strength. Preferring phishing-resistant methods is real advice, and it is on the authentication_strength_policies variable, but requiring built-in MFA is a standard configuration in most tenants and a check that fires on every plan for a normal setup teaches people to ignore checks.

Worth knowing

The conditional access API is rate limited to one request per second. Terraform backs off and retries, but a large change set is slow rather than broken. Reduce -parallelism if it matters.

Using conditions.applications.filter or conditions.devices.filter requires the Attribute Definition Reader role, which is not included in Global Administrator and must be assigned separately.

Usage

module "conditional_access" {
  source  = "libre-devops/conditional-access/azuread"
  version = "~> 1.0"

  break_glass_group_object_ids = [azuread_group.break_glass.object_id]

  conditional_access_policies = {
    "block-legacy-authentication" = {
      conditions = {
        client_app_types = ["exchangeActiveSync", "other"]
        applications     = { included_applications = ["All"] }
        users            = { included_users = ["All"] }
      }

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

That policy deploys in report-only. To enforce it, set state = "enabled" on the policy and add enforcement_acknowledged = ["block-legacy-authentication"].

Examples

  • examples/minimal - one named location and no policies, which is inert and changes nobody's access.
  • examples/complete - a break-glass group, named locations, an authentication strength and three policies, all left in report-only. Read its README before running it: unlike the rest of this library it changes the security configuration of a real tenant rather than creating disposable resources in a resource group.

Developing

Local work needs PowerShell 7+ and just, because the recipes wrap the LibreDevOpsHelpers PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install just with brew install just, or uv tool add rust-just then uv run just <recipe>.

Run just to list recipes: just update-ldo-pwsh (install or force-update LibreDevOpsHelpers from PSGallery), just validate, just scan (Trivy only), just pwsh-analyze (PSScriptAnalyzer only), just plan, just apply, just destroy, just e2e, just test, and just docs (the plan/apply/destroy recipes mirror the action, including the storage firewall dance; just e2e applies an example then always destroys it, defaulting to minimal, so nothing is left running). Releasing is also just: just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the Terraform Registry picks up the tag.

The test suite is plan-time and offline: terraform init -backend=false && terraform test needs no credentials. Two runs apply rather than plan, because the ids they assert on are computed and so unknowable at plan time; the provider is mocked, so those applies stay entirely local. Note that terraform test treats a check block failure as a run failure, so the advisory checks cannot be asserted from a passing run; they are exercised by hand against a deliberately broken configuration when they change.

Security scan exceptions

This module is scanned with Trivy; HIGH and CRITICAL findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a finding that should be fixed. Waivers live in .trivyignore.yaml (the machine-applied source of truth, passed to Trivy with --ignorefile) and are mirrored in the table below so the reason is auditable.

Trivy ID Resource Finding Justification
None

To add an exception: add an entry to .trivyignore.yaml (id, optional paths to scope it, and a statement recording why), then add a matching row here. Where the finding is out of this module's scope, point the justification at the Libre DevOps module that does address it (for example the private-endpoint module). Both the file and this table are reviewed in the pull request.

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
azuread >= 3.0.0, < 4.0.0

Providers

Name Version
azuread >= 3.0.0, < 4.0.0

Modules

No modules.

Resources

Name Type
azuread_authentication_strength_policy.this resource
azuread_conditional_access_policy.this resource
azuread_named_location.this resource

Inputs

Name Description Type Default Required
authentication_strength_policies Custom authentication strength policies, keyed by display name. An authentication strength is a
named set of acceptable authentication method combinations, which a policy's grant control can
demand instead of the blunt built-in mfa control.

Use it to require phishing-resistant methods (FIDO2, Windows Hello for Business, certificate
based) rather than any second factor, which is what "require MFA" actually means and is why so
many MFA policies survive an adversary-in-the-middle phish.

A policy in var.conditional_access_policies references one of these by its key through
grant_controls.authentication_strength_key, which is what wires the dependency without a
hand-maintained depends_on.
map(object({
allowed_combinations = set(string)
description = optional(string)
}))
{} no
break_glass_group_object_ids Object ids of the break-glass (emergency access) groups. The module appends these to the excluded
groups of EVERY conditional access policy it creates.

This is the whole reason to use a module for conditional access rather than writing the resources
directly. A policy that blocks or demands MFA of every user, applied to every application, will
lock every administrator out of the tenant including the one running Terraform, and the recovery
is a support case. Microsoft's guidance is to keep two cloud-only emergency accounts excluded
from every policy; injecting the exclusion here means it cannot be forgotten on the one policy
that matters.

The exclusion is added, never replaced: a policy may still list its own excluded_groups and both
sets apply.

Required whenever any policy is declared. Leave it empty only when the module is used for named
locations or authentication strengths alone.
list(string) [] no
conditional_access_policies Conditional access policies, keyed by a handle that doubles as the display name unless
display_name is set.

state defaults to enabledForReportingButNotEnforced, NOT enabled. A conditional access policy is
the one piece of configuration in a tenant that can deny everybody access to everything, so the
safe state is the default and enforcement is the deliberate act. Run a policy in report-only,
read the sign-in logs to see what it would have done, then enforce it.

Moving a policy to enabled also wants its key naming in var.enforcement_acknowledged, otherwise a
check block reports it. That records who decided to enforce, in the pull request that did it.

Composition, so no id is copied by hand:
- grant_controls.authentication_strength_key names a key in var.authentication_strength_policies.
- conditions.locations.included_location_keys and excluded_location_keys name keys in
var.named_locations. The raw included_locations and excluded_locations lists still take the
literals All and AllTrusted, or object ids of locations this module does not manage.

The break-glass groups are appended to conditions.users.excluded_groups automatically.
map(object({
display_name = optional(string)
state = optional(string, "enabledForReportingButNotEnforced")

conditions = object({
client_app_types = optional(list(string), ["all"])
sign_in_risk_levels = optional(list(string))
user_risk_levels = optional(list(string))
service_principal_risk_levels = optional(list(string))
insider_risk_levels = optional(string)
authentication_flow_transfer_methods = optional(set(string))

applications = object({
included_applications = optional(list(string))
included_user_actions = optional(list(string))
excluded_applications = optional(list(string))

filter = optional(object({
mode = string
rule = string
}))
})

users = object({
included_users = optional(list(string))
included_groups = optional(list(string))
included_roles = optional(list(string))

excluded_users = optional(list(string), [])
excluded_groups = optional(list(string), [])
excluded_roles = optional(list(string), [])

included_guests_or_external_users = optional(list(object({
guest_or_external_user_types = list(string)

external_tenants = optional(list(object({
membership_kind = string
members = optional(list(string))
})), [])
})), [])

excluded_guests_or_external_users = optional(list(object({
guest_or_external_user_types = list(string)

external_tenants = optional(list(object({
membership_kind = string
members = optional(list(string))
})), [])
})), [])
})

platforms = optional(object({
included_platforms = list(string)
excluded_platforms = optional(list(string))
}))

locations = optional(object({
included_locations = optional(list(string), [])
excluded_locations = optional(list(string), [])
included_location_keys = optional(list(string), [])
excluded_location_keys = optional(list(string), [])
}))

devices = optional(object({
filter = object({
mode = string
rule = string
})
}))

client_applications = optional(object({
included_service_principals = optional(list(string))
excluded_service_principals = optional(list(string))

filter = optional(object({
mode = string
rule = string
}))
}))
})

grant_controls = optional(object({
operator = optional(string, "OR")
built_in_controls = optional(list(string))
custom_authentication_factors = optional(list(string))
terms_of_use = optional(list(string))
authentication_strength_key = optional(string)
authentication_strength_policy_id = optional(string)
}))

session_controls = optional(object({
application_enforced_restrictions_enabled = optional(bool)
cloud_app_security_policy = optional(string)
disable_resilience_defaults = optional(bool)
persistent_browser_mode = optional(string)
sign_in_frequency = optional(number)
sign_in_frequency_authentication_type = optional(string)
sign_in_frequency_interval = optional(string)
sign_in_frequency_period = optional(string)
}))
}))
{} no
enforcement_acknowledged Keys of the policies you have decided to ENFORCE, having already run them in report-only and read
what they would have done.

A policy whose state is enabled but whose key is absent here is reported by a check block. The
acknowledgement lives in code and gets reviewed in the pull request that enforces the policy, so
the decision to start denying sign-ins is recorded next to the change that does it rather than
inferred later from a state field.
set(string) [] no
named_locations Named locations, keyed by display name. Each is either an ip block or a country block, never
both.

trusted on an ip location is load bearing: a trusted location satisfies the "require MFA except
from a trusted location" shape, so anything inside it bypasses that control. Keep trusted ranges
small and specific. A check block warns when a trusted range is broader than a /16.

Each CIDR prefix must be /8 or larger, which the API enforces.
map(object({
ip = optional(object({
ip_ranges = list(string)
trusted = optional(bool, false)
}))

country = optional(object({
countries_and_regions = list(string)
country_lookup_method = optional(string, "clientIpAddress")
include_unknown_countries_and_regions = optional(bool, false)
}))
}))
{} no

Outputs

Name Description
authentication_strength_policies The full authentication strength policy objects, keyed by display name.
authentication_strength_policy_ids Map of display name to policy id.
break_glass_group_object_ids The break-glass groups excluded from every policy this module created. Echoed back so a downstream check can assert the module was given the groups the estate expects, rather than an empty list.
conditional_access_policies The full conditional access policy objects, keyed by the caller's handle.
conditional_access_policy_ids Map of the caller's handle to the policy object id.
disabled_policies Policies deployed but switched off, sorted.
enforced_policies Policies currently in the enabled state, sorted. This is the line worth reviewing in a pull request, because everything here is denying or challenging real sign-ins.
named_location_ids Map of display name to object id. Pass one of these to another module, or into a policy this module does not manage.
named_locations The full named location objects, keyed by display name.
policy_excluded_groups The effective excluded groups per policy, after the break-glass groups are merged in. The answer to 'is this policy survivable', without reading the portal.
report_only_policies Policies in report-only, sorted. Each is writing to the sign-in logs without affecting access, which is where a policy should live until its impact has been read.
trusted_named_locations Named locations flagged trusted, sorted. Each one is a bypass for any except-from-trusted-location control, so it is worth asserting against.

About

πŸ” Entra ID conditional access as code, built so a bad apply cannot lock you out: break-glass exclusions injected into every policy, report-only by default, enforcement as a recorded decision

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages