NsfFolderCommands.ps1
|
#requires -Version 5.1 . "$PSScriptRoot/NsfBatchSampleData.ps1" function New-KeeperNSFFolder { <# .Synopsis Creates a new Keeper NSF folder. .Description Creates a new folder in Keeper NSF using the v3 API. .Parameter Name Name of the folder to create. .Parameter ParentFolderUid UID of the parent folder. If omitted, the folder is created at root level. .Parameter Color Optional color for the folder. .Parameter NoInheritPermissions If specified, the folder will not inherit permissions from its parent. #> [CmdletBinding()] Param ( [Parameter(Position = 0, Mandatory = $true)] [string] $Name, [Parameter()] [string] $ParentFolderUid, [Parameter()] [string] $Color, [Parameter()] [switch] $NoInheritPermissions ) try{ [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Host "Error getting vault: $($_.Exception.Message)" -ForegroundColor Red return } $inheritPermissions = -not $NoInheritPermissions.IsPresent try { $folderUid = $vault.CreateKeeperNSFFolder($Name, $ParentFolderUid, $Color, $inheritPermissions).GetAwaiter().GetResult() Write-Host "Folder '$Name' created successfully (UID: $folderUid)." -ForegroundColor Green return $folderUid } catch { Write-Host "Error creating folder: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-mkdir -Value New-KeeperNSFFolder function New-KeeperNSFFolders { <# .Synopsis Creates multiple Keeper NSF folders in a single batch API call (up to 100 per request). .Description Uses batching. Creating under an existing parent requires add/create permission on that parent. Folder keys are AES-GCM encrypted; the folder name is stored in encrypted FolderData.Data. Larger sets are chunked automatically (100 folders per request). JSON schema: a "folders" array. Each item: name (required), optional parent / parent_uid, optional color, optional inherit_permissions (default true). .Parameter FilePath Path to a UTF-8 JSON folder batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleFolders Writes a sample batch create JSON file and exits without creating folders. .EXAMPLE PS> New-KeeperNSFFolders -DownloadSampleFolders .EXAMPLE PS> New-KeeperNSFFolders -FilePath .\nsf-folders-batch.sample.json #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleFolders ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleFolders) { if (-not $FilePath) { $FilePath = 'nsf-folders-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder batch file written to: $fullPath" -ForegroundColor Green Write-Host "Edit folder names / parent UIDs, then run:" Write-Host " New-KeeperNSFFolders -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleFolders is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $folderRequests = ConvertTo-KeeperNSFFolderCreateRequests -JsonText $jsonText if (-not $folderRequests -or $folderRequests.Count -eq 0) { throw "Folder file contains no folders." } if ($folderRequests -isnot [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderCreateRequest]]) { $typed = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderCreateRequest]' foreach ($item in @($folderRequests)) { if ($item -is [KeeperSecurity.Vault.KeeperNSFFolderCreateRequest]) { $typed.Add($item) | Out-Null } } $folderRequests = $typed } } catch { Write-Host "Error parsing folder payload: $($_.Exception.Message)" -ForegroundColor Red return } if (-not $PSCmdlet.ShouldProcess("$($folderRequests.Count) Keeper NSF folder(s)", "Create Keeper NSF folders")) { return } try { Write-Host "Creating $($folderRequests.Count) Keeper NSF folder(s) in batch..." $folderList = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderCreateRequest]]$folderRequests $results = $vault.CreateKeeperNSFFolders($folderList).GetAwaiter().GetResult() $ok = @($results | Where-Object { $_.Success }) $fail = @($results | Where-Object { -not $_.Success }) Write-Host "Batch complete: $($ok.Count) succeeded, $($fail.Count) failed." Write-Host "" foreach ($result in $results) { if ($result.Success) { Write-Host " [OK] $($result.Name) UID: $($result.FolderUid)" -ForegroundColor Green } else { $msg = if ($result.Message) { $result.Message } else { '(no message)' } Write-Host " [FAIL] $($result.Name) status=$($result.Status) $msg" -ForegroundColor Red } } return ,@($results) } catch { Write-Host "Error creating folders in batch: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-mkdirs -Value New-KeeperNSFFolders # Parse batch folder-create JSON into KeeperNSFFolderCreateRequest objects. function Script:ConvertTo-KeeperNSFFolderCreateRequests { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $parsed = $JsonText | ConvertFrom-Json -ErrorAction Stop $items = @() if ($null -ne $parsed.folders) { $items = @($parsed.folders) } elseif ($parsed -is [System.Array]) { $items = @($parsed) } else { throw "JSON must contain a 'folders' array (or be a root array of folder objects)." } $list = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderCreateRequest]' $index = 0 foreach ($item in $items) { $name = $null if ($item.name) { $name = [string]$item.name } elseif ($item.Name) { $name = [string]$item.Name } if ([string]::IsNullOrWhiteSpace($name)) { throw "Folder item at index $index is missing required 'name'." } $parent = $null if ($item.parent) { $parent = [string]$item.parent } elseif ($item.parent_uid) { $parent = [string]$item.parent_uid } elseif ($item.ParentFolderUid) { $parent = [string]$item.ParentFolderUid } $color = $null if ($item.color) { $color = [string]$item.color } elseif ($item.Color) { $color = [string]$item.Color } $inherit = $true if ($null -ne $item.inherit_permissions) { $inherit = [bool]$item.inherit_permissions } elseif ($null -ne $item.InheritPermissions) { $inherit = [bool]$item.InheritPermissions } $req = New-Object KeeperSecurity.Vault.KeeperNSFFolderCreateRequest $req.Name = $name.Trim() if (-not [string]::IsNullOrWhiteSpace($parent)) { $req.ParentFolderUid = $parent.Trim() } $req.Color = $color $req.InheritPermissions = $inherit $list.Add($req) | Out-Null $index++ } return ,$list } function Set-KeeperNSFFolderAccess { <# .Synopsis Grant or revoke user or team access to a Keeper NSF folder. .Description Changes the sharing permissions of a Keeper NSF folder using the v3 API. Supports granting access with a specified role, or revoking access entirely. Recipients may be user emails, team names, or team UIDs. For bulk grant/update/revoke from JSON, use Share-KeeperNSFFolderAccesses, Update-KeeperNSFFolderAccesses, or Unshare-KeeperNSFFolderAccesses. .Parameter FolderUid UID of the folder to share. .Parameter Action Action to perform: 'grant' (default) or 'remove'. .Parameter Email One or more user email addresses, team names, or team UIDs to grant/revoke access. .Parameter Role Access role for grant action: viewer (default), share-manager, content-manager, content-share-manager, full-manager. .Parameter ExpireIn Optional. Share expiration period from now (e.g. 30d, 6mo, 1y, 24h, 30mi), integer minutes, or a TimeSpan. Same as Grant-KeeperRecordAccess. .Parameter ExpireAt Optional. Absolute share expiration as ISO datetime (e.g. 2027-01-01T00:00:00Z). #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] Param ( [Parameter(Position = 0, Mandatory = $true)] [string] $FolderUid, [Parameter()] [ValidateSet('grant', 'remove')] [string] $Action = 'grant', [Parameter(Mandatory = $true)] [string[]] $Email, [Parameter()] [ValidateSet('viewer', 'share-manager', 'content-manager', 'content-share-manager', 'full-manager')] [string] $Role = 'viewer', [Alias('expire-in')] [Parameter()] [System.Object] $ExpireIn, [Alias('expire-at')] [Parameter()] [string] $ExpireAt ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Host "Error getting vault: $($_.Exception.Message)" -ForegroundColor Red return } [KeeperSecurity.Vault.FolderNode]$tmpFolder = $null if (-not $vault.TryGetKeeperNSFFolder($FolderUid, [ref]$tmpFolder)) { Write-Host "Error: NSF folder '$FolderUid' not found." -ForegroundColor Red return } $shareOptions = $null if ($Action -eq 'grant' -and ($ExpireIn -or $ExpireAt)) { try { $expirationDto = Get-ExpirationDate -ExpireIn $ExpireIn -ExpireAt $ExpireAt } catch { Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red return } $shareOptions = New-Object KeeperSecurity.Vault.SharedFolderUserOptions $shareOptions.Expiration = $expirationDto } foreach ($user in $Email) { try { if ($Action -eq 'grant') { [void]$vault.GrantKeeperNSFFolderAccess($FolderUid, $user, $Role, $shareOptions).GetAwaiter().GetResult() $expireMsg = if ($shareOptions -and $shareOptions.Expiration) { " (expires $($shareOptions.Expiration.LocalDateTime.ToString('g')))" } else { '' } Write-Host "Granted '$Role' access to '$user' on folder '$FolderUid'$expireMsg." -ForegroundColor Green } else { [void]$vault.RevokeKeeperNSFFolderAccess($FolderUid, $user).GetAwaiter().GetResult() Write-Host "Revoked access for '$user' from folder '$FolderUid'." -ForegroundColor Green } } catch { Write-Host "Error ${Action}ing access for '$user': $($_.Exception.Message)" -ForegroundColor Red } } } New-Alias -Name nsf-share-folder -Value Set-KeeperNSFFolderAccess function Share-KeeperNSFFolderAccesses { <# .Synopsis Batch-grant Keeper NSF folder access (user or team) — up to 500 entries per API request. .Description Uses vault/folders/v3/access_update (FolderAccessAdds). Independent of Set-KeeperNSFFolderAccess. Caller must have share/update-access permission on each folder. JSON schema: an "accesses" array. Each item: folder_uid, accessor (email/team name/UID), optional role (default viewer), optional expire_in / expire_at. Omit as_team for team names/UIDs so resolution matches nsf-share-folder auto-detect. .Parameter FilePath Path to a UTF-8 JSON folder access grant batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleAccesses Writes a sample grant batch JSON file and exits without granting access. .EXAMPLE PS> Share-KeeperNSFFolderAccesses -DownloadSampleAccesses .EXAMPLE PS> Share-KeeperNSFFolderAccesses -FilePath .\nsf-folders-access-grant-batch.sample.json #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleAccesses ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleAccesses) { if (-not $FilePath) { $FilePath = 'nsf-folders-access-grant-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderAccessGrantBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder access grant batch file written to: $fullPath" -ForegroundColor Green Write-Host "Replace placeholders, then run:" Write-Host " Share-KeeperNSFFolderAccesses -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleAccesses is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $requests = ConvertTo-KeeperNSFFolderAccessGrantRequests -JsonText $jsonText if (-not $requests -or $requests.Count -eq 0) { throw "Access grant file contains no entries." } if ($requests -isnot [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest]]) { $typed = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest]' foreach ($item in @($requests)) { if ($item -is [KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest]) { $typed.Add($item) | Out-Null } } $requests = $typed } } catch { Write-Host "Error parsing folder access grant payload: $($_.Exception.Message)" -ForegroundColor Red return } if (-not $PSCmdlet.ShouldProcess("$($requests.Count) Keeper NSF folder access grant(s)", "Grant Keeper NSF folder access")) { return } try { Write-Host "Granting $($requests.Count) Keeper NSF folder access(es) in batch..." $list = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest]]$requests $results = $vault.GrantKeeperNSFFolderAccesses($list).GetAwaiter().GetResult() Write-KeeperNSFFolderAccessBatchResults -Results $results -ActionLabel 'grant' return ,@($results) } catch { Write-Host "Error granting folder access in batch: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-share-folders -Value Share-KeeperNSFFolderAccesses function Update-KeeperNSFFolderAccesses { <# .Synopsis Batch-update existing Keeper NSF folder access (user or team) — up to 500 entries per API request. .Description Uses vault/folders/v3/access_update (FolderAccessUpdates). Independent of Set-KeeperNSFFolderAccess. JSON schema: an "accesses" array. Each item: folder_uid, accessor, role and/or expire_in/expire_at. Omit as_team for team names/UIDs so resolution matches nsf-share-folder auto-detect. .Parameter FilePath Path to a UTF-8 JSON folder access update batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleAccesses Writes a sample update batch JSON file and exits without updating access. .EXAMPLE PS> Update-KeeperNSFFolderAccesses -DownloadSampleAccesses .EXAMPLE PS> Update-KeeperNSFFolderAccesses -FilePath .\nsf-folders-access-update-batch.sample.json #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleAccesses ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleAccesses) { if (-not $FilePath) { $FilePath = 'nsf-folders-access-update-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderAccessUpdateBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder access update batch file written to: $fullPath" -ForegroundColor Green Write-Host "Replace placeholders, then run:" Write-Host " Update-KeeperNSFFolderAccesses -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleAccesses is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $requests = ConvertTo-KeeperNSFFolderAccessUpdateRequests -JsonText $jsonText if (-not $requests -or $requests.Count -eq 0) { throw "Access update file contains no entries." } if ($requests -isnot [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest]]) { $typed = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest]' foreach ($item in @($requests)) { if ($item -is [KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest]) { $typed.Add($item) | Out-Null } } $requests = $typed } } catch { Write-Host "Error parsing folder access update payload: $($_.Exception.Message)" -ForegroundColor Red return } if (-not $PSCmdlet.ShouldProcess("$($requests.Count) Keeper NSF folder access update(s)", "Update Keeper NSF folder access")) { return } try { Write-Host "Updating $($requests.Count) Keeper NSF folder access(es) in batch..." $list = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest]]$requests $results = $vault.UpdateKeeperNSFFolderAccesses($list).GetAwaiter().GetResult() Write-KeeperNSFFolderAccessBatchResults -Results $results -ActionLabel 'update' return ,@($results) } catch { Write-Host "Error updating folder access in batch: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-update-folder-access -Value Update-KeeperNSFFolderAccesses function Unshare-KeeperNSFFolderAccesses { <# .Synopsis Batch-revoke Keeper NSF folder access (user or team) — up to 500 entries per API request. .Description Uses vault/folders/v3/access_update (FolderAccessRemoves). Independent of Set-KeeperNSFFolderAccess. JSON schema: an "accesses" array. Each item: folder_uid, accessor. Omit as_team for team names/UIDs so resolution matches nsf-share-folder auto-detect. .Parameter FilePath Path to a UTF-8 JSON folder access revoke batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleAccesses Writes a sample revoke batch JSON file and exits without revoking access. .EXAMPLE PS> Unshare-KeeperNSFFolderAccesses -DownloadSampleAccesses .EXAMPLE PS> Unshare-KeeperNSFFolderAccesses -FilePath .\nsf-folders-access-revoke-batch.sample.json #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleAccesses ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleAccesses) { if (-not $FilePath) { $FilePath = 'nsf-folders-access-revoke-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderAccessRevokeBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder access revoke batch file written to: $fullPath" -ForegroundColor Green Write-Host "Replace placeholders, then run:" Write-Host " Unshare-KeeperNSFFolderAccesses -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleAccesses is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $requests = ConvertTo-KeeperNSFFolderAccessRevokeRequests -JsonText $jsonText if (-not $requests -or $requests.Count -eq 0) { throw "Access revoke file contains no entries." } if ($requests -isnot [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest]]) { $typed = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest]' foreach ($item in @($requests)) { if ($item -is [KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest]) { $typed.Add($item) | Out-Null } } $requests = $typed } } catch { Write-Host "Error parsing folder access revoke payload: $($_.Exception.Message)" -ForegroundColor Red return } if (-not $PSCmdlet.ShouldProcess("$($requests.Count) Keeper NSF folder access revoke(s)", "Revoke Keeper NSF folder access")) { return } try { Write-Host "Revoking $($requests.Count) Keeper NSF folder access(es) in batch..." $list = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest]]$requests $results = $vault.RevokeKeeperNSFFolderAccesses($list).GetAwaiter().GetResult() Write-KeeperNSFFolderAccessBatchResults -Results $results -ActionLabel 'revoke' return ,@($results) } catch { Write-Host "Error revoking folder access in batch: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-unshare-folders -Value Unshare-KeeperNSFFolderAccesses # Print per-item OK/FAIL lines after a folder-access batch call. function Script:Write-KeeperNSFFolderAccessBatchResults { Param( [Parameter(Mandatory = $true)] $Results, [string] $ActionLabel = 'access' ) $ok = @($Results | Where-Object { $_.Success }) $fail = @($Results | Where-Object { -not $_.Success }) Write-Host "Batch complete: $($ok.Count) succeeded, $($fail.Count) failed." Write-Host "" foreach ($result in $Results) { $typeHint = if ($result.AccessType) { " ($($result.AccessType))" } else { '' } $roleHint = if ($result.Role) { " [$($result.Role)]" } else { '' } if ($result.Success) { Write-Host " [OK] $($result.FolderUid) -> $($result.Accessor)$typeHint$roleHint" -ForegroundColor Green } else { $msg = if ($result.Message) { $result.Message } else { '(no message)' } Write-Host " [FAIL] $($result.FolderUid) -> $($result.Accessor)$typeHint status=$($result.Status) $msg" -ForegroundColor Red } } } # Pull the access array out of grant/update/revoke batch JSON (accesses, shares, or root array). function Script:Get-KeeperNSFFolderAccessJsonItems { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $parsed = $JsonText | ConvertFrom-Json -ErrorAction Stop if ($null -ne $parsed.accesses) { return @($parsed.accesses) } if ($null -ne $parsed.shares) { return @($parsed.shares) } if ($parsed -is [System.Array]) { return @($parsed) } throw "JSON must contain an 'accesses' array (or be a root array of access objects)." } # Normalize one JSON access object to folder_uid, accessor, role, team flag, and expiration. function Script:Get-KeeperNSFFolderAccessItemFields { Param($Item, [int] $Index, [switch] $RequireRole) $folderUid = $null if ($Item.folder_uid) { $folderUid = [string]$Item.folder_uid } elseif ($Item.uid) { $folderUid = [string]$Item.uid } elseif ($Item.FolderUid) { $folderUid = [string]$Item.FolderUid } $accessor = $null if ($Item.accessor) { $accessor = [string]$Item.accessor } elseif ($Item.email) { $accessor = [string]$Item.email } elseif ($Item.user_email) { $accessor = [string]$Item.user_email } elseif ($Item.Accessor) { $accessor = [string]$Item.Accessor } if ([string]::IsNullOrWhiteSpace($folderUid)) { throw "Access item at index $Index is missing required 'folder_uid' (or uid)." } if ([string]::IsNullOrWhiteSpace($accessor)) { throw "Access item at index $Index is missing required 'accessor' (or email)." } $role = $null if ($Item.role) { $role = [string]$Item.role } elseif ($Item.Role) { $role = [string]$Item.Role } $validRoles = @('viewer', 'share-manager', 'content-manager', 'content-share-manager', 'full-manager') if ($RequireRole) { if ([string]::IsNullOrWhiteSpace($role)) { $role = 'viewer' } $roleNormalized = $role.Trim().ToLowerInvariant() if ($validRoles -notcontains $roleNormalized) { throw "Invalid role '$role' at access index $Index. Valid roles: $($validRoles -join ', ')." } $role = $roleNormalized } elseif (-not [string]::IsNullOrWhiteSpace($role)) { $roleNormalized = $role.Trim().ToLowerInvariant() if ($validRoles -notcontains $roleNormalized) { throw "Invalid role '$role' at access index $Index. Valid roles: $($validRoles -join ', ')." } $role = $roleNormalized } $asTeam = $null if ($null -ne $Item.as_team) { $asTeam = [bool]$Item.as_team } elseif ($null -ne $Item.AsTeam) { $asTeam = [bool]$Item.AsTeam } $expireIn = $null if ($Item.expire_in) { $expireIn = $Item.expire_in } elseif ($Item.ExpireIn) { $expireIn = $Item.ExpireIn } $expireAt = $null if ($Item.expire_at) { $expireAt = [string]$Item.expire_at } elseif ($Item.ExpireAt) { $expireAt = [string]$Item.ExpireAt } $options = $null if ($expireIn -or $expireAt) { $expirationDto = Get-ExpirationDate -ExpireIn $expireIn -ExpireAt $expireAt $options = New-Object KeeperSecurity.Vault.SharedFolderUserOptions $options.Expiration = $expirationDto } return @{ FolderUid = $folderUid.Trim() Accessor = $accessor.Trim() Role = $role AsTeam = $asTeam Options = $options } } # Build grant requests from folder-access batch JSON. function Script:ConvertTo-KeeperNSFFolderAccessGrantRequests { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $items = Get-KeeperNSFFolderAccessJsonItems -JsonText $JsonText $list = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest]' $index = 0 foreach ($item in $items) { $fields = Get-KeeperNSFFolderAccessItemFields -Item $item -Index $index -RequireRole $req = New-Object KeeperSecurity.Vault.KeeperNSFFolderAccessGrantRequest $req.FolderUid = $fields.FolderUid $req.Accessor = $fields.Accessor $req.Role = $fields.Role if ($null -ne $fields.AsTeam) { $req.AsTeam = $fields.AsTeam } if ($null -ne $fields.Options) { $req.Options = $fields.Options } $list.Add($req) | Out-Null $index++ } return ,$list } # Build role/expiration update requests from folder-access batch JSON. function Script:ConvertTo-KeeperNSFFolderAccessUpdateRequests { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $items = Get-KeeperNSFFolderAccessJsonItems -JsonText $JsonText $list = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest]' $index = 0 foreach ($item in $items) { $fields = Get-KeeperNSFFolderAccessItemFields -Item $item -Index $index if ([string]::IsNullOrWhiteSpace($fields.Role) -and $null -eq $fields.Options) { throw "Access update at index $index requires 'role' and/or expire_in/expire_at." } $req = New-Object KeeperSecurity.Vault.KeeperNSFFolderAccessUpdateRequest $req.FolderUid = $fields.FolderUid $req.Accessor = $fields.Accessor $req.Role = $fields.Role if ($null -ne $fields.AsTeam) { $req.AsTeam = $fields.AsTeam } if ($null -ne $fields.Options) { $req.Options = $fields.Options } $list.Add($req) | Out-Null $index++ } return ,$list } # Build revoke requests from folder-access batch JSON. function Script:ConvertTo-KeeperNSFFolderAccessRevokeRequests { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $items = Get-KeeperNSFFolderAccessJsonItems -JsonText $JsonText $list = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest]' $index = 0 foreach ($item in $items) { $fields = Get-KeeperNSFFolderAccessItemFields -Item $item -Index $index $req = New-Object KeeperSecurity.Vault.KeeperNSFFolderAccessRevokeRequest $req.FolderUid = $fields.FolderUid $req.Accessor = $fields.Accessor if ($null -ne $fields.AsTeam) { $req.AsTeam = $fields.AsTeam } $list.Add($req) | Out-Null $index++ } return ,$list } function Write-KeeperNSFRemoveImpact { param( [Folder.V3.Remove.RemoveResponse]$Response, [string] $ItemLabel = 'Record' ) if ($Response.ErrorMessage) { Write-Host "Error: $($Response.ErrorMessage)" -ForegroundColor Red } foreach ($result in $Response.Results) { $recordUid = if ($result.ItemUid.Length -gt 0) { [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($result.ItemUid.ToByteArray()) } else { '(unknown)' } $folderUid = if ($result.FolderUid.Length -gt 0) { [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($result.FolderUid.ToByteArray()) } else { '' } Write-Host "" Write-Host "${ItemLabel}: $recordUid" -ForegroundColor Cyan if ($folderUid) { Write-Host " Folder context: $folderUid" } Write-Host " Status: $($result.Status)" if ($result.Error -and $result.Error.Message) { Write-Host " Error: $($result.Error.Message)" -ForegroundColor Red } if ($result.Impact) { $impact = $result.Impact Write-Host " Impact:" Write-Host " Folders: $($impact.FoldersCount)" Write-Host " Records: $($impact.RecordsCount)" Write-Host " Affected users: $($impact.AffectedUsersCount)" Write-Host " Affected teams: $($impact.AffectedTeamsCount)" if ($impact.RecordInfo) { Write-Host " Other locations: $($impact.RecordInfo.LocationsCount)" } foreach ($warning in $impact.Warnings) { Write-Host " Warning: $warning" -ForegroundColor Yellow } } } } function Set-KeeperNSFFolder { <# .Synopsis Renames, recolors, or updates permission inheritance for a single Keeper NSF folder. .Description Updates one folder via the Keeper NSF v3 API. For updating many folders from JSON, use Set-KeeperNSFFolders (nsf-folders-update). .Parameter Folder Folder UID or name. .Parameter Name New folder name. .Parameter Color Optional folder color, or "none" to clear. .Parameter NoInheritPermissions Do not inherit parent folder permissions. .EXAMPLE PS> Set-KeeperNSFFolder <folderUid> -Name "Renamed" -Color blue .EXAMPLE PS> nsf-rndir <folderUid> -Name "Renamed" #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Default')] Param( [Parameter(Position = 0, Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Folder, [Alias('n')] [Parameter(ParameterSetName = 'Default')] [string] $Name, [ValidateSet('none', 'red', 'orange', 'yellow', 'green', 'blue', 'gray', 'grey')] [string] $Color, [Parameter()] [switch] $NoInheritPermissions ) if (-not $Name -and -not $PSBoundParameters.ContainsKey('Color') -and -not $NoInheritPermissions.IsPresent) { Write-Error -Message "Specify -Name, -Color, and/or -NoInheritPermissions to update the folder." return } try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error -Message "Error getting vault: $($_.Exception.Message)" return } [KeeperSecurity.Vault.FolderNode]$folderNode = $null if (-not $vault.TryResolveKeeperNSFFolder($Folder, [ref]$folderNode)) { Write-Error -Message "Keeper NSF folder `"$Folder`" was not found. Run Sync-Keeper or nsf-list first." return } $newNameArg = if ($PSBoundParameters.ContainsKey('Name')) { $Name } else { [NullString]::Value } $colorArg = if ($PSBoundParameters.ContainsKey('Color')) { $Color } else { [NullString]::Value } $inheritVal = if ($NoInheritPermissions.IsPresent) { $false } else { $null } $target = if ([string]::IsNullOrEmpty($folderNode.FolderUid)) { $Folder } else { "$($folderNode.Name) ($($folderNode.FolderUid))" } if (-not $PSCmdlet.ShouldProcess($target, "Update Keeper NSF folder")) { return } try { $result = $vault.UpdateKeeperNSFFolder($folderNode.FolderUid, $newNameArg, $colorArg, $inheritVal).GetAwaiter().GetResult() [KeeperSecurity.Vault.VaultOnline]::ValidateFolderModifyResult($result) Write-Host "Folder '$($folderNode.FolderUid)' updated." -ForegroundColor Green } catch { Write-Error -Message $_.Exception.Message return } $vault.SyncDown($false).GetAwaiter().GetResult() | Out-Null } New-Alias -Name nsf-rndir -Value Set-KeeperNSFFolder function Set-KeeperNSFFolders { <# .Synopsis Batch-updates Keeper NSF folders from JSON (up to 100 per API request). .Description Uses vault/folders/v3/update batching. Independent of Set-KeeperNSFFolder / nsf-rndir (single folder). Folder name/color are AES-GCM encrypted into FolderData.Data. Optional inherit_permissions may only be false (disable inheritance); true is rejected — same as Set-KeeperNSFFolder -NoInheritPermissions. Requires share/update-access permission on each folder. Larger sets are chunked automatically (100 folders per request). JSON schema: a "folders" array. Each item: uid (required), optional name, optional color, optional inherit_permissions (false only). .Parameter FilePath Path to a UTF-8 JSON folder update batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleFolders Writes a sample batch update JSON file and exits without updating folders. .EXAMPLE PS> Set-KeeperNSFFolders -DownloadSampleFolders .EXAMPLE PS> Set-KeeperNSFFolders -FilePath .\nsf-folders-update-batch.sample.json #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleFolders ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleFolders) { if (-not $FilePath) { $FilePath = 'nsf-folders-update-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderUpdateBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder update batch file written to: $fullPath" -ForegroundColor Green Write-Host "Replace REPLACE_WITH_FOLDER_UID_* values, then run:" Write-Host " Set-KeeperNSFFolders -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleFolders is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $folderRequests = ConvertTo-KeeperNSFFolderUpdateRequests -JsonText $jsonText if (-not $folderRequests -or $folderRequests.Count -eq 0) { throw "Folder update file contains no folders." } if ($folderRequests -isnot [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest]]) { $typed = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest]' foreach ($item in @($folderRequests)) { if ($item -is [KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest]) { $typed.Add($item) | Out-Null } } $folderRequests = $typed } } catch { Write-Host "Error parsing folder update payload: $($_.Exception.Message)" -ForegroundColor Red return } if (-not $PSCmdlet.ShouldProcess("$($folderRequests.Count) Keeper NSF folder(s)", "Update Keeper NSF folders")) { return } try { Write-Host "Updating $($folderRequests.Count) Keeper NSF folder(s) in batch..." $folderList = [System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest]]$folderRequests $results = $vault.UpdateKeeperNSFFolders($folderList).GetAwaiter().GetResult() $ok = @($results | Where-Object { $_.Success }) $fail = @($results | Where-Object { -not $_.Success }) Write-Host "Batch complete: $($ok.Count) succeeded, $($fail.Count) failed." Write-Host "" foreach ($result in $results) { if ($result.Success) { Write-Host " [OK] $($result.Name) UID: $($result.FolderUid)" -ForegroundColor Green } else { $msg = if ($result.Message) { $result.Message } else { '(no message)' } Write-Host " [FAIL] $($result.Name) UID: $($result.FolderUid) status=$($result.Status) $msg" -ForegroundColor Red } } return ,@($results) } catch { Write-Host "Error updating folders in batch: $($_.Exception.Message)" -ForegroundColor Red } } New-Alias -Name nsf-folders-update -Value Set-KeeperNSFFolders # Parse batch folder-update JSON into KeeperNSFFolderUpdateRequest objects. function Script:ConvertTo-KeeperNSFFolderUpdateRequests { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $parsed = $JsonText | ConvertFrom-Json -ErrorAction Stop $items = @() if ($null -ne $parsed.folders) { $items = @($parsed.folders) } elseif ($parsed -is [System.Array]) { $items = @($parsed) } else { throw "JSON must contain a 'folders' array (or be a root array of folder update objects)." } $list = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest]' $index = 0 foreach ($item in $items) { $uid = $null if ($item.uid) { $uid = [string]$item.uid } elseif ($item.folder_uid) { $uid = [string]$item.folder_uid } elseif ($item.FolderUid) { $uid = [string]$item.FolderUid } if ([string]::IsNullOrWhiteSpace($uid)) { throw "Folder update item at index $index is missing required 'uid' (or folder_uid)." } $hasName = $false $name = $null if ($null -ne $item.PSObject.Properties['name']) { $hasName = $true if ($null -ne $item.name) { $name = [string]$item.name } } elseif ($null -ne $item.PSObject.Properties['Name']) { $hasName = $true if ($null -ne $item.Name) { $name = [string]$item.Name } } $hasColor = $false $color = $null if ($null -ne $item.PSObject.Properties['color']) { $hasColor = $true if ($null -ne $item.color) { $color = [string]$item.color } } elseif ($null -ne $item.PSObject.Properties['Color']) { $hasColor = $true if ($null -ne $item.Color) { $color = [string]$item.Color } } $hasInherit = $false $inherit = $null if ($null -ne $item.PSObject.Properties['inherit_permissions']) { $hasInherit = $true if ($null -ne $item.inherit_permissions) { $inherit = [bool]$item.inherit_permissions } } elseif ($null -ne $item.PSObject.Properties['InheritPermissions']) { $hasInherit = $true if ($null -ne $item.InheritPermissions) { $inherit = [bool]$item.InheritPermissions } } if (-not $hasName -and -not $hasColor -and -not $hasInherit) { throw "Folder update item at index $index must include name, color, and/or inherit_permissions." } if ($hasInherit -and $null -ne $inherit -and $inherit -eq $true) { throw "Folder update item at index ${index}: inherit_permissions can only be false on update (same as Set-KeeperNSFFolder -NoInheritPermissions)." } $req = New-Object KeeperSecurity.Vault.KeeperNSFFolderUpdateRequest $req.FolderUid = $uid.Trim() if ($hasName) { $req.Name = $name } if ($hasColor) { $req.Color = $color } if ($hasInherit -and $null -ne $inherit) { $req.InheritPermissions = $inherit } $list.Add($req) | Out-Null $index++ } return ,$list } function Remove-KeeperNSFFolder { <# .Synopsis Removes one or more Keeper NSF folders by UID/name (Keeper NSF v3 API). .Description Uses vault/folders/v3/remove_folder (preview/confirm). Max 100 folders per API request; larger sets are chunked automatically. For JSON batch remove, use Remove-KeeperNSFFolders (nsf-rmdirs). .Parameter Folder One or more folder UIDs or names. .Parameter Operation folder-trash (default, recoverable) or delete-permanent (irreversible). owner-trash is not supported for folders. .Parameter Force Skip confirmation after preview. .Parameter DryRun Preview only; do not remove folders. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Default')] Param( [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)] [string[]] $Folder, [Alias('o')] [ValidateSet('folder-trash', 'delete-permanent')] [string] $Operation = 'folder-trash', [Alias('f')] [switch] $Force, [switch] $DryRun ) begin { [KeeperSecurity.Vault.VaultOnline]$vault = getVault $removals = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderRemoval]' $op = switch ($Operation) { 'folder-trash' { [KeeperSecurity.Vault.KeeperNSFFolderRemoveOperation]::FolderTrash } 'delete-permanent' { [KeeperSecurity.Vault.KeeperNSFFolderRemoveOperation]::DeletePermanent } } } process { foreach ($name in $Folder) { [KeeperSecurity.Vault.FolderNode]$folderNode = $null if (-not $vault.TryResolveKeeperNSFFolder($name, [ref]$folderNode)) { Write-Error -Message "Keeper NSF folder `"$name`" was not found. Run Sync-Keeper or nsf-list first." continue } $removal = New-Object KeeperSecurity.Vault.KeeperNSFFolderRemoval $removal.FolderUid = $folderNode.FolderUid $removal.Operation = $op $removals.Add($removal) } } end { if ($removals.Count -eq 0) { return } if ($Operation -eq 'delete-permanent' -and -not $Force -and -not $DryRun) { Write-Host "" Write-Host "*** WARNING ***" -ForegroundColor Red Write-Host " delete-permanent is IRREVERSIBLE." Write-Host " All sub-folders and records inside will be permanently destroyed." } Write-Host "" Write-Host "=== Keeper NSF Folder Remove Preview ===" -ForegroundColor Cyan try { $previewResult = $vault.RemoveKeeperNSFFolders($removals, $true).GetAwaiter().GetResult() } catch { Write-Error -Message $_.Exception.Message return } Write-KeeperNSFRemoveImpact -Response $previewResult.PreviewResponse -ItemLabel 'Folder' if ($previewResult.FailedChunkCount -gt 0) { Write-Error -Message "Folder remove preview failed for $($previewResult.FailedChunkCount) chunk(s)." foreach ($err in @($previewResult.ChunkErrors)) { Write-Warning " $err" } return } try { [KeeperSecurity.Vault.VaultOnline]::ValidateRemoveResponse($previewResult.PreviewResponse, $false) } catch { Write-Error -Message $_.Exception.Message return } if ($DryRun) { Write-Host "" Write-Host "Dry run: no folders were removed." -ForegroundColor DarkYellow return } if (-not $Force) { $prompt = if ($Operation -eq 'delete-permanent') { "Are you sure you want to permanently delete the folder(s) above? This action cannot be undone. (yes/No)" } else { "Are you sure you want to remove the folder(s) above? (yes/No)" } $confirmation = Read-Host $prompt if ($confirmation -notmatch '^(y|yes)$') { Write-Host "Remove operation cancelled" return } } if (-not $previewResult.ChunkConfirmationTokens -or $previewResult.ChunkConfirmationTokens.Count -eq 0) { Write-Error -Message "Preview did not return confirmation token(s)." return } Write-Host "" Write-Host "Removing folders..." -ForegroundColor Cyan try { $confirmResult = $vault.ConfirmKeeperNSFFolders($removals, $previewResult).GetAwaiter().GetResult() } catch { Write-Error -Message $_.Exception.Message return } if ($confirmResult.PartialSuccess) { Write-Warning "Partial folder removal: $($confirmResult.ConfirmedChunkCount) chunk(s) succeeded, $($confirmResult.FailedChunkCount) failed." foreach ($err in @($confirmResult.ChunkErrors)) { Write-Warning " $err" } $vault.SyncDown($false).GetAwaiter().GetResult() | Out-Null return } if (-not $confirmResult.Confirmed) { $detail = if ($confirmResult.ChunkErrors -and $confirmResult.ChunkErrors.Count -gt 0) { ($confirmResult.ChunkErrors -join '; ') } else { 'Folder removal was not confirmed by the server.' } Write-Error -Message $detail return } $vault.SyncDown($false).GetAwaiter().GetResult() | Out-Null Write-Host "" Write-Host "Keeper NSF folder removal completed." -ForegroundColor Green } } New-Alias -Name nsf-rmdir -Value Remove-KeeperNSFFolder function Remove-KeeperNSFFolders { <# .Synopsis Batch-remove Keeper NSF folders from JSON (up to 100 per API request). .Description Uses vault/folders/v3/remove_folder (preview/confirm). Independent of Remove-KeeperNSFFolder / nsf-rmdir. Larger sets are chunked automatically (100 folders per request). Duplicate folder_uid values in the same batch are rejected. owner-trash is not supported for folders. JSON schema: a "folders" (or "removals") array. Each item: uid / folder_uid (required), optional operation (folder-trash default, or delete-permanent). .Parameter FilePath Path to a UTF-8 JSON folder remove batch file. .Parameter Json Inline JSON string (same schema as -FilePath). .Parameter DownloadSampleFolders Writes a sample batch remove JSON file and exits without removing folders. .Parameter Force Skip confirmation after preview. .Parameter DryRun Preview only; do not remove folders. .EXAMPLE PS> Remove-KeeperNSFFolders -DownloadSampleFolders .EXAMPLE PS> Remove-KeeperNSFFolders -FilePath .\nsf-folders-remove-batch.sample.json .EXAMPLE PS> Remove-KeeperNSFFolders -FilePath .\nsf-folders-remove-batch.sample.json -Force #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'File')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'File')] [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string] $Json, [Parameter(Mandatory = $false, ParameterSetName = 'DownloadSample')] [switch] $DownloadSampleFolders, [Alias('f')] [switch] $Force, [switch] $DryRun ) try { [KeeperSecurity.Vault.VaultOnline]$vault = getVault } catch { Write-Error "Not connected to Keeper. Please login first." return } if ($DownloadSampleFolders) { if (-not $FilePath) { $FilePath = 'nsf-folders-remove-batch.sample.json' } $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) $parentDir = Split-Path -Parent $fullPath if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $sampleJson = Get-KeeperNSFFolderRemoveBatchSampleJson $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($fullPath, $sampleJson, $utf8NoBom) Write-Host "Sample NSF folder remove batch file written to: $fullPath" -ForegroundColor Green Write-Host "Replace REPLACE_WITH_FOLDER_UID_* values, then run:" Write-Host " Remove-KeeperNSFFolders -FilePath `"$FilePath`"" return } if (-not $FilePath -and -not $Json) { Write-Error "FilePath or Json is required when -DownloadSampleFolders is not specified." return } try { $jsonText = if ($PSCmdlet.ParameterSetName -eq 'File') { if (-not (Test-Path -LiteralPath $FilePath)) { throw "File not found: $FilePath" } Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 } else { $Json } $specs = ConvertTo-KeeperNSFFolderRemovalSpecs -JsonText $jsonText if (-not $specs -or $specs.Count -eq 0) { throw "Folder remove file contains no folders." } } catch { Write-Host "Error parsing folder remove payload: $($_.Exception.Message)" -ForegroundColor Red return } $removals = New-Object 'System.Collections.Generic.List[KeeperSecurity.Vault.KeeperNSFFolderRemoval]' $seenUids = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $resolveErrors = New-Object System.Collections.Generic.List[string] $index = 0 foreach ($spec in $specs) { [KeeperSecurity.Vault.FolderNode]$folderNode = $null if (-not $vault.TryResolveKeeperNSFFolder($spec.FolderUid, [ref]$folderNode)) { $resolveErrors.Add("Keeper NSF folder `"$($spec.FolderUid)`" was not found (index $index). Run Sync-Keeper or nsf-list first.") $index++ continue } if (-not $seenUids.Add($folderNode.FolderUid)) { $resolveErrors.Add("Duplicate folder_uid '$($folderNode.FolderUid)' in the same request (index $index).") $index++ continue } if ($spec.Operation -notin @('folder-trash', 'delete-permanent')) { $resolveErrors.Add("Invalid operation '$($spec.Operation)' at index $index. Use folder-trash or delete-permanent.") $index++ continue } $op = switch ($spec.Operation) { 'folder-trash' { [KeeperSecurity.Vault.KeeperNSFFolderRemoveOperation]::FolderTrash } 'delete-permanent' { [KeeperSecurity.Vault.KeeperNSFFolderRemoveOperation]::DeletePermanent } } $removal = New-Object KeeperSecurity.Vault.KeeperNSFFolderRemoval $removal.FolderUid = $folderNode.FolderUid $removal.Operation = $op $removals.Add($removal) $index++ } if ($resolveErrors.Count -gt 0) { foreach ($err in $resolveErrors) { Write-Error -Message $err } Write-Error -Message "Aborting folder remove batch: $($resolveErrors.Count) item(s) failed validation. Fix the JSON and retry." return } if ($removals.Count -eq 0) { return } $hasPermanent = @($removals | Where-Object { $_.Operation -eq [KeeperSecurity.Vault.KeeperNSFFolderRemoveOperation]::DeletePermanent }).Count -gt 0 if ($hasPermanent -and -not $Force -and -not $DryRun) { Write-Host "" Write-Host "*** WARNING ***" -ForegroundColor Red Write-Host " delete-permanent is IRREVERSIBLE." Write-Host " All sub-folders and records inside will be permanently destroyed." } Write-Host "" Write-Host "=== Keeper NSF Folder Remove Preview ===" -ForegroundColor Cyan try { $previewResult = $vault.RemoveKeeperNSFFolders($removals, $true).GetAwaiter().GetResult() } catch { Write-Error -Message $_.Exception.Message return } Write-KeeperNSFRemoveImpact -Response $previewResult.PreviewResponse -ItemLabel 'Folder' if ($previewResult.FailedChunkCount -gt 0) { Write-Error -Message "Folder remove preview failed for $($previewResult.FailedChunkCount) chunk(s)." foreach ($err in @($previewResult.ChunkErrors)) { Write-Warning " $err" } return } $previewErrors = @($previewResult.PreviewResponse.Results | Where-Object { $_.Error -and -not [string]::IsNullOrWhiteSpace($_.Error.Message) }) if ($previewErrors.Count -gt 0) { Write-Host "" Write-Host "One or more folders could not be previewed. Aborting." -ForegroundColor Yellow return } try { [KeeperSecurity.Vault.VaultOnline]::ValidateRemoveResponse($previewResult.PreviewResponse, $false) } catch { Write-Error -Message $_.Exception.Message return } if ($DryRun) { Write-Host "" Write-Host "Dry run: no folders were removed." -ForegroundColor DarkYellow return } if (-not $PSCmdlet.ShouldProcess("$($removals.Count) Keeper NSF folder(s)", "Remove Keeper NSF folders")) { return } if (-not $Force) { $prompt = if ($hasPermanent) { "Are you sure you want to permanently delete the folder(s) above? This action cannot be undone. (yes/No)" } else { "Are you sure you want to remove the folder(s) above? (yes/No)" } $confirmation = Read-Host $prompt if ($confirmation -notmatch '^(y|yes)$') { Write-Host "Remove operation cancelled" return } } if (-not $previewResult.ChunkConfirmationTokens -or $previewResult.ChunkConfirmationTokens.Count -eq 0) { Write-Error -Message "Preview did not return confirmation token(s)." return } Write-Host "" Write-Host "Removing $($removals.Count) Keeper NSF folder(s) in batch..." -ForegroundColor Cyan try { $confirmResult = $vault.ConfirmKeeperNSFFolders($removals, $previewResult).GetAwaiter().GetResult() } catch { Write-Error -Message $_.Exception.Message return } if ($confirmResult.PartialSuccess) { Write-Warning "Partial folder removal: $($confirmResult.ConfirmedChunkCount) chunk(s) succeeded, $($confirmResult.FailedChunkCount) failed." foreach ($err in @($confirmResult.ChunkErrors)) { Write-Warning " $err" } $vault.SyncDown($false).GetAwaiter().GetResult() | Out-Null return } if (-not $confirmResult.Confirmed) { $detail = if ($confirmResult.ChunkErrors -and $confirmResult.ChunkErrors.Count -gt 0) { ($confirmResult.ChunkErrors -join '; ') } else { 'Folder removal was not confirmed by the server.' } Write-Error -Message $detail return } $vault.SyncDown($false).GetAwaiter().GetResult() | Out-Null Write-Host "" Write-Host "Keeper NSF folder removal completed." -ForegroundColor Green } New-Alias -Name nsf-rmdirs -Value Remove-KeeperNSFFolders # Parse batch folder-remove JSON into removal specs (uid + operation, deduped). function Script:ConvertTo-KeeperNSFFolderRemovalSpecs { Param( [Parameter(Mandatory = $true)] [string] $JsonText ) $parsed = $JsonText | ConvertFrom-Json -ErrorAction Stop $items = @() if ($null -ne $parsed.folders) { $items = @($parsed.folders) } elseif ($null -ne $parsed.removals) { $items = @($parsed.removals) } elseif ($parsed -is [System.Array]) { $items = @($parsed) } else { throw "JSON must contain a 'folders' (or 'removals') array (or be a root array of folder remove objects)." } if ($items.Count -eq 0) { throw "folders must not be empty." } $list = New-Object System.Collections.Generic.List[object] $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $index = 0 foreach ($item in $items) { $uid = $null if ($item.uid) { $uid = [string]$item.uid } elseif ($item.folder_uid) { $uid = [string]$item.folder_uid } elseif ($item.FolderUid) { $uid = [string]$item.FolderUid } if ([string]::IsNullOrWhiteSpace($uid)) { throw "Folder remove item at index $index is missing required 'uid' (or folder_uid)." } $uid = $uid.Trim() if (-not $seen.Add($uid)) { throw "Duplicate folder_uid '$uid' in the same request (index $index)." } $operation = 'folder-trash' if ($null -ne $item.PSObject.Properties['operation'] -and $null -ne $item.operation) { $operation = [string]$item.operation } elseif ($null -ne $item.PSObject.Properties['Operation'] -and $null -ne $item.Operation) { $operation = [string]$item.Operation } $operation = $operation.Trim().ToLowerInvariant() if ($operation -eq 'owner-trash') { throw "Folder remove item at index ${index}: owner-trash (FOLDER_MOVE_TO_OWNER_TRASH) is not supported yet." } if ($operation -notin @('folder-trash', 'delete-permanent')) { throw "Folder remove item at index $index has invalid operation '$operation'. Use folder-trash or delete-permanent." } $list.Add([pscustomobject]@{ FolderUid = $uid Operation = $operation }) | Out-Null $index++ } return ,$list } # SIG # Begin signature block # MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCB7Zr5fwHp4jfhm # hjdUPLyZY37Ja/Lk3kzCUomdpOs7FKCCITswggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx # CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4 # RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg # MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit # eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS # 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM # swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC # Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3 # /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j # q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5 # OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo # 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU # tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm # KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP # TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/ # BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j # BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud # JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0 # cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0 # cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E # PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz # dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq # hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK # r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda # qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+ # lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a # brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS # y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK # iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb # KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q # xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm # zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn # HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w # gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1 # c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo # dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi # 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg # xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF # cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ # m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS # GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1 # ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9 # MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7 # Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG # RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6 # X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd # BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx # XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF # BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln # aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j # b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo # dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy # bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL # BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj # aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0 # hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0 # F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT # mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf # ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE # wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh # OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX # gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO # LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG # WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg # AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG # EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0 # IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex # MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx # FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy # NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI # hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3 # zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch # TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj # FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo # yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP # KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS # uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w # JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW # doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg # rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K # 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf # gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy # Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL # TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG # AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j # b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp # Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy # dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j # cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB # CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ # D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/ # ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu # +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o # bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h # ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn # M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol # /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY # xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc # CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB # ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx # oAMCAQICEAHdzU+FVN9jCMv0HhHagNUwDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE # BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy # dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB # MTAeFw0yNjA2MDUwMDAwMDBaFw0yNzA2MDQyMzU5NTlaMIHRMRMwEQYLKwYBBAGC # NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ # cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC # VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK # ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5 # IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCb4DRTV0sNQsa1 # 0YRh+bliabmLOVYr6S0+BSVvRJAN3SHP6x52i1Dkpki5xVDIH06ZnnsToVrgvTv+ # QxGwsn9SAPHEZ/PIJRFxbMR4ShDaptYyL4f0u4k/3HwRzIleWE4mTUonYH8BdgLw # /F53B7wa7VTDHtxXltYTibEOwJxYCOi4Zr2FYQhjw14/CHcqS3FSMs6YYU2T56+g # w819hQM3K0YlwTNOFoIm1v7/ZZZiJGH8uGDsvy1makh1Xyyo/wN8EbQ1nbslmePT # roPm9w7WqiP/yiq+CZHiuTk9JK5bEgkWG3ns+v25cI251WidJx3SU7IZnX0OTd6/ # ZdKhprD5Gcfy5GBbJdcYw2WycQRW0PT5BEt55xRE0heufkpDaTUN6RdOuJdXbkl0 # hV91IZIuhueEMCk3h5mDTlU5gImxqj0R/TbAxjSSGTKCeuYFkQIRqytSabdrZZ48 # kW5hOIZMVDY1f4kpPJa8UeEvDZXT3vrtj36aSJrwez2uh4FMNlkCAwEAAaOCAgIw # ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBT1 # SmCYU/7Yrz1fX66Ur5nSzlSYOzA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB # BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC # B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0 # cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p # bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI # QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT # QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA # A4ICAQBcavcUHNFEg872HDRq2+hRlnvaghCXv7X/6h9HSzjAQP3rt95BZty3ASqi # 2MYyGQLGdDl4DToe/WhajtEOBOYa83agW6tBvrfcKRrDrwJOMPTbwNYvn+GuiL4T # CKzXaytWiJJbrc5odc7Ecat2ZvJylpPmNainr4Q0LzzH23Gea/Mm/hIJTN4IGgrH # hrXiTIIW/ZUzrY6g8b3RZB4BA497n43wNdSqP+C3ntFw6NiGB4Z25SW4YntIxYPv # Kf37OVhF0xqxLC1sK/XxgK0EGQ6iaj8Ncpr2C5vSNZqfW2MndxOA1W67pgDpg83k # UWG+/YJeGhqOTF82/0kIzQXeI/lIqbnL/IJAJqSm/ROSpsGUKVbzk03cpTD55ZQX # WjM0fLirypBqY05T8gnh1L0fSwxr/SwJZ8OddivgyK1YOMn02nnsEG5kxBt9cMX4 # JCYABhypmAVDRvyYifEVdoFWv2gAXXW+PPRvlNa6E4aMCZrVcoKHiyeMAXOi1IC9 # mHvC2+foTSMFueq3AdnYfeKnZnAiKXKRhXcdHbQYcR2A7AIzIcqahPYr4FNEgb/E # /y/kypAkf0rMHlYl1kNqLs2Nv1UnMEHYT5YmDVLO63+1Trcw4zTZ70zuqIqeID/d # nbOlgtyG6DSRCL7f0E7kP18f4RoX5i1PkfeO4VJHsAuCeNG1qjGCBdkwggXVAgEB # MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD # VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI # QTM4NCAyMDIxIENBMQIQAd3NT4VU32MIy/QeEdqA1TANBglghkgBZQMEAgEFAKCB # hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE # AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ # BDEiBCA9gfgIZLt/CaQy28q8ipWYjSRc3QH5paTZVJ2ymvff7jANBgkqhkiG9w0B # AQEFAASCAYBZJEjS7Fr36ACvoaRSHkXd0fjelXduxNXVVoKrbdfeBdje2lIYfrgq # WRMDSIY6/wNl6i3GKKZyVke4QRTuBOnJqbZ0bnA+v8XS8FzTLankZr/+mrfO6Vh1 # jnLuwsE08TVafZyYY++zcbLjQWS4AXWIrv1NJ0J0wnqrF1DiZzGa3cAYCEkf5zdH # uVlypcgItzJctjGnmjc3PX66y91K4ZnC5+SpNRe8VSkPC7eV3CDh06Tgyr27Ozm5 # iFDN6LJ9kq7ID7c0oyvLHAgNGubPCKBPTozWbLWaj4OmBu3fvg1P9XbyG9YsH0Ms # Mj2eFXB3kMUcMWdUSlcfVjpsQyf5dtr8dod4pnhuEC+FxVl/vZ8FS5Evmi3sueCe # UDhTRGBU9iPXS4ktdSPK4ew6T5JzsXnZ9cnSeaqLGq6UI2PR8U+DVJIJ/Zeh7JPi # zCE2GuZOXNUkCfwYZIJBM2seheLH+pjWIMgMC1Txb9N0oj46wpJrMCZEY7Xx9uHZ # /lykruUQ1m2hggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD # VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD # ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg # Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN # AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwODE0MjExOTE2WjAv # BgkqhkiG9w0BCQQxIgQgXCg7ZMy71agQ31SjqK0EYHTeRB0lLiNy2wUVWPDyvLgw # DQYJKoZIhvcNAQEBBQAEggIAvENgg4DOFs91095m97ybYTDIAF6VRBg5GRTTczkS # DVwkAqksbNwb560CKsMtlN/aQAsBTE7zoCKf9vs+oBKKx6d5RBqCxILoYfisNEx1 # pt9KsISg28PxmuA6N0IAa3zF+Jsx0/i+2EbhYMq21ebqI1/t1FvV+VOGayqo8ZWi # uwCbFhfD9aoTVIIfGSbWXEjE7FK6EvR+3wwLhcjtayJJjeqMT/wyx+IzcyefPB5j # 5eqpFeKy09gNPGJFZiTiXg3Hv47Gjctzp+5J9OE5sJM34etvBwzicSYC4LsBUxh6 # nmbwSaEWRqk3mrqjj0kXQqYA7BEptB7YXWfiHlIZaAE1XnyGfieS1J1bvOekXFJ+ # nHwTERSH7NNmXsPq5iRnuqae/PTjoPFgH5Ff3jHnUzNtELsAAtsHVw2lz9HAe4MA # Jy6SRxvCrtBsY/gbRQDSG4EUlwufHckFJGDXh37CQFXEvSLUy5Sqhfm1Viw1gXGF # KOOLAw2eWSviyUOJYwwLoJ0abDMN2cHn0t1niWyElj6ZPpqr3zLE4ZyCCyDlBEmU # cMaEhWtxHnGhDtPUoOB6NP/pWIU1WHQhi1i4Xht6HDXsKkxPIfhZ+2fIO4FOJwl4 # p8kisGWHfYGrE15vnvKIGZT5GSfwJeYHkL1+txcZQRou3d6I/9zA/1wlD2CUnm4e # n9Q= # SIG # End signature block |