Private/Checks/Nsxt/Test-VcfNsxtPasswordExpiration.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================
function Get-VcfCheckNsxCredentialExpiration {

    <#
        .SYNOPSIS
        Resolves password-expiration details for a set of NSX credential Ids of one resource type.

        .DESCRIPTION
        Wraps Initialize-VcfCredentialsExpirationSpec/Invoke-VcfGetPasswordExpiration, polling
        Invoke-VcfGetPasswordExpirationByTaskID until the task completes.
        CredentialsExpirationSpec requires a single -ResourceType per request, so NSX Manager and
        NSX Edge credentials (different resource types) must be resolved in separate calls - see
        Test-VcfNsxtPasswordExpiration, which calls this once per resource type.

        .PARAMETER ResourceType
        NSXT_MANAGER or NSXT_EDGE.

        .PARAMETER Ids
        Credential Ids to resolve. Returns an empty array immediately if none are supplied.

        .PARAMETER MaxPollAttempts
        Maximum number of status polls before giving up.

        .PARAMETER PollDelaySeconds
        Delay between polls.

        .OUTPUTS
        [Object[]] CredentialExpirationCheck elements (Id, Username, Resource, Expiry).
    #>


    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ResourceType,
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [String[]]$Ids,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 60)] [Int]$MaxPollAttempts = 10,
        [Parameter(Mandatory = $false)] [ValidateRange(0, 300)] [Int]$PollDelaySeconds = 5
    )

    if (@($Ids).Count -eq 0) {
        return @()
    }

    $spec = Initialize-VcfCredentialsExpirationSpec -ResourceType $ResourceType -CredentialIds @($Ids)
    $task = Invoke-VcfGetPasswordExpiration -CredentialsExpirationSpec $spec -ErrorAction Stop

    $status = [String]$task.Status
    $attempt = 0
    while ($status -match 'IN_?PROGRESS|PENDING' -and $attempt -lt $MaxPollAttempts) {
        Start-Sleep -Seconds $PollDelaySeconds
        $task = Invoke-VcfGetPasswordExpirationByTaskID -Id $task.Id -ErrorAction Stop
        $status = [String]$task.Status
        $attempt++
    }

    if ($status -match 'IN_?PROGRESS|PENDING') {
        throw [System.Exception]::new("Credential-expiration task `"$($task.Id)`" for `"$ResourceType`" did not complete after $MaxPollAttempts poll(s).")
    }

    return @($task.Elements)
}
function Resolve-VcfCheckNsxMissingExpiration {

    <#
        .SYNOPSIS
        Retries password-expiration lookup for credentials a batched task returned without a
        usable Expiry element.

        .DESCRIPTION
        SDDC Manager's credential-expiration task occasionally completes without an Expiry
        element (or with a blank ExpiryDate) for one or more credentials in a large batch,
        even though the same credential resolves cleanly when queried on its own. This
        re-submits just the missing credential Ids as a second, smaller task and merges any
        results back in before Test-VcfNsxtPasswordExpiration falls back to reporting Error.

        .PARAMETER Credentials
        The full set of Credential elements for one resource type.

        .PARAMETER Expirations
        The Expiry-bearing elements returned by the initial batched lookup.

        .PARAMETER ResourceType
        NSXT_MANAGER or NSXT_EDGE.

        .PARAMETER MaxPollAttempts
        Maximum number of status polls before giving up on the retry task.

        .PARAMETER PollDelaySeconds
        Delay between polls for the retry task.

        .OUTPUTS
        [Object[]] The original Expirations plus any resolved on retry.
    #>


    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [PSObject[]]$Credentials,
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Expirations,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ResourceType,
        [Parameter(Mandatory = $true)] [ValidateRange(1, 60)] [Int]$MaxPollAttempts,
        [Parameter(Mandatory = $true)] [ValidateRange(0, 300)] [Int]$PollDelaySeconds
    )

    $missingIds = @($Credentials | Where-Object {
        $credentialId = $_.Id
        $expiry = ($Expirations | Where-Object { $_.Id -eq $credentialId } | Select-Object -First 1)
        -not $expiry -or -not $expiry.Expiry -or [String]::IsNullOrEmpty($expiry.Expiry.ExpiryDate)
    } | Select-Object -ExpandProperty Id)

    if ($missingIds.Count -eq 0) {
        return $Expirations
    }

    Write-LogMessage -Type WARNING -Message "Password-expiration task for `"$ResourceType`" omitted $($missingIds.Count) credential(s); retrying individually."

    try {
        $retryExpirations = @(Get-VcfCheckNsxCredentialExpiration -ResourceType $ResourceType -Ids $missingIds -MaxPollAttempts $MaxPollAttempts -PollDelaySeconds $PollDelaySeconds)
    } catch {
        Write-LogMessage -Type ERROR -Message "Retry of password-expiration lookup for `"$ResourceType`" failed: $($_.Exception.Message)"
        return $Expirations
    }

    return @($Expirations) + @($retryExpirations)
}
function ConvertTo-VcfCheckNsxCredentialRow {

    <#
        .SYNOPSIS
        Evaluates a single NSX Manager/Edge account's password expiry and auto-rotation policy.

        .DESCRIPTION
        Helper for Test-VcfNsxtPasswordExpiration. Combines a Credential element (Username,
        Resource, AutoRotatePolicy) from Invoke-VcfGetCredentials with its matching Expiry
        (ExpirationDetails) from Get-VcfCheckNsxCredentialExpiration. Fail if already expired;
        Warning if expiring within -WarningThresholdDays (regardless of rotation policy); Error
        if expiry could not be determined. Automatic rotation being disabled does not by itself
        raise a Warning, since a password more than -WarningThresholdDays from expiry is not at
        risk of blocking the upgrade. When rotation is disabled, the Detail text adds a
        heads-up that manual action will be required to rotate the password.

        .PARAMETER Account
        A Credential element (Username, Resource.ResourceName, AutoRotatePolicy).

        .PARAMETER Expiry
        The matching ExpirationDetails object, or $null if none was found.

        .PARAMETER WarningThresholdDays
        Number of days before expiry to raise a Warning instead of a Pass.

        .OUTPUTS
        [PSCustomObject] with Hostname, Username, 'Expiry Date', 'Days Until', Status,
        'Rotation Schedule', 'Next Rotation', Detail.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Account,
        [Parameter(Mandatory = $true)] [AllowNull()] [PSObject]$Expiry,
        [Parameter(Mandatory = $true)] [Int32]$WarningThresholdDays
    )

    $hostname = $Account.Resource.ResourceName
    $username = $Account.Username
    $rotationEnabled = [bool]$Account.AutoRotatePolicy
    $rotationSchedule = if ($rotationEnabled) { "Every $($Account.AutoRotatePolicy.FrequencyInDays) day(s)" } else { 'Disabled' }
    $nextRotation = if ($rotationEnabled) { $Account.AutoRotatePolicy.NextSchedule } else { 'N/A' }

    if (-not $Expiry -or [String]::IsNullOrEmpty($Expiry.ExpiryDate)) {
        return [PSCustomObject]@{
            Hostname            = $hostname
            Username            = $username
            'Expiry Date'       = 'Unknown'
            'Days Until'        = 'Unknown'
            Status              = 'Error'
            'Rotation Schedule' = $rotationSchedule
            'Next Rotation'     = $nextRotation
            Detail              = "Could not determine password expiry for `"$username`" on `"$hostname`"."
        }
    }

    $expiryDate = [DateTime]::Parse($Expiry.ExpiryDate, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
    $daysUntilExpiry = [Math]::Floor(($expiryDate - [DateTime]::UtcNow).TotalDays)
    $rotationDetail = 'automatic rotation is disabled in SDDC Manager; manual action will be required to rotate this password'
    if ($rotationEnabled) {
        $rotationDetail = "automatic rotation is enabled every $($Account.AutoRotatePolicy.FrequencyInDays) day(s) via SDDC Manager"
    }

    if ($Expiry.Status -eq 'EXPIRED' -or $daysUntilExpiry -lt 0) {
        return [PSCustomObject]@{
            Hostname = $hostname; Username = $username; 'Expiry Date' = $Expiry.ExpiryDate; 'Days Until' = $daysUntilExpiry
            Status = 'Fail'; 'Rotation Schedule' = $rotationSchedule; 'Next Rotation' = $nextRotation
            Detail = "The `"$username`" account password on `"$hostname`" has already expired; $rotationDetail."
        }
    }
    if ($Expiry.Status -eq 'EXPIRING' -or $daysUntilExpiry -lt $WarningThresholdDays) {
        return [PSCustomObject]@{
            Hostname = $hostname; Username = $username; 'Expiry Date' = $Expiry.ExpiryDate; 'Days Until' = $daysUntilExpiry
            Status = 'Warning'; 'Rotation Schedule' = $rotationSchedule; 'Next Rotation' = $nextRotation
            Detail = "The `"$username`" account password on `"$hostname`" expires in $daysUntilExpiry day(s); $rotationDetail."
        }
    }
    return [PSCustomObject]@{
        Hostname = $hostname; Username = $username; 'Expiry Date' = $Expiry.ExpiryDate; 'Days Until' = $daysUntilExpiry
        Status = 'Pass'; 'Rotation Schedule' = $rotationSchedule; 'Next Rotation' = $nextRotation
        Detail = "The `"$username`" account password on `"$hostname`" expires in $daysUntilExpiry day(s); $rotationDetail."
    }
}
function Test-VcfNsxtPasswordExpiration {

    <#
        .SYNOPSIS
        Checks local admin/audit/root account password expiration and auto-rotation policy on
        every NSX Manager and NSX Edge node registered with SDDC Manager.

        .DESCRIPTION
        Queries the SDDC Manager credentials API (Invoke-VcfGetCredentials -ResourceType
        NSXT_MANAGER/NSXT_EDGE -AccountType SYSTEM) to retrieve local system accounts across all
        domains. Password expiry details are obtained via Initialize-VcfCredentialsExpirationSpec and
        Invoke-VcfGetPasswordExpiration (wrapped in Get-VcfCheckNsxCredentialExpiration).
        Each account is evaluated by ConvertTo-VcfCheckNsxCredentialRow and reported as a row
        (Hostname, Username, 'Expiry Date', 'Days Until', Status, 'Rotation Schedule', 'Next Rotation')
        in the result's breakdown table.

        Overall status is the worst of every row's status (Fail > Error/Warning > Pass).

        .PARAMETER Context
        The VcfCheck.Context object. Must already be connected to SDDC Manager.

        .PARAMETER WarningThresholdDays
        Number of days before expiry to raise a Warning instead of a Pass. Default 30.

        .PARAMETER MaxPollAttempts
        Maximum number of status polls before giving up. Default 10.

        .PARAMETER PollDelaySeconds
        Delay between polls. Default 5.

        .OUTPUTS
        [PSObject] a VcfCheck.Result.
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $false)] [String]$DisplayName = '',
        [Parameter(Mandatory = $false)] [ValidateRange(1, 3650)] [Int]$WarningThresholdDays = 30,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 60)] [Int]$MaxPollAttempts = 10,
        [Parameter(Mandatory = $false)] [ValidateRange(0, 300)] [Int]$PollDelaySeconds = 5
    )

    $startedAt = Get-Date
    $checkId = 'nsxt_password_expiration'
    $catalogEntry = (Get-VcfCheckCatalog)[$checkId]
    $displayName = if ([String]::IsNullOrEmpty($DisplayName)) { $catalogEntry.displayName } else { $DisplayName }
    $validationCriteria = $catalogEntry.validationCriteria
    $remediation = $catalogEntry.remediation

    try {
        $managerCredentials = @((Invoke-VcfGetCredentials -ResourceType NSXT_MANAGER -AccountType SYSTEM -ErrorAction Stop).Elements)
        $edgeCredentials = @((Invoke-VcfGetCredentials -ResourceType NSXT_EDGE -AccountType SYSTEM -ErrorAction Stop).Elements)
    } catch {
        return New-VcfCheckResult -CheckId $checkId -Area NSX -Status Error `
            -Exception $_.Exception.Message -ValidationCriteria $validationCriteria -Remediation $remediation -StartedAt $startedAt -CompletedAt (Get-Date) -DisplayName $displayName
    }

    try {
        $managerExpirations = @(Get-VcfCheckNsxCredentialExpiration -ResourceType NSXT_MANAGER -Ids @($managerCredentials.Id) -MaxPollAttempts $MaxPollAttempts -PollDelaySeconds $PollDelaySeconds)
        $edgeExpirations = @(Get-VcfCheckNsxCredentialExpiration -ResourceType NSXT_EDGE -Ids @($edgeCredentials.Id) -MaxPollAttempts $MaxPollAttempts -PollDelaySeconds $PollDelaySeconds)
    } catch {
        return New-VcfCheckResult -CheckId $checkId -Area NSX -Status Error `
            -Exception $_.Exception.Message -ValidationCriteria $validationCriteria -Remediation $remediation -StartedAt $startedAt -CompletedAt (Get-Date) -DisplayName $displayName
    }

    $managerExpirations = Resolve-VcfCheckNsxMissingExpiration -Credentials $managerCredentials -Expirations $managerExpirations -ResourceType NSXT_MANAGER -MaxPollAttempts $MaxPollAttempts -PollDelaySeconds $PollDelaySeconds
    $edgeExpirations = Resolve-VcfCheckNsxMissingExpiration -Credentials $edgeCredentials -Expirations $edgeExpirations -ResourceType NSXT_EDGE -MaxPollAttempts $MaxPollAttempts -PollDelaySeconds $PollDelaySeconds

    $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($credential in $managerCredentials) {
        $expiry = ($managerExpirations | Where-Object { $_.Id -eq $credential.Id } | Select-Object -First 1).Expiry
        $rows.Add((ConvertTo-VcfCheckNsxCredentialRow -Account $credential -Expiry $expiry -WarningThresholdDays $WarningThresholdDays))
    }
    foreach ($credential in $edgeCredentials) {
        $expiry = ($edgeExpirations | Where-Object { $_.Id -eq $credential.Id } | Select-Object -First 1).Expiry
        $rows.Add((ConvertTo-VcfCheckNsxCredentialRow -Account $credential -Expiry $expiry -WarningThresholdDays $WarningThresholdDays))
    }

    $reportRows = @($rows | ForEach-Object { [PSCustomObject]@{
        Hostname            = $_.Hostname
        Username            = $_.Username
        'Expiry Date'       = $_.'Expiry Date'
        'Days Until'        = $_.'Days Until'
        Status              = $_.Status
        'Rotation Schedule' = $_.'Rotation Schedule'
        'Next Rotation'     = $_.'Next Rotation'
        Detail              = $_.Detail
    } })

    $failures = @($rows | Where-Object { $_.Status -eq 'Fail' })
    $warningsAndErrors = @($rows | Where-Object { $_.Status -eq 'Warning' -or $_.Status -eq 'Error' })

    $managerNodeCount = @($managerCredentials | Select-Object -ExpandProperty Resource | Select-Object -ExpandProperty ResourceName -Unique).Count
    $edgeNodeCount = @($edgeCredentials | Select-Object -ExpandProperty Resource | Select-Object -ExpandProperty ResourceName -Unique).Count

    if ($failures.Count -gt 0) {
        $status = 'Fail'
        $detail = "$($failures.Count) account password(s) have already expired. See the results table below for details."
    } elseif ($warningsAndErrors.Count -gt 0) {
        $status = 'Warning'
        $detail = "$($warningsAndErrors.Count) account password(s) expire within $WarningThresholdDays day(s) or could not be checked. See the results table below for details."
    } else {
        $status = 'Pass'
        $detail = "Checked $($rows.Count) account(s) across $managerNodeCount NSX Manager(s) and $edgeNodeCount NSX Edge node(s); none expire within $WarningThresholdDays day(s)."
    }

    return New-VcfCheckResult -CheckId $checkId -Area NSX -Status $status `
        -Detail $detail -Rows $reportRows -ValidationCriteria $validationCriteria -Remediation $remediation `
        -StartedAt $startedAt -CompletedAt (Get-Date) -DisplayName $displayName
}