Post

Terraforming Your Existing Entra ID Conditional Access Policies

Terraforming Your Existing Entra ID Conditional Access Policies

This guide demonstrates how to import existing Microsoft Entra ID Conditional Access Policies into Terraform using PowerShell, enabling Infrastructure as Code without any manual policy recreation.

Pre-requisites

  1. You have Terraform installed.
  2. Azure CLI to run Terraform interactively.
  3. Microsoft Graph PowerShell SDK or at least Microsoft.Graph.Authentication.
  4. Entra ID Permissions (‘Policy.Read.All’)- global reader is probably your best bet.

For your convenience, all the scripts and required terraform files are available in the blog-code-snippets repository.

Steps to import Conditional Access Policies (CAPs)

1. Setup the environment

In the working directory, configure the providers.tf with your tenant_id.

1
2
3
4
5
6
7
8
9
10
11
12
terraform {
  required_version = ">= 1.11.0"
  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.3.0"
    }
  }
}
provider "azuread" {
  tenant_id = "f9373f4a-a17e-4478-b0c8-1507ef1401cf"
}

Run terraform init to initialize the Terraform directory and download the required providers:

terraform init

2. Generate terraform import config

Run the .\1_generate-imports.ps1 script to generate imports.tf.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Retrieve tenant_id from providers.tf
$tenantId = ((Get-Content .\providers.tf | Where-Object { $_ -match "tenant_id" }) -split '"')[1]

# Ensure Graph Connection with required scope
$mgContext = Get-MgContext
if (-not $mgContext -or
    ($mgContext.TenantId -ne $tenantId) -or
    ("Policy.Read.All" | Where-Object { $_ -notin $mgContext.scopes }).Count
) { Connect-MgGraph -TenantId $tenantId -Scopes "Policy.Read.All" -NoWelcome -ErrorAction Stop -Debug:$false }

$graphData = @()
"namedLocations","policies" | ForEach-Object {
    $graphData += Invoke-GraphRequest -Uri "v1.0/identity/conditionalAccess/$_/`?select=id,displayName" -OutputType PSObject
}

$content = @()
foreach ( $query in $graphData ) {

  if ( $query.'@odata.context' -match "namedLocations" ) { 
    $identifierType = "namedLocations"
    $destinationType = "named_location"
  }
  else { 
    $identifierType = "policies"
    $destinationType = "conditional_access_policy"
  }

  # terraform import code block formatting
  foreach ( $object in $query.value ) {
    $content += "import {"
    $content += "    id = `"identity/conditionalAccess/$identifierType/$($object.id)`""
    $content += "    to = azuread_$destinationType.$($object.displayName -replace '\s', '_' -replace '\W')"
    $content += "}"
  }
}
$content | Out-File "imports.tf" -Encoding utf8 -Force

The resulting imports.tf will contain the necessary Terraform import statements for our CA and named locations: imports.tf sample

3. Import the configuration

Run terraform plan -generate-config-out="generated.tf" to generate terraform configuration in generated.tf.

Expect errors like below, config generation is still experimental. We will clean these up in the next step!

import errors

The resulting file will look similar to this: generated.tf

4. Clean Up the Config

Run the .\3_clean_files.ps1 script to clean the generated config files and split them into multiple files, this will get rid of most of the errors and warnings in the previous step.

Named locations will be saved to _named_locations.tf.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Clean up the generated terraform files and split them into multiple files prefixed by underscore.
$path = "generated.tf"

# removes empty blocks and null values (including sign_in_frequency = 0 (bug in the terraform provider))
$fileContent = Get-Content $path -Encoding UTF8
$fileContent | Where-Object { $_ -notmatch "\[\]$|=\snull$|sign_in_frequency\s+=\s0$" } | Out-File $path -Encoding utf8 -Force

# split the file into multiple files
$fileContent = Get-Content $path -Raw
$resources = $fileContent -split "# __generated__ by Terraform.*\r?\n" | Where-Object { $_ -match "^resource" }

# named locations go in one file
$namedLocationsContent = @()
foreach ( $res in $resources ) {
    # extract the resource type and name from the first line
    $metadata = ($res -split "\r?\n" )[0] -replace "`"" -split "\s"
    $type = $metadata[1]
    $name = $metadata[2]

    if ( $type -match "azuread_conditional_access_policy" ) {  $res | Out-File "$name.tf" -Encoding utf8 -Force }
    elseif ( $type -match "azuread_named_location" ) { $namedLocationsContent += $res }
    else { Write-Warning "Unknown resource type: $type" }
}
$namedLocationsContent | Out-File "_named_locations.tf" -Encoding utf8 -Force

# Fix the formatting
terraform fmt | Out-Null

# remove files that are no longer needed
remove-item $path -Force
remove-item imports.tf -Force

The resulting files will look similar to this: clean.tf

5. Moment of truth

Run terraform validate to validate the configuration. If everything is correct, you should see a message like this: clean.tf

If you still encounter errors, you may need to resolve these manually, the ‘AzureAd’ Terraform provider is under development and new Conditional Access Policy features often lag behind.

Known Issues

This post is licensed under CC BY 4.0 by the author.