Skip to content

Latest commit

 

History

History

complete

README.md

Complete example

A break-glass group, two named locations, an authentication strength, and three policies covering the shapes an estate actually uses.

Read this before running it. Conditional access is tenant scoped and security critical. Unlike the other examples in this library, applying this one changes the security configuration of a real tenant rather than creating disposable resources in a resource group.

What makes it safe to run:

  • Every policy is left in report-only, the module's default. Report-only policies write what they would have done to the sign-in logs and affect nobody's access. enforcement_acknowledged is deliberately empty, so nothing here is enforced.
  • A break-glass group is created and excluded from every policy automatically.

What to check anyway: that you are pointed at a test tenant, and that a destroy afterwards is acceptable. just e2e complete does both.

The group's members are deliberately not managed here. An emergency access account provisioned by the same pipeline that could lock you out is not an emergency access account.

Three details worth copying: the legacy-authentication block is safe as a tenant-wide block precisely because client_app_types narrows it to protocols that cannot do modern authentication; the privileged-role policy composes an authentication strength by key rather than by copying an id; and the location-based policy requires MFA rather than blocking, because a block-everything-except-a-location policy is the classic way to lock a tenant out when the location turns out to be wrong.

Run it with just e2e complete, which applies the stack then always destroys it.

Terraform Registry

Example configuration

# Complete call: a break-glass group, a named location, an authentication strength, and three
# policies covering the shapes an estate actually uses.
#
# READ THIS BEFORE RUNNING IT. Conditional access is tenant scoped and security critical. Unlike the
# other examples in this library, applying this one changes the security configuration of a real
# tenant rather than creating disposable resources in a resource group.
#
# What makes it safe to run:
#
#   - EVERY policy is left in report-only (the module's default). Report-only policies write what
#     they WOULD have done to the sign-in logs and affect nobody's access. enforcement_acknowledged
#     is deliberately empty, so nothing here is enforced.
#   - A break-glass group is created and excluded from every policy automatically.
#
# What to check before running it anyway: that you are pointed at a test tenant, and that a
# `terraform destroy` afterwards is acceptable. `just e2e complete` does both.
locals {
  break_glass_name = "grp-${var.short}-break-glass-emergency-access"
}

# The emergency access group. Microsoft's guidance is two cloud-only accounts, excluded from every
# conditional access policy, with credentials held offline. This creates the group; putting the
# accounts in it is deliberately manual, because an emergency account provisioned by the same
# pipeline that could lock you out is not an emergency account.
resource "azuread_group" "break_glass" {
  display_name     = local.break_glass_name
  description      = "Emergency access accounts, excluded from every conditional access policy. Members are added by hand, never by pipeline."
  security_enabled = true
}

module "conditional_access" {
  source = "../../"

  # Injected into the excluded groups of every policy below. This is the module's whole reason for
  # existing: it cannot be forgotten on the one policy where forgetting it matters.
  break_glass_group_object_ids = [azuread_group.break_glass.object_id]

  named_locations = {
    "${var.short} office ranges" = {
      ip = {
        # Documentation ranges (RFC 5737). Replace with your own egress ranges.
        ip_ranges = ["203.0.113.0/24", "198.51.100.0/24"]
        trusted   = true
      }
    }

    "${var.short} permitted countries" = {
      country = {
        countries_and_regions                 = ["GB", "IE"]
        include_unknown_countries_and_regions = false
      }
    }
  }

  # Phishing-resistant methods only. This is what the built-in mfa control is not: mfa accepts any
  # second factor, including SMS and voice, which an adversary-in-the-middle kit relays without
  # difficulty.
  authentication_strength_policies = {
    "${var.short} phishing resistant" = {
      description = "FIDO2, Windows Hello for Business, or certificate-based authentication."

      allowed_combinations = [
        "fido2",
        "windowsHelloForBusiness",
        "x509CertificateMultiFactor",
      ]
    }
  }

  conditional_access_policies = {
    # The canonical legacy authentication block. Safe as a tenant-wide block precisely because
    # client_app_types narrows it to the protocols that cannot do modern authentication, which is
    # why the module's tenant-wide-block check does not fire on it.
    "block-legacy-authentication" = {
      display_name = "Block legacy authentication"

      conditions = {
        client_app_types = ["exchangeActiveSync", "other"]
        applications     = { included_applications = ["All"] }
        users            = { included_users = ["All"] }
      }

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

    # Phishing-resistant authentication for privileged roles, composed by KEY rather than by copying
    # the strength policy's id. The role GUID is the well-known template id for Global Administrator.
    "require-phishing-resistant-for-admins" = {
      display_name = "Require phishing-resistant authentication for privileged roles"

      conditions = {
        applications = { included_applications = ["All"] }
        users        = { included_roles = ["62e90394-69f5-4237-9190-012177145e10"] }
      }

      grant_controls = {
        authentication_strength_key = "${var.short} phishing resistant"
      }
    }

    # MFA for everyone, except from the office ranges, composed by location KEY. Note this requires
    # MFA rather than blocking: a block-everything-except-a-location policy is the classic way to
    # lock a tenant out when the location definition turns out to be wrong.
    "require-mfa-outside-the-office" = {
      display_name = "Require MFA outside the office ranges"

      conditions = {
        applications = { included_applications = ["All"] }
        users        = { included_users = ["All"] }

        locations = {
          included_locations     = ["All"]
          excluded_location_keys = ["${var.short} office ranges"]
        }
      }

      grant_controls = {
        built_in_controls = ["mfa"]
      }

      session_controls = {
        sign_in_frequency          = 12
        sign_in_frequency_period   = "hours"
        sign_in_frequency_interval = "timeBased"
      }
    }
  }

  # Deliberately empty. Every policy above stays in report-only, and enforcing one is a separate,
  # reviewed change that adds its key here.
  enforcement_acknowledged = []
}

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

Name Source Version
conditional_access ../../ n/a

Resources

Name Type
azuread_group.break_glass resource

Inputs

Name Description Type Default Required
short Infix: short product code used in resource names. string "ldo" no

Outputs

Name Description
authentication_strength_policy_ids Map of display name to authentication strength policy id.
break_glass_group_object_id The emergency access group excluded from every policy. Add its members by hand.
conditional_access_policy_ids Map of the caller's handle to the policy object id.
enforced_policies Policies actively denying or challenging sign-ins. Empty in this example by design.
named_location_ids Map of display name to object id.
policy_excluded_groups Effective excluded groups per policy after the break-glass groups are merged in. The answer to whether each policy is survivable.
report_only_policies Policies writing to the sign-in logs without affecting access.