Public/Get-MsecEntraConditionalAccessPolicy.ps1

function Get-MsecEntraConditionalAccessPolicy {
    <#
    .SYNOPSIS
        Lists every Microsoft Entra Conditional Access policy as flat rows, with
        the conditions and grant controls flattened to top-level columns.

    .DESCRIPTION
        Calls Microsoft Graph /v1.0/identity/conditionalAccess/policies and
        projects each policy to a PSCustomObject. The Graph response is deeply
        nested (conditions.users.includeGroups, conditions.applications.*, etc.);
        the projection flattens the audit-relevant arrays to top-level columns
        so Where-Object / Group-Object / Export-Excel work naturally.

        Use this for inventory ("what CA policies are configured, what do they
        enforce, who are they targeted at"). For effectiveness data ("did the
        policy fire? was anyone blocked?"), see Get-MsecEntraConditionalAccessSignInLog
        - that comes from sign-in events, not policy objects.

        Requires the 'Policy.Read.All' application permission. A clearer error is
        raised on the typical 403.

    .EXAMPLE
        Get-MsecEntraConditionalAccessPolicy | Group-Object State

    .EXAMPLE
        # Policies that DO require MFA - the headline CA evidence for an audit.
        Get-MsecEntraConditionalAccessPolicy |
            Where-Object Requires -contains 'mfa' |
            Select DisplayName, State, IncludedGroups

    .EXAMPLE
        # Report-only policies — they don't enforce, just observe. Worth tracking.
        Get-MsecEntraConditionalAccessPolicy |
            Where-Object State -eq 'enabledForReportingButNotEnforced'

    .OUTPUTS
        PSCustomObject per policy. See .NOTES for the projection.

    .NOTES
        Each row is a [PSCustomObject] with PSTypeName 'MsecEntraConditionalAccessPolicy'.
        That type has a DefaultDisplayPropertySet (DisplayName, State, Requires,
        IncludedGroups) registered in the module's .psm1 - so Format-Table shows a
        clean 4-column view by default. The Raw column is fully accessible via
        $row.Raw or | Format-List, it just doesn't clutter the default table.

        Projection (Graph field path -> output property):
          id -> Id
          displayName -> DisplayName
          state -> State
          createdDateTime / modifiedDateTime -> CreatedDateTime / ModifiedDateTime
          conditions.users.includeUsers/Groups/Roles -> IncludedUsers / IncludedGroups / IncludedRoles
          conditions.users.excludeUsers/Groups/Roles -> ExcludedUsers / ExcludedGroups / ExcludedRoles
          conditions.applications.includeApplications -> IncludedApps
          conditions.applications.excludeApplications -> ExcludedApps
          conditions.applications.includeUserActions -> UserActions
          conditions.platforms.includePlatforms -> IncludedPlatforms
          conditions.platforms.excludePlatforms -> ExcludedPlatforms
          conditions.locations.includeLocations -> IncludedLocations
          conditions.locations.excludeLocations -> ExcludedLocations
          conditions.clientAppTypes -> ClientAppTypes
          conditions.signInRiskLevels -> SignInRiskLevels
          conditions.userRiskLevels -> UserRiskLevels
          grantControls.operator -> GrantOperator ('OR' / 'AND')
          grantControls.builtInControls -> Requires (mfa, compliantDevice, ...)
          <entire policy object verbatim from Graph> -> Raw (PSObject; full detail)

        Use Raw for audit dives, JSON backup, or change-diff:
          Get-MsecEntraConditionalAccessPolicy |
              ForEach-Object { $_.Raw | ConvertTo-Json -Depth 20 |
                               Set-Content "./ca-policies/$($_.Id).json" }
    #>

    [CmdletBinding()]
    param()

    Assert-MsecSession

    $path = '/v1.0/identity/conditionalAccess/policies'

    try {
        $policies = @(Invoke-MsecGraphRequest -Path $path -All)
    }
    catch {
        if ($_.Exception.Message -match '403|Forbidden') {
            throw "Forbidden when calling /identity/conditionalAccess/policies. The msec app needs the 'Policy.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $($_.Exception.Message)"
        }
        throw
    }

    # @($null) returns a single-element array containing $null, NOT an empty
    # array - which then poisons downstream Where-Object -contains and Count
    # checks. Use this helper to coerce missing/null to a real empty array.
    $arr = { param($v) if ($null -eq $v) { @() } else { @($v) } }

    foreach ($p in $policies) {
        $c  = $p.conditions       # may be $null in pathological cases, PS nested access still safe
        $gc = $p.grantControls

        $row = [PSCustomObject]@{
            PSTypeName          = 'MsecEntraConditionalAccessPolicy'

            Id                  = $p.id
            DisplayName         = $p.displayName
            State               = $p.state
            CreatedDateTime     = if ($p.createdDateTime)  { [datetime]$p.createdDateTime }  else { $null }
            ModifiedDateTime    = if ($p.modifiedDateTime) { [datetime]$p.modifiedDateTime } else { $null }

            # Users / groups / roles targeted
            IncludedUsers       = & $arr $c.users.includeUsers
            ExcludedUsers       = & $arr $c.users.excludeUsers
            IncludedGroups      = & $arr $c.users.includeGroups
            ExcludedGroups      = & $arr $c.users.excludeGroups
            IncludedRoles       = & $arr $c.users.includeRoles
            ExcludedRoles       = & $arr $c.users.excludeRoles

            # Apps + user actions
            IncludedApps        = & $arr $c.applications.includeApplications
            ExcludedApps        = & $arr $c.applications.excludeApplications
            UserActions         = & $arr $c.applications.includeUserActions

            # Platforms (entirely absent when no platform restriction is set)
            IncludedPlatforms   = & $arr $c.platforms.includePlatforms
            ExcludedPlatforms   = & $arr $c.platforms.excludePlatforms

            # Locations (entirely absent when no location restriction is set)
            IncludedLocations   = & $arr $c.locations.includeLocations
            ExcludedLocations   = & $arr $c.locations.excludeLocations

            # Risk + client app conditions
            ClientAppTypes      = & $arr $c.clientAppTypes
            SignInRiskLevels    = & $arr $c.signInRiskLevels
            UserRiskLevels      = & $arr $c.userRiskLevels

            # What the policy enforces (when conditions match)
            GrantOperator       = $gc.operator
            Requires            = & $arr $gc.builtInControls

            # Raw - the full nested Graph object. Useful for audit dives /
            # JSON backup / change-diff. Hidden from default Format-Table view
            # by the DefaultDisplayPropertySet registered in Msec.psm1.
            Raw                 = $p
        }
        $row
    }
}