- teams: harden Classic Teams removal, protect New Teams, and clean HKU run keys - dotnet: add bootstrapper EXE uninstall support and remove orphaned 3.1.* folders - teams: add MSIX package targeting and version parsing for vulnerable New Teams builds - m365: improve Graph SDK export speed, reuse sessions, and add null-safe handling
458 lines
15 KiB
PowerShell
458 lines
15 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Exports completed DocuSign envelopes and Certificates of Completion via the eSignature REST API.
|
|
|
|
.DESCRIPTION
|
|
Uses the DocuSign eSignature REST API to:
|
|
1. Authenticate with JWT Bearer assertion and handle automatic token renewal.
|
|
2. Enumerate completed envelopes across a specified date range with API pagination.
|
|
3. Download each envelope as an archive ZIP or combined PDF.
|
|
4. Support incremental runs by skipping previously downloaded files.
|
|
5. Output a detailed CSV export log and execution summary.
|
|
|
|
.PARAMETER IntegrationKey
|
|
The DocuSign App / Integration Key (Client ID).
|
|
|
|
.PARAMETER UserId
|
|
The API User ID (GUID) of the user being impersonated.
|
|
|
|
.PARAMETER AccountId
|
|
The DocuSign Account ID (GUID).
|
|
|
|
.PARAMETER PrivateKeyPath
|
|
Path to the RSA private key PEM file associated with the Integration Key.
|
|
|
|
.PARAMETER FromDate
|
|
Start date for querying completed envelopes.
|
|
|
|
.PARAMETER ToDate
|
|
End date for querying completed envelopes. Defaults to the current date/time.
|
|
|
|
.PARAMETER OutputRoot
|
|
Root directory where downloads and logs will be saved.
|
|
|
|
.PARAMETER Environment
|
|
DocuSign environment target: 'Production' or 'Demo'. Defaults to 'Production'.
|
|
|
|
.PARAMETER OutputFormat
|
|
Format to download documents: 'ArchiveZip' (ZIP containing all docs + certificate) or 'CombinedPdf' (Single combined PDF).
|
|
|
|
.PARAMETER SkipExisting
|
|
If specified, skips downloading envelopes whose target file already exists locally. Defaults to $true.
|
|
|
|
.PARAMETER PageSize
|
|
Number of envelopes to request per API page call. Defaults to 1000 (DocuSign max limit).
|
|
|
|
.PARAMETER RetryCount
|
|
Maximum retry attempts for transient API errors or rate-limit throttling. Defaults to 4.
|
|
|
|
.EXAMPLE
|
|
.\Export-DocuSignCompletedAgreements.ps1 `
|
|
-IntegrationKey "00000000-0000-0000-0000-000000000000" `
|
|
-UserId "11111111-1111-1111-1111-111111111111" `
|
|
-AccountId "22222222-2222-2222-2222-222222222222" `
|
|
-PrivateKeyPath "C:\Keys\docusign_private.key" `
|
|
-FromDate "2024-01-01" `
|
|
-OutputRoot "D:\DocuSignExport" `
|
|
-OutputFormat ArchiveZip
|
|
|
|
.NOTES
|
|
Created: 2025-03-05
|
|
Modified: 2026-10-04
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$IntegrationKey,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$UserId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$AccountId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
|
|
[string]$PrivateKeyPath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[datetime]$FromDate,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[datetime]$ToDate = (Get-Date),
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$OutputRoot,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[ValidateSet("Production", "Demo")]
|
|
[string]$Environment = "Production",
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[ValidateSet("ArchiveZip", "CombinedPdf")]
|
|
[string]$OutputFormat = "ArchiveZip",
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[bool]$SkipExisting = $true,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[ValidateRange(1, 1000)]
|
|
[int]$PageSize = 1000,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$RetryCount = 4
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# Global state tracking for OAuth token management
|
|
$script:currentToken = $null
|
|
$script:tokenExpiration = [DateTimeOffset]::MinValue
|
|
$script:apiBaseUri = $null
|
|
|
|
# Helper Functions
|
|
|
|
function ConvertTo-Base64Url {
|
|
param([byte[]]$Bytes)
|
|
[Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
|
|
}
|
|
|
|
function Import-RsaPrivateKey {
|
|
param([string]$Path)
|
|
|
|
$pem = Get-Content -Path $Path -Raw
|
|
$rsa = [System.Security.Cryptography.RSA]::Create()
|
|
|
|
if ($pem -match "-----BEGIN RSA PRIVATE KEY-----") {
|
|
$body = $pem -replace "-----BEGIN RSA PRIVATE KEY-----", "" -replace "-----END RSA PRIVATE KEY-----", "" -replace "\s", ""
|
|
$bytes = [Convert]::FromBase64String($body)
|
|
[void]$rsa.ImportRSAPrivateKey($bytes, [ref]0)
|
|
return $rsa
|
|
}
|
|
|
|
if ($pem -match "-----BEGIN PRIVATE KEY-----") {
|
|
$body = $pem -replace "-----BEGIN PRIVATE KEY-----", "" -replace "-----END PRIVATE KEY-----", "" -replace "\s", ""
|
|
$bytes = [Convert]::FromBase64String($body)
|
|
[void]$rsa.ImportPkcs8PrivateKey($bytes, [ref]0)
|
|
return $rsa
|
|
}
|
|
|
|
throw "Private key must be PEM formatted with 'BEGIN RSA PRIVATE KEY' or 'BEGIN PRIVATE KEY'."
|
|
}
|
|
|
|
function New-DocuSignJwtAssertion {
|
|
param(
|
|
[string]$IntegrationKey,
|
|
[string]$UserId,
|
|
[string]$AuthServer,
|
|
[string]$PrivateKeyPath
|
|
)
|
|
|
|
$now = [DateTimeOffset]::UtcNow
|
|
$header = @{
|
|
alg = "RS256"
|
|
typ = "JWT"
|
|
} | ConvertTo-Json -Compress
|
|
|
|
$payload = @{
|
|
iss = $IntegrationKey
|
|
sub = $UserId
|
|
aud = $AuthServer
|
|
iat = $now.ToUnixTimeSeconds()
|
|
exp = $now.AddMinutes(55).ToUnixTimeSeconds()
|
|
scope = "signature impersonation"
|
|
} | ConvertTo-Json -Compress
|
|
|
|
$encodedHeader = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($header))
|
|
$encodedPayload = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($payload))
|
|
$unsignedToken = "$encodedHeader.$encodedPayload"
|
|
|
|
$rsa = Import-RsaPrivateKey -Path $PrivateKeyPath
|
|
try {
|
|
$signature = $rsa.SignData(
|
|
[Text.Encoding]::UTF8.GetBytes($unsignedToken),
|
|
[Security.Cryptography.HashAlgorithmName]::SHA256,
|
|
[Security.Cryptography.RSASignaturePadding]::Pkcs1
|
|
)
|
|
}
|
|
finally {
|
|
$rsa.Dispose()
|
|
}
|
|
|
|
return "$unsignedToken.$(ConvertTo-Base64Url $signature)"
|
|
}
|
|
|
|
function Get-ValidAccessToken {
|
|
# Refresh token 5 minutes before actual expiry to avoid race conditions
|
|
if ($null -ne $script:currentToken -and [DateTimeOffset]::UtcNow -lt $script:tokenExpiration) {
|
|
return $script:currentToken
|
|
}
|
|
|
|
Write-Verbose "Obtaining fresh DocuSign access token..."
|
|
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
|
|
$tokenUri = "https://$authServer/oauth/token"
|
|
|
|
$assertion = New-DocuSignJwtAssertion `
|
|
-IntegrationKey $IntegrationKey `
|
|
-UserId $UserId `
|
|
-AuthServer $authServer `
|
|
-PrivateKeyPath $PrivateKeyPath
|
|
|
|
$tokenResponse = Invoke-WithRetry -RetryCount $RetryCount -ActionName "JWT Token Request" -ScriptBlock {
|
|
Invoke-RestMethod `
|
|
-Method Post `
|
|
-Uri $tokenUri `
|
|
-ContentType "application/x-www-form-urlencoded" `
|
|
-Body @{
|
|
grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
assertion = $assertion
|
|
}
|
|
}
|
|
|
|
$script:currentToken = $tokenResponse.access_token
|
|
# DocuSign tokens typically last 3600s (1 hr); refresh 5 mins early
|
|
$expiresInSeconds = [int]($tokenResponse.expires_in ?? 3600) - 300
|
|
$script:tokenExpiration = [DateTimeOffset]::UtcNow.AddSeconds($expiresInSeconds)
|
|
|
|
return $script:currentToken
|
|
}
|
|
|
|
function Invoke-WithRetry {
|
|
param(
|
|
[scriptblock]$ScriptBlock,
|
|
[int]$RetryCount = 4,
|
|
[string]$ActionName = "DocuSign API request"
|
|
)
|
|
|
|
for ($attempt = 1; $attempt -le ($RetryCount + 1); $attempt++) {
|
|
try {
|
|
return & $ScriptBlock
|
|
}
|
|
catch {
|
|
$response = $_.Exception.Response
|
|
$statusCode = $null
|
|
|
|
if ($response -and $response.StatusCode) {
|
|
$statusCode = [int]$response.StatusCode
|
|
}
|
|
|
|
$retryable = $statusCode -in @(429, 500, 502, 503, 504)
|
|
if (-not $retryable -or $attempt -gt $RetryCount) {
|
|
throw
|
|
}
|
|
|
|
# Honor DocuSign 'Retry-After' header if provided on HTTP 429
|
|
$delaySeconds = 0
|
|
if ($statusCode -eq 429 -and $response.Headers["Retry-After"]) {
|
|
[int]::TryParse($response.Headers["Retry-After"], [ref]$delaySeconds) | Out-Null
|
|
}
|
|
|
|
if ($delaySeconds -le 0) {
|
|
$delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt))
|
|
}
|
|
|
|
Write-Warning "$ActionName encountered HTTP $statusCode. Retrying in $delaySeconds seconds (Attempt $attempt of $RetryCount)..."
|
|
Start-Sleep -Seconds $delaySeconds
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-SafeFileName {
|
|
param(
|
|
[string]$Text,
|
|
[int]$MaxLength = 100
|
|
)
|
|
|
|
$invalid = [Regex]::Escape(([IO.Path]::GetInvalidFileNameChars() -join ""))
|
|
$safe = [Regex]::Replace($Text, "[$invalid]", "_")
|
|
$safe = [Regex]::Replace($safe, "\s+", " ").Trim()
|
|
|
|
if ([string]::IsNullOrWhiteSpace($safe)) {
|
|
$safe = "Envelope"
|
|
}
|
|
if ($safe.Length -gt $MaxLength) {
|
|
$safe = $safe.Substring(0, $MaxLength).Trim()
|
|
}
|
|
return $safe
|
|
}
|
|
|
|
function Save-EnvelopeDocument {
|
|
param(
|
|
[string]$Uri,
|
|
[string]$Path,
|
|
[string]$AcceptHeader,
|
|
[int]$RetryCount
|
|
)
|
|
|
|
Invoke-WithRetry -RetryCount $RetryCount -ActionName "Downloading document to '$Path'" -ScriptBlock {
|
|
$token = Get-ValidAccessToken
|
|
$headers = @{
|
|
Authorization = "Bearer $token"
|
|
Accept = $AcceptHeader
|
|
}
|
|
Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $Path -ErrorAction Stop
|
|
} | Out-Null
|
|
}
|
|
|
|
# Main Execution Flow
|
|
|
|
# 1. Directory Initialization
|
|
$downloadFolder = Join-Path $OutputRoot "completed-agreements"
|
|
$logFolder = Join-Path $OutputRoot "logs"
|
|
|
|
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
|
|
New-Item -ItemType Directory -Path $logFolder -Force | Out-Null
|
|
|
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
$logPath = Join-Path $logFolder "docusign-export-log-$timestamp.csv"
|
|
|
|
# 2. Base URI Discovery
|
|
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
|
|
$initialToken = Get-ValidAccessToken
|
|
|
|
Write-Host "Discovering account base URI for Account ID: $AccountId..." -ForegroundColor Cyan
|
|
$userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo Discovery" -ScriptBlock {
|
|
Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers @{ Authorization = "Bearer $initialToken" }
|
|
}
|
|
|
|
$account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1
|
|
if (-not $account) {
|
|
throw "AccountId '$AccountId' was not found for the authenticated impersonated user."
|
|
}
|
|
|
|
$baseUri = $account.base_uri.TrimEnd("/")
|
|
$script:apiBaseUri = "$baseUri/restapi/v2.1/accounts/$AccountId"
|
|
|
|
# 3. Request Format Settings
|
|
$documentId = if ($OutputFormat -eq "ArchiveZip") { "archive" } else { "combined" }
|
|
$extension = if ($OutputFormat -eq "ArchiveZip") { ".zip" } else { ".pdf" }
|
|
$accept = if ($OutputFormat -eq "ArchiveZip") { "application/zip" } else { "application/pdf" }
|
|
|
|
$fromIso = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
$toIso = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
|
|
Write-Host "Querying completed envelopes from $fromIso to $toIso..." -ForegroundColor Cyan
|
|
|
|
# 4. Enumerate Completed Envelopes (Paginated)
|
|
$allEnvelopes = [System.Collections.Generic.List[object]]::new()
|
|
$startPosition = 0
|
|
|
|
do {
|
|
$query = @{
|
|
from_date = $fromIso
|
|
to_date = $toIso
|
|
status = "completed"
|
|
count = $PageSize
|
|
start_position = $startPosition
|
|
order_by = "completed"
|
|
order = "asc"
|
|
}
|
|
|
|
$queryString = ($query.GetEnumerator() | ForEach-Object {
|
|
"{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString([string]$_.Value)
|
|
}) -join "&"
|
|
|
|
$uri = "$($script:apiBaseUri)/envelopes?$queryString"
|
|
|
|
$page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Fetching Envelope List Page (Offset: $startPosition)" -ScriptBlock {
|
|
$token = Get-ValidAccessToken
|
|
Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Authorization = "Bearer $token" }
|
|
}
|
|
|
|
if ($page.envelopes) {
|
|
foreach ($env in $page.envelopes) {
|
|
$allEnvelopes.Add($env)
|
|
}
|
|
}
|
|
|
|
$returnedCount = if ($page.envelopes) { @($page.envelopes).Count } else { 0 }
|
|
Write-Host " Retrieved $returnedCount envelopes (Page Start: $startPosition)." -ForegroundColor Gray
|
|
|
|
$startPosition += $PageSize
|
|
$totalSetSize = [int]($page.totalSetSize ?? 0)
|
|
|
|
} while ($allEnvelopes.Count -lt $totalSetSize -and $returnedCount -gt 0)
|
|
|
|
Write-Host "Total completed envelopes identified: $($allEnvelopes.Count)" -ForegroundColor Green
|
|
|
|
# 5. Download Documents & Build Audit Log
|
|
$logRows = [System.Collections.Generic.List[object]]::new()
|
|
$counter = 0
|
|
|
|
foreach ($envelope in $allEnvelopes) {
|
|
$counter++
|
|
$envelopeId = $envelope.envelopeId
|
|
$subject = Get-SafeFileName -Text $envelope.emailSubject -MaxLength 80
|
|
|
|
$completedRaw = if ($envelope.completedDateTime) { $envelope.completedDateTime } else { $envelope.statusChangedDateTime }
|
|
$completedDate = if ($completedRaw) {
|
|
([datetime]$completedRaw).ToString("yyyy-MM-dd")
|
|
} else {
|
|
"unknown-date"
|
|
}
|
|
|
|
$fileName = "$completedDate - $subject - $envelopeId$extension"
|
|
$filePath = Join-Path $downloadFolder $fileName
|
|
$downloadUri = "$($script:apiBaseUri)/envelopes/$envelopeId/documents/$documentId`?certificate=true"
|
|
|
|
Write-Progress `
|
|
-Activity "Downloading DocuSign Agreements" `
|
|
-Status "Item $counter of$($allEnvelopes.Count):$subject" `
|
|
-PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100)
|
|
|
|
$status = "Downloaded"
|
|
$errorMessage = ""
|
|
|
|
# Check for incremental skip
|
|
if ($SkipExisting -and (Test-Path -LiteralPath $filePath)) {
|
|
$status = "Skipped (Exists)"
|
|
Write-Verbose "Skipping existing file: $filePath"
|
|
}
|
|
else {
|
|
try {
|
|
Save-EnvelopeDocument -Uri $downloadUri -Path $filePath -AcceptHeader $accept -RetryCount $RetryCount
|
|
}
|
|
catch {
|
|
$status = "Failed"
|
|
$errorMessage = $_.Exception.Message
|
|
Write-Warning "Failed to download envelope $envelopeId`: $errorMessage"
|
|
}
|
|
}
|
|
|
|
$logRows.Add([PSCustomObject]@{
|
|
EnvelopeId = $envelopeId
|
|
Status = $status
|
|
EnvelopeStatus = $envelope.status
|
|
CompletedDate = $completedRaw
|
|
Subject = $envelope.emailSubject
|
|
OutputFormat = $OutputFormat
|
|
OutputPath = $filePath
|
|
Error = $errorMessage
|
|
})
|
|
}
|
|
|
|
# 6. Save Export Summary Log
|
|
$logRows \vert{} Export-Csv -Path$logPath -NoTypeInformation -Encoding UTF8
|
|
|
|
$downloadedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Downloaded" }).Count
|
|
$skippedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Skipped (Exists)" }).Count
|
|
$failedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Failed" }).Count
|
|
|
|
Write-Host "`n========================================" -ForegroundColor Green
|
|
Write-Host "DocuSign Agreement Export Completed" -ForegroundColor Green
|
|
Write-Host "========================================" -ForegroundColor Green
|
|
Write-Host " Total Identified : $($allEnvelopes.Count)"
|
|
Write-Host " Downloaded : $downloadedCount"
|
|
Write-Host " Skipped Existing : $skippedCount"
|
|
Write-Host " Failed : $failedCount"
|
|
Write-Host " Files Saved To : $downloadFolder"
|
|
Write-Host " Audit Log Saved : $logPath`n"
|
|
|
|
if ($failedCount -gt 0) {
|
|
Write-Warning "One or more envelope downloads failed. Review the CSV audit log for detailed error reasons."
|
|
} |