Public/Get-NSPConditionalAccessInventory.ps1

function Get-NSPConditionalAccessInventory {
    <#
    .SYNOPSIS
        Read-only snapshot of a tenant's Conditional Access state and the directory objects policies depend on.
    .DESCRIPTION
        Makes GET requests only. Requires a session signed in as this tool's own app, from
        Connect-NSPConditionalAccessGraph. Any other session is refused, even one with the right
        scopes. Captures:
          - organization identity and hybrid state (onPremisesSyncEnabled);
          - every Conditional Access policy and named location;
          - security groups, with sync state (used to bind group slots and spot duplicate names);
          - breakglass candidates by name (display name or UPN starting with 'breakglass');
          - every user a policy includes or excludes by ID, resolved, with the policies that
            reference it, sorted by how many policies exclude it (NotFound if since deleted);
          - group and active role memberships (transitive) of breakglass candidates and referenced
            users, so the preflight can evaluate group- and role-scoped policies;
          - custom authentication strengths, the Temporary Access Pass method settings,
            whether Security Defaults is on, and the licensed service plans;
          - directory sync accounts (members of either directory-sync role);
          - holders of the admin roles NSP-ADMIN-MFA-01 targets (active, tenant-wide; directly or
            through a role-assignable group), with their memberships;
          - directory role templates, cross-checked against the module's role table;
          - service principals for each application name the definitions reference.
 
        The result's Directory property is the -Directory input for
        Resolve-NSPConditionalAccessPolicy. Only names matching exactly one service principal are
        included; ambiguous and missing names are listed separately.
 
        -Save writes the snapshot under .nsp-conditionalaccess\inventory\<TenantId>\ (Git-ignored).
        It contains client identifiers and must never be committed.
    .EXAMPLE
        Connect-NSPConditionalAccessGraph -Tenant 'contoso.onmicrosoft.com'
        $inventory = Get-NSPConditionalAccessInventory -Save
    #>

    [CmdletBinding()]
    param(
        [string[]]$ApplicationName,
        [switch]$Save,
        [string]$ConfigRoot
    )

    Import-NSPM365Graph
    $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot

    if (-not $PSBoundParameters.ContainsKey('ApplicationName')) {
        $ApplicationName = @(Get-NSPConditionalAccessDefinition | ForEach-Object {
                foreach ($section in 'Include', 'Exclude') {
                    if ($_['Applications'] -is [System.Collections.IDictionary]) { $_['Applications'][$section] }
                }
            } | Where-Object { $_ -match '^\{App:(.+)\}$' } | ForEach-Object { $Matches[1] } | Sort-Object -Unique)
    }

    function Get-Query {
        param([string]$Path, [string]$Filter, [string]$Select)
        $query = @()
        if ($Filter) { $query += "`$filter=$([uri]::EscapeDataString($Filter))" }
        if ($Select) { $query += "`$select=$Select" }
        if ($query) { "$Path`?$($query -join '&')" } else { $Path }
    }

    function Show-Step {
        param([int]$Number, [string]$Text)
        Write-NSPConditionalAccessProgress -Activity Inventory -Status $Text -Step $Number -Of 14
    }

    Show-Step 1 'Tenant identity'
    $organization = @((Invoke-NSPGraphRequest -Uri (Get-Query 'organization' -Select 'id,displayName,verifiedDomains,onPremisesSyncEnabled,countryLetterCode'))['value'])[0]
    if ($organization['id'] -ne $context.TenantId) {
        throw "Organization $($organization['id']) does not match the session tenant $($context.TenantId)."
    }
    $defaultDomain = @($organization['verifiedDomains'] | Where-Object { $_ -and $_['isDefault'] })[0]

    Show-Step 2 'Conditional Access policies'
    $policies = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies')
    Show-Step 3 "Named locations ($($policies.Count) policies read)"
    $namedLocations = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations')
    Show-Step 4 'Security groups'
    $groups = @(Invoke-NSPGraphCollection -Uri (Get-Query 'groups' "securityEnabled eq true" 'id,displayName,onPremisesSyncEnabled,mailEnabled,securityEnabled,groupTypes'))
    Show-Step 5 "Breakglass accounts ($($groups.Count) groups read)"
    $breakGlass = @(Invoke-NSPGraphCollection -Uri (Get-Query 'users' "startswith(displayName,'breakglass') or startswith(userPrincipalName,'breakglass')" 'id,displayName,userPrincipalName,accountEnabled,onPremisesSyncEnabled'))
    Show-Step 6 'Directory roles'
    $roleTemplates = @(Invoke-NSPGraphCollection -Uri (Get-Query 'directoryRoleTemplates' -Select 'id,displayName'))
    Show-Step 7 'Authentication strengths'
    $strengths =@(Invoke-NSPGraphCollection -Uri 'policies/authenticationStrengthPolicies' | Where-Object { $_['policyType'] -eq 'custom' } | ForEach-Object {
            [ordered]@{ id = $_['id']; displayName = $_['displayName']; policyType = $_['policyType']; allowedCombinations = @($_['allowedCombinations']) }
        })
    # The Temporary Access Pass method settings, for the TAP-only registration policies. A read
    # failure is recorded, not thrown: the preflight reports it as unverified.
    Show-Step 8 'Temporary Access Pass settings'
    $tapMethod = try {
        $tap = Invoke-NSPGraphRequest -Uri 'policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass'
        [ordered]@{
            Available = $true
            State = $tap['state']
            IsUsableOnce = $tap['isUsableOnce']
            DefaultLifetimeInMinutes = $tap['defaultLifetimeInMinutes']
            IncludeTargets = @($tap['includeTargets'] | Where-Object { $_ } | ForEach-Object { [ordered]@{ Id = $_['id']; TargetType = $_['targetType'] } })
        }
    } catch {
        [ordered]@{ Available = $false; Reason = "$_" }
    }
    # Security Defaults: while on, Graph refuses to turn a Conditional Access policy On.
    Show-Step 9 'Security Defaults'
    $securityDefaults = try {
        $defaults = Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy'
        [ordered]@{ Available = $true; IsEnabled = [bool]$defaults['isEnabled'] }
    } catch {
        [ordered]@{ Available = $false; Reason = "$_" }
    }
    # Licenses: the service plans the tenant has, so the preflight can check what a policy needs
    # (Entra ID P2 for the risk policies). Organization.Read.All covers it.
    $licenses = try {
        $skus = @(Invoke-NSPGraphCollection -Uri 'subscribedSkus?$select=skuPartNumber,capabilityStatus,servicePlans')
        $active = @($skus | Where-Object { $_['capabilityStatus'] -in 'Enabled', 'Warning' })
        [ordered]@{
            Available = $true
            Skus = @($active | ForEach-Object { [string]$_['skuPartNumber'] } | Sort-Object -Unique)
            ServicePlans = @($active | ForEach-Object { @($_['servicePlans']) } | Where-Object { $_['provisioningStatus'] -ne 'Disabled' } | ForEach-Object { [string]$_['servicePlanName'] } | Sort-Object -Unique)
        }
    } catch {
        [ordered]@{ Available = $false; Reason = "$_" }
    }

    # Users are resolved once each. A user deleted since a policy referenced it is kept as NotFound:
    # a stale exclusion is itself a finding, not an error.
    $userCache = @{}
    $lookupUser = {
        param([string]$Id)
        if ($userCache.ContainsKey($Id)) { return $userCache[$Id] }
        $record = try {
            $user = Invoke-NSPGraphRequest -Uri "users/$Id`?`$select=id,displayName,userPrincipalName,accountEnabled,onPremisesSyncEnabled"
            [pscustomobject]@{
                Id = $Id
                Status = 'Found'
                DisplayName = $user['displayName']
                UserPrincipalName = $user['userPrincipalName']
                AccountEnabled = $user['accountEnabled']
                Synced = [bool]$user['onPremisesSyncEnabled']
            }
        } catch {
            if ("$_" -notmatch 'Request_ResourceNotFound|NotFound|404') { throw }
            [pscustomobject]@{ Id = $Id; Status = 'NotFound'; DisplayName = $null; UserPrincipalName = $null; AccountEnabled = $null; Synced = $null }
        }
        $userCache[$Id] = $record
        $record
    }

    # Directory Synchronization Accounts, and On Premises Directory Sync Account for newer Entra Connect.
    $syncRoles = @{
        'd29b2b05-8046-44ba-8758-1e26182fcf32' = 'Directory Synchronization Accounts'
        'a92aed5d-d78a-4d16-b381-09adb37eb3b0' = 'On Premises Directory Sync Account'
    }
    Show-Step 10 'Directory sync accounts'
    $syncAccounts = foreach ($roleId in $syncRoles.Keys | Sort-Object) {
        $assignments = @(Invoke-NSPGraphCollection -Uri (Get-Query 'roleManagement/directory/roleAssignments' "roleDefinitionId eq '$roleId'" 'principalId,roleDefinitionId'))
        foreach ($principalId in @($assignments | ForEach-Object { $_['principalId'] } | Sort-Object -Unique)) {
            $user = & $lookupUser $principalId
            $user | Select-Object *, @{ Name = 'SyncRole'; Expression = { $syncRoles[$roleId] } }
        }
    }
    $syncIds = @($syncAccounts | ForEach-Object { $_.Id })

    # Holders of the roles NSP-ADMIN-MFA-01 targets, so the preflight can check that an enforced
    # MFA policy reaches each one. A read failure is recorded, not thrown (AdminRolesUnverified).
    Show-Step 10 'Admin role holders'
    $adminRoles = try {
        $holders = Get-NSPConditionalAccessAdminRoleHolder
        [ordered]@{
            Available = $true
            Users = @(foreach ($id in $holders.Keys | Sort-Object) {
                    & $lookupUser $id | Select-Object *, @{ Name = 'Roles'; Expression = { @($holders[$id].Via) } }
                })
        }
    } catch {
        [ordered]@{ Available = $false; Reason = "$_"; Users = @() }
    }

    $references = [ordered]@{}
    $guidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
    foreach ($policy in $policies) {
        $users = $policy['conditions']['users']
        if (-not $users) { continue }
        foreach ($direction in 'include', 'exclude') {
            foreach ($id in @($users["$($direction)Users"] | Where-Object { $_ -match $guidPattern })) {
                if (-not $references.Contains($id)) { $references[$id] = @{ include = [System.Collections.Generic.List[string]]::new(); exclude = [System.Collections.Generic.List[string]]::new() } }
                $references[$id][$direction].Add($policy['displayName'])
            }
        }
    }
    $done = 0
    $referencedUsers = foreach ($id in $references.Keys) {
        $done++
        Show-Step 11 "Users named in policies: $done of $($references.Count)"
        & $lookupUser $id | Select-Object *,
            @{ Name = 'ExcludedBy'; Expression = { $references[$id]['exclude'].ToArray() } },
            @{ Name = 'IncludedBy'; Expression = { $references[$id]['include'].ToArray() } },
            @{ Name = 'IsSyncAccount'; Expression = { $id -in $syncIds } }
    }

    # Group and active role memberships (transitive) of the users a breakglass account can be
    # chosen from, so the preflight can tell whether a group- or role-scoped policy reaches it.
    $memberships = [ordered]@{}
    $memberIds = @(@($breakGlass | ForEach-Object { $_['id'] }) + @($referencedUsers | Where-Object Status -eq 'Found' | ForEach-Object Id) +
        @($adminRoles.Users | Where-Object Status -eq 'Found' | ForEach-Object Id) | Sort-Object -Unique)
    $done = 0
    foreach ($id in $memberIds) {
        $done++
        Show-Step 12 "Group and role memberships: $done of $($memberIds.Count) accounts"
        $memberOf =@(Invoke-NSPGraphCollection -Uri "users/$id/transitiveMemberOf?`$select=id,roleTemplateId")
        $memberships[$id] = [ordered]@{
            Groups = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.group' } | ForEach-Object { $_['id'] })
            Roles = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.directoryRole' } | ForEach-Object { $_['roleTemplateId'] })
        }
    }

    $applications = [ordered]@{}
    $ambiguous = [System.Collections.Generic.List[object]]::new()
    $notFound = [System.Collections.Generic.List[string]]::new()
    Show-Step 13 "Applications the templates name ($(@($ApplicationName).Count))"
    foreach ($name in $ApplicationName) {
        $matched = @(Invoke-NSPGraphCollection -Uri (Get-Query 'servicePrincipals' "displayName eq '$($name -replace "'", "''")'" 'id,appId,displayName'))
        switch ($matched.Count) {
            0 { $notFound.Add($name) }
            1 { $applications[$name] = $matched[0]['appId'] }
            default { $ambiguous.Add([pscustomobject]@{ Name = $name; AppIds = @($matched | ForEach-Object { $_['appId'] }) }) }
        }
    }

    $roleTableCheck = foreach ($entry in (Get-NSPConditionalAccessRoleTable).GetEnumerator() | Sort-Object Key) {
        $template = @($roleTemplates | Where-Object { $_['id'] -eq $entry.Value })[0]
        $status = if (-not $template) { 'Missing' }
        elseif (($template['displayName'] -replace '[^A-Za-z]', '') -eq $entry.Key) { 'Match' }
        else { 'NameDiffers' }
        [pscustomobject]@{
            Name = $entry.Key
            TemplateId = $entry.Value
            TenantDisplayName = if ($template) { $template['displayName'] } else { $null }
            Status = $status
        }
    }

    $inventory = [pscustomobject][ordered]@{
        SchemaVersion = 1
        CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        TenantId = $context.TenantId
        TenantDomain = if ($defaultDomain) { $defaultDomain['name'] } else { $null }
        Hybrid = [bool]$organization['onPremisesSyncEnabled']
        Organization = $organization
        Policies = $policies
        NamedLocations = $namedLocations
        Groups = $groups
        BreakGlassCandidates = $breakGlass
        ReferencedUsers = @($referencedUsers | Sort-Object { - @($_.ExcludedBy).Count }, DisplayName)
        SyncAccounts = @($syncAccounts)
        AdminRoles = $adminRoles
        Memberships = $memberships
        AuthenticationStrengths = $strengths
        TemporaryAccessPass = $tapMethod
        SecurityDefaults = $securityDefaults
        Licenses = $licenses
        RoleTemplates = $roleTemplates
        RoleTableCheck = @($roleTableCheck)
        Directory = [ordered]@{ Applications = $applications }
        AmbiguousApplications = $ambiguous.ToArray()
        MissingApplications = $notFound.ToArray()
        SavedPath = $null
    }

    if ($Save) {
        Show-Step 14 'Saving'
        $directory = Join-Path (Get-NSPConditionalAccessLocalPath -Kind Inventory -ConfigRoot $ConfigRoot) $context.TenantId
        New-Item -ItemType Directory -Path $directory -Force | Out-Null
        $inventory.SavedPath = Join-Path $directory ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json')
        ConvertTo-Json -InputObject $inventory -Depth 32 | Set-Content -LiteralPath $inventory.SavedPath -Encoding UTF8
    }
    Write-NSPConditionalAccessProgress -Activity Inventory -Completed
    $inventory
}