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.
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.
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.
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.
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.
- Unacknowledged enforcement. A policy set to
enabledwhose key is not inenforcement_acknowledged. - Tenant-wide block.
blockplus 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 becauseclient_app_typesnarrows it toexchangeActiveSyncandother. 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.
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.
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/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.
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.
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.
The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.
| Name | Version |
|---|---|
| terraform | >= 1.9.0, < 2.0.0 |
| azuread | >= 3.0.0, < 4.0.0 |
| Name | Version |
|---|---|
| azuread | >= 3.0.0, < 4.0.0 |
No modules.
| Name | Type |
|---|---|
| azuread_authentication_strength_policy.this | resource |
| azuread_conditional_access_policy.this | resource |
| azuread_named_location.this | resource |
| 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({ |
{} |
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({ |
{} |
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({ |
{} |
no |
| 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. |