Private/Resolvers/Resolve-AdPrincipal.ps1

# Stubs so Pester Mock has a target when the ActiveDirectory module isn't loaded
# (e.g. unit tests or hosts without RSAT). The real cmdlets shadow these once
# the ActiveDirectory module is imported.
if (-not (Get-Command Get-ADObject -ErrorAction SilentlyContinue)) {
    function Get-ADObject {
        [CmdletBinding()]
        param(
            [Parameter(ValueFromPipeline)]$Identity,
            [string]$Filter,
            $Server,
            $Properties,
            [pscredential]$Credential
        )
        throw "ActiveDirectory module is not loaded; install RSAT or import ActiveDirectory."
    }
}
if (-not (Get-Command Get-ADGroupMember -ErrorAction SilentlyContinue)) {
    function Get-ADGroupMember {
        [CmdletBinding()]
        param(
            [Parameter(ValueFromPipeline)]$Identity,
            $Server,
            [switch]$Recursive,
            [pscredential]$Credential
        )
        throw "ActiveDirectory module is not loaded; install RSAT or import ActiveDirectory."
    }
}

function Test-IsDefaultGroupSid {
    # True when the SID looks like a well-known domain default group (Domain
    # Admins, Domain Users, etc.) — recursion through these can produce huge
    # member lists that drown out the meaningful permissions.
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Sid)
    if ([string]::IsNullOrWhiteSpace($Sid)) { return $false }
    if ($Sid -notmatch '^S-1-5-21-.*-(?<rid>\d+)$') { return $false }
    $rid = [int]$matches['rid']
    # Domain Admins / Users / Guests / Computers / Domain Controllers /
    # Cert Publishers / Schema Admins / Enterprise Admins / GPC Owners /
    # RODC / Cloneable DCs / Protected Users.
    return $rid -in 512,513,514,515,516,517,518,519,520,521,522,525
}

function Resolve-AdPrincipal {
    # Return ONE record describing the principal — Type, Domain, SAM, DN, error
    # cause if any. Group expansion is delegated to Expand-AdGroupMember; this
    # function never recurses, so callers get a stable shape for the direct
    # ACE/group-member entry.
    #
    # -CredentialMap is an optional FQDN(upper)→PSCredential hashtable. When the
    # principal's home domain is present in the map, the AD calls run as that
    # credential — letting the caller reach untrusted forests, or use a different
    # account than the jumphost's Kerberos context. Domains not in the map use
    # current Kerberos as before.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Sid,
        [Parameter(Mandatory)][string]$Account,
        [Parameter(Mandatory)][hashtable]$DomainCache,
        [hashtable]$CredentialMap = @{}
    )

    # Built-in / well-known SIDs short-circuit (no AD lookup possible / needed).
    if ($Sid -match '^S-1-5-(18|19|20|32-)') {
        return [pscustomobject]@{
            Sid = $Sid; Account = $Account; Type = 'WellKnown'
            Domain = $null; SamAccountName = $null
            DistinguishedName = $null; ResolveError = $null
        }
    }

    $netbios = ($Account -split '\\', 2)[0]
    $fqdn    = Resolve-DomainFqdn -NetbiosName $netbios -Cache $DomainCache

    # No domain mapping for this netbios → it is the local SAM (computer name
    # or BUILTIN-style prefix that did not match the well-known regex above).
    # Local SAM SIDs always have the S-1-5-21-<machine>-<rid> shape.
    if (-not $fqdn) {
        $sam = if ($Account -like '*\*') { ($Account -split '\\', 2)[1] } else { $Account }
        $type = if ($Sid -match '^S-1-5-21-') { 'LocalAccount' } else { 'Unresolved' }
        return [pscustomobject]@{
            Sid = $Sid; Account = $Account; Type = $type
            Domain = $null; SamAccountName = $sam
            DistinguishedName = $null
            ResolveError = if ($type -eq 'Unresolved') { "No domain mapping for netbios '$netbios'" } else { $null }
        }
    }

    # Get-ADObject -Identity does not reliably accept a raw SID string —
    # the binder tries SAM/GUID/DN first and reports "Cannot find an object
    # with identity: 'S-1-5-…'". Query by objectSID filter (canonical), and
    # if that path errors (different AD module versions complain about
    # different filter syntax), fall back to passing a SecurityIdentifier
    # object to -Identity. -Properties does NOT expose 'SID' on Get-ADObject;
    # only ObjectSID. The SID we already have, so we don't ask for it back.
    $props = 'ObjectClass','SamAccountName','DistinguishedName'
    $obj   = $null
    $errFilter = $null

    $credKey = $fqdn.ToUpperInvariant()
    $adArgs  = @{ Server = $fqdn; Properties = $props; ErrorAction = 'Stop' }
    if ($CredentialMap -and $CredentialMap.ContainsKey($credKey)) {
        $adArgs['Credential'] = $CredentialMap[$credKey]
    }

    try {
        $obj = Get-ADObject -Filter "objectSID -eq '$Sid'" @adArgs |
            Select-Object -First 1
    } catch { $errFilter = $_.Exception.Message }

    if (-not $obj) {
        try {
            $sidObj = New-Object System.Security.Principal.SecurityIdentifier($Sid)
            $obj = Get-ADObject -Identity $sidObj @adArgs
        } catch { }
    }

    if (-not $obj) {
        $msg = if ($errFilter) { $errFilter } else { "No AD object with SID $Sid in $fqdn" }
        return [pscustomobject]@{
            Sid = $Sid; Account = $Account; Type = 'Unresolved'
            Domain = $fqdn; SamAccountName = $null
            DistinguishedName = $null
            ResolveError = $msg
        }
    }

    $type = switch ($obj.ObjectClass) {
        'user'                            { if ($Account.EndsWith('$')) { 'gMSA' } else { 'User' } }
        'msDS-GroupManagedServiceAccount' { 'gMSA' }
        'computer'                        { 'Computer' }
        'group'                           { 'Group' }
        default                           { 'User' }
    }

    [pscustomobject]@{
        Sid = $Sid; Account = $Account; Type = $type
        Domain = $fqdn; SamAccountName = $obj.SamAccountName
        DistinguishedName = $obj.DistinguishedName
        ResolveError = $null
    }
}