Public/Get-RotationCandidate.ps1

function Get-RotationCandidate {
    <#
    .SYNOPSIS
        Works out which credentials on the given machines need rotating.

    .DESCRIPTION
        The caller states the machines. This function never searches for them, never
        reads a tag and has no opinion about which machines belong in scope - that is
        the orchestrator's job, and keeping it there is what lets the same module run
        from a workstation against one machine and from a runbook against a fleet.

        What it does decide is whether a machine the caller already chose actually has
        something to rotate. Rotation is triggered by one of five conditions:

          ResumePending - a previous run was interrupted after staging a value
          Missing - no secret yet, or a secret with no expiry date
          Expiry - the expiry date is within the threshold
          Access - not detected here; access pulls the expiry date forward,
                          and this function then sees it as Expiry
          Manual - nothing else applied, so the credential is replaced because
                          the caller asked for this machine

    .PARAMETER VM
        The machines to examine. Objects from Get-AzVM, fetched by the caller.

    .PARAMETER OnlyIfDue
        Consult the expiry date instead of rotating regardless. Without it every machine
        handed in is a candidate, which is what asking for a machine means. A scheduled
        pass sets it, so it touches only what is missing, half-rotated or near expiry.

    .PARAMETER ThresholdDays
        How close to expiry counts as due. Only consulted with -OnlyIfDue.

    .PARAMETER SecretNameTemplate
        How secret names are built from {vm}, {user}, {rg} and {kind}. Must match what was
        used when the secrets were written, or nothing will be found.

        The last point is the design in one sentence: the expiry date is the only
        signal. Everything else writes to it.

    .OUTPUTS
        PSCustomObject with VM, CredentialType, Reason, SecretName, ExpiresOn.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$VaultName,

        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][object[]]$VM,

        [switch]$OnlyIfDue,

        # Only consulted with -OnlyIfDue.
        [ValidateRange(0, 3650)][int]$ThresholdDays = 14,

        # Linux VMs get an SSH key rotated unless this is set.
        [switch]$SkipSshKeys,

        [ValidateNotNullOrEmpty()][string]$SecretNameTemplate = '{vm}-{user}-{kind}'
    )

    $candidates = [System.Collections.Generic.List[object]]::new()
    $now = (Get-Date).ToUniversalTime()

    $vms = @($VM)
    Write-RotationLog -Message "$($vms.Count) machine(s) handed in by the caller" -Level Info -Scope 'discovery'

    foreach ($vm in $vms) {
        $adminUsername = $vm.OSProfile.AdminUsername
        if ([string]::IsNullOrWhiteSpace($adminUsername)) {
            Write-RotationLog -Message 'No admin username in the OS profile (specialised image?), skipping' -Level Warning -Scope $vm.Name
            continue
        }

        $osType = [string]$vm.StorageProfile.OsDisk.OsType
        $types = [System.Collections.Generic.List[string]]::new()

        if ($osType -eq 'Windows') {
            $types.Add('Password')
        }
        elseif ($osType -eq 'Linux') {
            $passwordAuthEnabled = -not $vm.OSProfile.LinuxConfiguration.DisablePasswordAuthentication
            if ($passwordAuthEnabled) { $types.Add('Password') }
            if (-not $SkipSshKeys) { $types.Add('SSHKey') }
        }
        else {
            Write-RotationLog -Message "Unknown OS type '$osType', skipping" -Level Warning -Scope $vm.Name
            continue
        }

        foreach ($type in $types) {
            $kind = if ($type -eq 'Password') { 'pw' } else { 'ssh-priv' }
            $secretName = Resolve-SecretName -VMName $vm.Name -AdminUsername $adminUsername -Kind $kind -ResourceGroupName $vm.ResourceGroupName -Template $SecretNameTemplate
            $pendingName = Resolve-SecretName -VMName $vm.Name -AdminUsername $adminUsername -Kind $kind -ResourceGroupName $vm.ResourceGroupName -Pending -Template $SecretNameTemplate

            $reason = $null
            $expiresOn = $null

            $pending = Get-RotationSecret -VaultName $VaultName -Name $pendingName
            $isPendingOpen = $pending.Exists -and
                             $pending.Secret.Tags.State -eq 'pending' -and
                             $pending.Secret.Enabled -ne $false

            if ($isPendingOpen) {
                $reason = 'ResumePending'
            }
            else {
                $secret = Get-RotationSecret -VaultName $VaultName -Name $secretName

                if (-not $secret.Exists) {
                    $reason = 'Missing'
                }
                elseif ($null -eq $secret.Secret.Expires) {
                    $reason = 'Missing'
                    Write-RotationLog -Message "Secret '$secretName' has no expiry date" -Level Warning -Scope $vm.Name
                }
                elseif (-not $OnlyIfDue) {
                    # The caller handed this machine over. Doing nothing because the date is
                    # comfortable would be the wrong answer to a direct request.
                    $expiresOn = $secret.Secret.Expires.ToUniversalTime()
                    $reason = 'Manual'
                }
                else {
                    $expiresOn = $secret.Secret.Expires.ToUniversalTime()
                    if (($expiresOn - $now).TotalDays -le $ThresholdDays) {
                        # Access-driven rotation works by pulling the expiry date
                        # forward, so by the time it gets here it is indistinguishable
                        # from ordinary ageing. Register-CredentialAccess leaves a tag
                        # behind precisely so the audit record can still say which of
                        # the two it was - without it, the workbook cannot answer
                        # "was this replaced because someone read it, or because it
                        # got old", which is most of the point of keeping records.
                        $reason = if ($secret.Secret.Tags -and $secret.Secret.Tags['RotationReason'] -eq 'Access') {
                            'Access'
                        }
                        else { 'Expiry' }
                    }
                }
            }

            if (-not $reason) { continue }

            $candidates.Add([pscustomobject]@{
                VM             = $vm
                CredentialType = $type
                Reason         = $reason
                SecretName     = $secretName
                ExpiresOn      = $expiresOn
            })
        }
    }

    return $candidates.ToArray()
}