Public/Get-NSPADVpnInventory.ps1

function Get-NSPADVpnInventory {
    <#
    .SYNOPSIS
        Collects the AD side of an IKEv2 VPN deployment - VPN groups (members, nesting, SIDs),
        client-authentication certificate templates (Enroll/AutoEnroll groups), and TameMyCerts OU
        stamps - into the SchemaVersion 1 object NSP.FortiGate's VPN report reads. Read-only.
 
    .DESCRIPTION
        Groups are seeded from -GroupPattern name matches, every SID an NPS policy names in ias.xml,
        and the ordinary domain groups granted Enroll/AutoEnroll on a collected template; then widened
        to their direct parents and every nested member group. Templates are schema v2+ with Client
        Authentication (or matching -TemplatePattern).
 
        Queries AD over LDAP (ADSI - no RSAT needed). Run it on the CA/NPS server when possible:
        ias.xml and the TameMyCerts policy folder are then found automatically.
 
        SchemaVersion 1 contract (NSP.FortiGate reads these names; add fields freely, but renaming or
        removing one is a SchemaVersion bump): SchemaVersion, Generated, Domain, ComputerName, Tool,
        Groups[], Templates[], TameMyCerts, Sources.
 
    .PARAMETER GroupPattern
        sAMAccountName wildcards seeding the group list.
 
    .PARAMETER TemplatePattern
        Collect templates matching this name instead of the client-auth rule.
 
    .PARAMETER NpsConfigPath
        ias.xml to read NPS group SIDs from (defaults to this machine's, when it is the NPS server).
 
    .PARAMETER PolicyDirectory
        TameMyCerts policy folder (defaults to the one in this machine's CA registry).
 
    .EXAMPLE
        $inv = Get-NSPADVpnInventory -GroupPattern 'VPN*'
        $inv.Groups | Select-Object Name, RecursiveUserCount
    #>

    [CmdletBinding()]
    param(
        [string[]]$GroupPattern = @('IKEv2*', 'VPNFW*', 'FGT*'),
        [string]$TemplatePattern,
        [string]$NpsConfigPath,
        [string]$PolicyDirectory
    )

    $context = Get-ADInvNamingContext
    $sidOf = { param($Item) if ($Item['objectsid']) { (New-Object System.Security.Principal.SecurityIdentifier([byte[]]$Item['objectsid'][0], 0)).Value } }
    $groupProperties = @('samaccountname', 'distinguishedname', 'objectsid', 'description', 'memberof')
    $escapeLdap = { param([string]$Text) ($Text -replace '\\', '\5c' -replace '\*', '\2a' -replace '\(', '\28' -replace '\)', '\29') }

    if (-not $NpsConfigPath) {
        $local = Join-Path $env:SystemRoot 'System32\ias\ias.xml'
        if (Test-Path $local) { $NpsConfigPath = $local }
    }
    $npsSids = @()
    if ($NpsConfigPath -and (Test-Path -LiteralPath $NpsConfigPath)) {
        $npsSids = Get-ADNpsGroupSids -IasText (Get-Content -LiteralPath $NpsConfigPath -Raw)
    }

    # Templates first: their Enroll/AutoEnroll groups are group seeds too.
    $templateBase = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($context.Configuration)"
    $publishers = @(Search-ADInvDirectory -SearchBase "CN=Enrollment Services,CN=Public Key Services,CN=Services,$($context.Configuration)" -Filter '(objectClass=pKIEnrollmentService)' -Property 'cn', 'certificatetemplates')
    $templateItems = @(Search-ADInvDirectory -SearchBase $templateBase -Filter '(objectClass=pKICertificateTemplate)' -Property 'cn', 'displayname', 'distinguishedname', 'mspki-cert-template-oid', 'mspki-template-schema-version', 'pkiexpirationperiod', 'pkioverlapperiod', 'mspki-certificate-name-flag', 'pkiextendedkeyusage', 'mspki-certificate-application-policy')

    if (-not $PolicyDirectory) {
        $PolicyDirectory = Get-ADInvTameMyCertsDirectory
        $tmcSource = if ($PolicyDirectory) { 'CA registry' } else { $null }
    } else { $tmcSource = 'parameter' }
    $stamps = [ordered]@{}
    if ($PolicyDirectory -and (Test-Path -LiteralPath $PolicyDirectory)) {
        foreach ($file in Get-ChildItem -LiteralPath $PolicyDirectory -Filter '*.xml' -File) {
            $ou = Get-ADTameMyCertsOuStamp -XmlText (Get-Content -LiteralPath $file.FullName -Raw)
            $stamps[$file.BaseName] = [pscustomobject]@{ Template = $file.BaseName; OuValue = $ou; File = $file.Name }
        }
    }

    $templates = foreach ($item in $templateItems) {
        $name = [string]$item['cn'][0]
        $ekus = @($item['pkiextendedkeyusage']) + @($item['mspki-certificate-application-policy'])
        $schema = if ($item['mspki-template-schema-version']) { [int]$item['mspki-template-schema-version'][0] } else { 1 }
        $wanted = if ($TemplatePattern) { $name -like $TemplatePattern } else { $ekus -contains $script:ADInvClientAuthEku -and $schema -ge 2 -and $script:ADInvSkipTemplates -notcontains $name }
        if (-not $wanted -and -not $stamps.Contains($name)) { continue }
        $flags = if ($item['mspki-certificate-name-flag']) { [int64]$item['mspki-certificate-name-flag'][0] } else { 0 }
        $access = Get-ADTemplateAccess -Rule @(Get-ADInvAccessRule -DistinguishedName ([string]$item['distinguishedname'][0]))
        [pscustomobject]@{
            Name                    = $name
            DisplayName             = [string]($item['displayname'] | Select-Object -First 1)
            Oid                     = [string]($item['mspki-cert-template-oid'] | Select-Object -First 1)
            SchemaVersion           = $schema
            ValidityDays            = ConvertFrom-ADTemplatePeriod -Bytes ($item['pkiexpirationperiod'] | Select-Object -First 1)
            RenewalDays             = ConvertFrom-ADTemplatePeriod -Bytes ($item['pkioverlapperiod'] | Select-Object -First 1)
            NameFlags               = ('0x{0:X8}' -f ($flags -band 0xFFFFFFFF))
            EnrolleeSuppliesSubject = [bool]($flags -band 1)
            Enroll                  = $access.Enroll
            AutoEnroll              = $access.AutoEnroll
            PublishedOn             = @($publishers | Where-Object { @($_['certificatetemplates']) -contains $name } | ForEach-Object { [string]$_['cn'][0] })
            OuStamp                 = if ($stamps.Contains($name)) { $stamps[$name].OuValue } else { $null }
        }
    }
    $templates = @($templates)

    # Group seeds: name patterns, NPS SIDs, template enrollment groups.
    $groups = [ordered]@{}
    $addGroup = {
        param($Item)
        $dn = [string]$Item['distinguishedname'][0]
        if (-not $groups.Contains($dn)) { $groups[$dn] = $Item }
    }
    $patternFilter = '(&(objectCategory=group)(|' + (($GroupPattern | ForEach-Object { "(sAMAccountName=$($_ -replace '\\', '\5c' -replace '\(', '\28' -replace '\)', '\29'))" }) -join '') + '))'
    foreach ($item in Search-ADInvDirectory -SearchBase $context.Default -Filter $patternFilter -Property $groupProperties) { & $addGroup $item }
    # Template ACLs also grant Domain Admins, Domain Computers, RAS and IAS Servers and similar
    # built-ins; seeding from those pulls half the directory in. Only ordinary domain groups (RID
    # 1000+) seed from templates; NPS SIDs are always deliberate.
    $templateSids = @($templates | ForEach-Object { $_.Enroll; $_.AutoEnroll } | ForEach-Object Sid | Where-Object { Test-ADInvOrdinaryDomainSid -Sid $_ })
    $seedSids = @($npsSids) + $templateSids
    foreach ($sid in ($seedSids | Select-Object -Unique)) {
        foreach ($item in Search-ADInvDirectory -SearchBase $context.Default -Filter "(&(objectCategory=group)(objectSid=$sid))" -Property $groupProperties) { & $addGroup $item }
    }
    # Widen: direct parents of every seed, then every nested member group below any of them.
    foreach ($dn in @($groups.Keys)) {
        # A group nested in nothing has no memberOf attribute at all.
        foreach ($parent in @($groups[$dn]['memberof'] | Where-Object { $_ })) {
            foreach ($item in Search-ADInvDirectory -SearchBase ([string]$parent) -Filter '(objectCategory=group)' -Property $groupProperties) { & $addGroup $item }
        }
    }
    $queue = [System.Collections.Generic.Queue[string]]::new([string[]]@($groups.Keys))
    while ($queue.Count) {
        $dn = $queue.Dequeue()
        foreach ($item in Search-ADInvDirectory -SearchBase $context.Default -Filter "(&(objectCategory=group)(memberOf=$(& $escapeLdap $dn)))" -Property $groupProperties) {
            $childDn = [string]$item['distinguishedname'][0]
            if (-not $groups.Contains($childDn)) { & $addGroup $item; $queue.Enqueue($childDn) }
        }
    }

    $memberProperties = @('samaccountname', 'distinguishedname', 'objectsid', 'objectclass', 'useraccountcontrol')
    $enabledOf = { param($Item) if ($Item['useraccountcontrol']) { -not ([int]$Item['useraccountcontrol'][0] -band 2) } else { $null } }
    $groupList = foreach ($dn in $groups.Keys) {
        $item = $groups[$dn]
        $escaped = & $escapeLdap $dn
        $members = @(Search-ADInvDirectory -SearchBase $context.Default -Filter "(memberOf=$escaped)" -Property $memberProperties | ForEach-Object {
                [pscustomobject]@{ Name = [string]$_['samaccountname'][0]; Class = [string]@($_['objectclass'])[-1]; Sid = & $sidOf $_; DistinguishedName = [string]$_['distinguishedname'][0]; Enabled = & $enabledOf $_ }
            })
        # LDAP_MATCHING_RULE_IN_CHAIN: every user reached through any depth of nesting.
        $users = @(Search-ADInvDirectory -SearchBase $context.Default -Filter "(&(objectCategory=person)(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=$escaped))" -Property $memberProperties | ForEach-Object {
                [pscustomobject]@{ Name = [string]$_['samaccountname'][0]; DistinguishedName = [string]$_['distinguishedname'][0]; Enabled = & $enabledOf $_ }
            })
        [pscustomobject]@{
            Name               = [string]$item['samaccountname'][0]
            Sid                = & $sidOf $item
            DistinguishedName  = $dn
            Description        = [string]($item['description'] | Select-Object -First 1)
            MemberOf           = @(foreach ($parent in @($item['memberof'] | Where-Object { $_ })) {
                    $parentItem = if ($groups.Contains([string]$parent)) { $groups[[string]$parent] } else { $null }
                    $parentName = if ($parentItem) { [string]$parentItem['samaccountname'][0] } else { ([string]$parent -replace '^CN=([^,]+),.*$', '$1') }
                    $parentSid = if ($parentItem) { & $sidOf $parentItem } else { $null }
                    [pscustomobject]@{ Name = $parentName; Sid = $parentSid; DistinguishedName = [string]$parent }
                })
            Members            = $members
            RecursiveUsers     = $users
            RecursiveUserCount = $users.Count
        }
    }

    [pscustomobject]@{
        SchemaVersion = 1
        Generated     = (Get-Date).ToString('o')
        Domain        = $context.DnsDomain
        ComputerName  = $env:COMPUTERNAME
        Tool          = "NSP.ActiveDirectory $(Get-NSPADModuleVersion)"
        Groups        = @($groupList)
        Templates     = $templates
        TameMyCerts   = [pscustomobject]@{ PolicyDirectory = $PolicyDirectory; Source = $tmcSource; Policies = @($stamps.Values) }
        Sources       = [pscustomobject]@{ NpsConfigPath = $NpsConfigPath; GroupPattern = $GroupPattern; TemplatePattern = $TemplatePattern }
    }
}