Skip to main content

Conditional Access Policy Report: Export Everything to Get an Overview

The Problem
#

You have 40 Conditional Access policies. Some target users, some target groups, some target roles. Some include locations, some exclude them. Some enforce MFA, some enforce compliant devices, some do both with different operators. And you need to answer the question: “What is our current CA posture?”

The Entra ID portal shows one policy at a time. There is no native export. There is no overview that lets you see all policies side by side with all their conditions and controls. So you end up with a shared spreadsheet that someone manually maintains and that is always out of date.

Prerequisites
#

  • PowerShell 7.x with Microsoft.Graph.Authentication and ImportExcel modules
  • Interactive sign-in via Connect-MgGraph with these delegated scopes:
    • Policy.Read.All, Directory.Read.All, Agreement.Read.All (for Terms of Use resolution)
  • An account with read access to Conditional Access policies

Implementation
#

The script hits Graph, resolves every GUID to something a human can read, and dumps everything into Excel – one row per policy, one column per condition or control. No more clicking through 40 policy blades.

Lookup Maps
#

Before processing policies, the script loads reference data for roles, named locations, and terms of use. Note that roles come from two sources – directoryRoleTemplates and roleDefinitions – because some custom roles only appear in the latter:

function Initialize-LookupMaps {
    $script:roleById = @{}
    $script:locationById = @{}
    $script:termsById = @{}

    # Directory role templates
    $roleTemplatesResp = Invoke-GraphGetPaged -Uri `
        "https://graph.microsoft.com/v1.0/directoryRoleTemplates?`$select=id,displayName"
    $roleTemplates = if ($roleTemplatesResp.value) { @($roleTemplatesResp.value) } else { @() }
    foreach ($r in $roleTemplates) {
        if ($r.id -and $r.displayName) {
            $script:roleById[[string]$r.id] = [string]$r.displayName
        }
    }

    # Custom role definitions (not always in directoryRoleTemplates)
    $roleDefsResp = Invoke-GraphGetPaged -Uri `
        "https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?`$select=id,displayName"
    $roleDefs = if ($roleDefsResp.value) { @($roleDefsResp.value) } else { @() }
    foreach ($r in $roleDefs) {
        if ($r.id -and $r.displayName -and -not $script:roleById.ContainsKey([string]$r.id)) {
            $script:roleById[[string]$r.id] = [string]$r.displayName
        }
    }

    # Named locations + Terms of Use follow the same pattern
}

Throttle-Safe Graph Calls
#

All Graph calls go through Invoke-MgGraphRequest (the Graph SDK handles token refresh). The wrapper retries on 429, 503, and 504 with exponential backoff (5s, 10s, 20s, 40s, 60s cap):

function Invoke-GraphGetSafe {
    param([Parameter(Mandatory)][string]$Uri)

    for ($attempt = 1; $attempt -le 5; $attempt++) {
        try {
            return Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop
        }
        catch {
            $msg = $_.Exception.Message
            if (($msg -match '429') -or ($msg -match 'Too Many Requests') -or
                ($msg -match '503') -or ($msg -match '504')) {
                $wait = [Math]::Min(5 * [Math]::Pow(2, $attempt - 1), 60)
                Start-Sleep -Seconds $wait
                continue
            }
            return $null
        }
    }
    return $null
}

Resolver Caching
#

The resolver functions cache results so the same group GUID is only looked up once, even if it appears in 20 policies. In a tenant with 60+ policies sharing the same 10 groups, the difference between caching and not is the difference between “done in 20 seconds” and “throttled by Graph and waiting forever”:

function Resolve-GroupName {
    param([string]$Id)
    if (-not (Test-IsGuid $Id)) { return $Id }
    if ($script:groupById.ContainsKey($Id)) { return $script:groupById[$Id] }

    $g = Invoke-GraphGetSafe -Uri `
        "https://graph.microsoft.com/v1.0/groups/$Id?`$select=id,displayName"
    if (-not $g) {
        $g = Invoke-GraphGetSafe -Uri `
            "https://graph.microsoft.com/v1.0/directoryObjects/$Id"
    }
    $name = if ($g.displayName) { [string]$g.displayName } else { $Id }
    $script:groupById[$Id] = $name
    return $name
}

The Flat Report
#

Each policy is flattened into a row. The final output includes policyName, state, createdDateTime, modifiedDateTime, policyId, and then all conditions and controls expanded into separate columns – inclUsers, exclUsers, inclGroups, exclGroups, inclRoles, exclRoles, inclApps, inclLocations, grantOperator, grantControls, sessionControls, and more. The grantOperator column is particularly useful – it shows whether a policy requires AND or OR logic across its grant controls. The createdDateTime and modifiedDateTime columns help you spot stale report-only policies that have been sitting untouched for months. Multi-value fields are joined with line breaks and the Excel export applies word wrap so they’re actually readable.

What to Look For
#

When reviewing the report, these patterns indicate problems:

  • Enabled policies with no exclusions – no break-glass account excluded means you can lock yourself out
  • Policies targeting “All Users” with broad app scope – check that these have the conditions you think they have
  • Disabled or report-only policies that have been sitting for months – either enable them or clean them up
  • Overlapping grant controls with different operators – two policies requiring different things for the same audience creates confusion
  • Missing platform coverage – if you enforce device compliance for Windows but not macOS, the report makes that gap obvious

Security Considerations
#

  • Policy.Read.All gives read access to all CA policies – your security controls are in this data. Treat the output as confidential.
  • The script only reads data. It does not modify any policies.
  • Guest and external user scoping is parsed and included in the report – review these carefully for over-permissive policies.

GitHub
#

Script on GitHub