Private/Backend/Invoke-SecretRotationCustomHandler.ps1

function Invoke-SecretRotationCustomHandler {
    <#
    .SYNOPSIS
        Loads and invokes a config-defined custom backend script to set a password.
    .DESCRIPTION
        Resolves customHandler.scriptPath relative to the live config file's own directory (not
        the module root, not the current working directory) - so a custom handler ships alongside
        the config that references it rather than inside the versioned module - dot-sources it,
        verifies it actually defined a function named customHandler.functionName (a silent no-op
        here would be far worse than a loud failure for a security-relevant script), and calls
        that function with a fixed contract: -Identifier, -Password, -BackendConfig.

        The dot-sourced script is confined to this function's own call-local scope: dot-sourcing
        (the leading '.') runs in the *current* scope rather than a child scope, and since this
        statement lives inside Invoke-SecretRotationCustomHandler's own function body, "current
        scope" is this call's local scope - created fresh per call and discarded when it returns.
        Nothing from the handler script lingers in, or pollutes, the module's own scope, so two
        different custom targets can define same-named functions in different scripts without
        colliding.

        A custom handler signals failure only by throwing - a normal return (no exception) means
        success, matching the failure model Set-ADAccountPassword and Update-MgUser both already
        use. No boolean/status return value is inspected.
    .PARAMETER Identifier
        The account identity on the custom backend (target.backendConfig.identity, post
        -BackendParameter merge).
    .PARAMETER Password
        The new password, as SecureString or String per the target's passwordRepresentation -
        passed through to the custom handler function unchanged and untyped (unlike the AD/EntraID
        backends, a custom handler's own required type is declared by the target's config, not
        fixed by this function).
    .PARAMETER BackendConfig
        The target's full merged backendConfig hashtable, passed through unchanged.
    .PARAMETER CustomHandler
        The target's 'customHandler' section: an object with 'scriptPath' and 'functionName'.
    .PARAMETER ConfigDirectory
        Directory the live config file lives in - customHandler.scriptPath is resolved relative
        to this when it isn't already an absolute path.
    .EXAMPLE
        Invoke-SecretRotationCustomHandler -Identifier 'vault-secret-1' -Password $securePassword -BackendConfig @{} -CustomHandler $target.customHandler -ConfigDirectory (Split-Path (Get-SecretRotationConfigPath) -Parent)
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'ShouldProcess confirmation happens once at the public cmdlet boundary (Update-SecretRotationAccountPassword); this private helper is only ever called after that confirmation already succeeded and must not prompt again.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'Password', Justification = 'Deliberately untyped: passed through as-is to the custom handler function, whose own required type (SecureString or String) is declared by the target''s passwordRepresentation config, not fixed by this functions')]
    param(
        [Parameter(Mandatory)]
        [string] $Identifier,

        [Parameter(Mandatory)]
        [AllowNull()]
        $Password,

        [Parameter(Mandatory)]
        [hashtable] $BackendConfig,

        [Parameter(Mandatory)]
        [PSCustomObject] $CustomHandler,

        [Parameter(Mandatory)]
        [string] $ConfigDirectory
    )

    if (-not $CustomHandler.scriptPath) {
        throw "Target backend 'Custom' requires customHandler.scriptPath in its config."
    }
    if (-not $CustomHandler.functionName) {
        throw "Target backend 'Custom' requires customHandler.functionName in its config."
    }

    $scriptPath = $CustomHandler.scriptPath
    if (-not [System.IO.Path]::IsPathRooted($scriptPath)) {
        $scriptPath = Join-Path -Path $ConfigDirectory -ChildPath $scriptPath
    }

    if (-not (Test-Path -Path $scriptPath -PathType Leaf)) {
        throw "Custom backend handler script '$scriptPath' (customHandler.scriptPath) was not found."
    }

    try {
        . $scriptPath
    } catch {
        throw "Failed to load custom backend handler script '$scriptPath': $_"
    }

    $functionName = $CustomHandler.functionName
    if (-not (Get-Command -Name $functionName -CommandType Function -ErrorAction SilentlyContinue)) {
        throw "Custom backend handler script '$scriptPath' did not define a function named '$functionName' (customHandler.functionName)."
    }

    & $functionName -Identifier $Identifier -Password $Password -BackendConfig $BackendConfig
}