Skip to content

Control 4.3: Site and Document Retention Management - PowerShell Setup

Read the FSI PowerShell baseline first

Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show the canonical patterns; the baseline is authoritative.

This playbook provides PowerShell automation guidance for Control 4.3.


Prerequisites

Pin module versions to versions approved by your Change Advisory Board. Replace <version> with the value recorded in your change ticket.

# Pin module versions per the FSI PowerShell Authoring Baseline (Section 1).
# DO NOT use -Force without -RequiredVersion in regulated tenants.
Install-Module -Name ExchangeOnlineManagement `
    -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense

Install-Module -Name Microsoft.Online.SharePoint.PowerShell `
    -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense

Required roles:

  • Purview Compliance Admin or Purview Records Manager for *-RetentionCompliancePolicy, *-RetentionComplianceRule, *-ComplianceTag cmdlets
  • SharePoint Admin for Get-SPOSite coverage analysis

Connect to Services (Sovereign-Cloud Aware)

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string]$AdminUpn,
    [Parameter(Mandatory)] [string]$SpoAdminUrl,  # e.g. https://contoso-admin.sharepoint.com
    [ValidateSet('Commercial','GCC','GCCHigh','DoD')]
    [string]$Cloud = 'Commercial'
)

$ErrorActionPreference = 'Stop'

# Sovereign-cloud routing for Security & Compliance PowerShell (see baseline §3)
$ippsParams = @{ UserPrincipalName = $AdminUpn }
switch ($Cloud) {
    'GCC'      { $ippsParams.ConnectionUri = 'https://ps.compliance.protection.outlook.com/PowerShell-LiveID' }
    'GCCHigh'  { $ippsParams.ConnectionUri = 'https://ps.compliance.protection.office365.us/PowerShell-LiveID'
                 $ippsParams.AzureADAuthorizationEndpointUri = 'https://login.microsoftonline.us/common' }
    'DoD'      { $ippsParams.ConnectionUri = 'https://l5.ps.compliance.protection.office365.us/PowerShell-LiveID'
                 $ippsParams.AzureADAuthorizationEndpointUri = 'https://login.microsoftonline.us/common' }
}
Connect-IPPSSession @ippsParams

Connect-SPOService -Url $SpoAdminUrl

# Smoke test — silent failure here usually means wrong cloud / wrong UPN
Get-RetentionCompliancePolicy | Select-Object Name, Enabled, Mode | Format-Table

Verify before run: check the current sovereign-cloud connection URIs on Microsoft Learn — Microsoft has changed these URLs more than once.


Evidence Helper (SHA-256 + Manifest)

Per baseline §5, every artifact emitted from this control should be hashed and listed in manifest.json. Source the helper once, then reuse.

function Write-FsiEvidence {
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)] [string]$Name,
        [Parameter(Mandatory)] [string]$EvidencePath
    )
    New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
    $ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
    $jsonPath = Join-Path $EvidencePath "$Name-$ts.json"
    $Object | ConvertTo-Json -Depth 20 | Set-Content -Path $jsonPath -Encoding UTF8
    $hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
    $manifestPath = Join-Path $EvidencePath "manifest.json"
    $manifest = @()
    if (Test-Path $manifestPath) { $manifest = @(Get-Content $manifestPath | ConvertFrom-Json) }
    $manifest += [PSCustomObject]@{
        file          = (Split-Path $jsonPath -Leaf)
        sha256        = $hash
        bytes         = (Get-Item $jsonPath).Length
        generated_utc = $ts
        control       = '4.3'
    }
    $manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
    return $jsonPath
}

Create Retention Policy for Agent Knowledge Sources

[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
    [Parameter(Mandatory)] [string[]]$KnowledgeSiteUrls,
    [string]$EvidencePath = ".\evidence\4.3"
)

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
Start-Transcript -Path "$EvidencePath\transcript-$ts.log" -IncludeInvocationHeader

$PolicyName = "FSI-Agent-Knowledge-Retention-7Years"

# Snapshot existing policy state (if any) before mutation
$before = Get-RetentionCompliancePolicy -Identity $PolicyName -ErrorAction SilentlyContinue
Write-FsiEvidence -Object $before -Name "policy-before-$PolicyName" -EvidencePath $EvidencePath | Out-Null

if ($PSCmdlet.ShouldProcess($PolicyName, "Create or update retention policy for agent knowledge sources")) {
    if (-not $before) {
        New-RetentionCompliancePolicy -Name $PolicyName `
            -Comment "Retention policy for agent knowledge sources per FINRA 4511 and SEC 17a-4 (Control 4.3)" `
            -SharePointLocation $KnowledgeSiteUrls `
            -Enabled $true | Out-Null
    } else {
        Set-RetentionCompliancePolicy -Identity $PolicyName -AddSharePointLocation $KnowledgeSiteUrls | Out-Null
    }

    # 7-year retain-and-delete from last modification
    $existingRule = Get-RetentionComplianceRule -Policy $PolicyName -ErrorAction SilentlyContinue
    if (-not $existingRule) {
        New-RetentionComplianceRule -Name "FSI-7Year-Retention-Rule" `
            -Policy $PolicyName `
            -RetentionDuration 2555 `
            -RetentionDurationDisplayHint Days `
            -RetentionComplianceAction KeepAndDelete `
            -ExpirationDateOption ModificationAgeInDays | Out-Null
    }
}

$after = Get-RetentionCompliancePolicy -Identity $PolicyName
Write-FsiEvidence -Object $after -Name "policy-after-$PolicyName" -EvidencePath $EvidencePath | Out-Null

Stop-Transcript

Always invoke first with -WhatIf before running for real. -SharePointLocation does not support wildcards — pass explicit URLs or use -SharePointLocation All to scope to every SharePoint site.


Create Zone-Specific Retention Policies

$Zones = @(
    @{Name='Zone1-Personal';    Duration=365;  Sites=@('https://contoso.sharepoint.com/sites/Zone1-PersonalAgents')},
    @{Name='Zone2-Team';        Duration=1825; Sites=@('https://contoso.sharepoint.com/sites/Zone2-TeamAgents')},
    @{Name='Zone3-Enterprise';  Duration=2555; Sites=@('https://contoso.sharepoint.com/sites/Zone3-EnterpriseAgents')}
)

foreach ($Zone in $Zones) {
    $name = "FSI-$($Zone.Name)-Retention"
    if ($PSCmdlet.ShouldProcess($name, "Create zone retention policy")) {
        New-RetentionCompliancePolicy -Name $name -SharePointLocation $Zone.Sites -Enabled $true | Out-Null
        New-RetentionComplianceRule -Name "FSI-$($Zone.Name)-Rule" `
            -Policy $name `
            -RetentionDuration $Zone.Duration `
            -RetentionDurationDisplayHint Days `
            -RetentionComplianceAction KeepAndDelete | Out-Null
    }
}

Create Retention Labels

$Labels = @(
    @{ Name='FSI-Communications-3Y';      Duration=1095; Action='KeepAndDelete'; Description='3-year retention for communications (SEC 17a-4(b)(4))' },
    @{ Name='FSI-Financial-Records-6Y';   Duration=2190; Action='KeepAndDelete'; Description='6-year retention for financial/accounting records (SEC 17a-4(a) / FINRA 4511)' },
    @{ Name='FSI-Audit-Workpapers-7Y';    Duration=2555; Action='KeepAndDelete'; Description='7-year retention for audit workpapers (SOX 802 / SEC Reg S-X Rule 2-06)' },
    @{ Name='FSI-Customer-Data-5Y';       Duration=1825; Action='KeepAndDelete'; Description='5-year retention for customer information (industry practice; GLBA does not mandate a specific period)' },
    @{ Name='FSI-Regulatory-Record-7Y';   Duration=2555; Action='Keep';          Description='7-year regulatory record (immutable retain-only)' }
)

foreach ($l in $Labels) {
    if (-not (Get-ComplianceTag -Identity $l.Name -ErrorAction SilentlyContinue)) {
        if ($PSCmdlet.ShouldProcess($l.Name, "Create retention label")) {
            New-ComplianceTag -Name $l.Name `
                -Comment $l.Description `
                -RetentionDuration $l.Duration `
                -RetentionAction $l.Action `
                -RetentionType ModificationAgeInDays `
                -IsRecordLabel $false | Out-Null
        }
    }
}

To declare a regulatory record (immutable for end users and admins), use -IsRecordLabel $true -Regulatory $true. This is one-way and requires Records Management; coordinate with Legal first.


Apply Preservation Lock to a Regulatory Policy

Preservation Lock satisfies SEC 17a-4(f)-style requirements that the retention configuration cannot be disabled, shortened, or removed. It is irreversible.

[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([Parameter(Mandatory)][string]$PolicyName, [string]$EvidencePath = ".\evidence\4.3")

$before = Get-RetentionCompliancePolicy -Identity $PolicyName
Write-FsiEvidence -Object $before -Name "lock-before-$PolicyName" -EvidencePath $EvidencePath | Out-Null

if ($PSCmdlet.ShouldProcess($PolicyName, "IRREVERSIBLE: enable Preservation Lock")) {
    Set-RetentionCompliancePolicy -Identity $PolicyName -RestrictiveRetention $true
}

$after = Get-RetentionCompliancePolicy -Identity $PolicyName
Write-FsiEvidence -Object $after -Name "lock-after-$PolicyName" -EvidencePath $EvidencePath | Out-Null

Coverage Report — Sites With and Without Retention

Connect-SPOService -Url $SpoAdminUrl  # if not already connected

$RetentionPolicies = Get-RetentionCompliancePolicy | Where-Object { $_.SharePointLocation }

$SiteRetention = foreach ($Policy in $RetentionPolicies) {
    $Rule = Get-RetentionComplianceRule -Policy $Policy.Name
    foreach ($Site in $Policy.SharePointLocation) {
        [PSCustomObject]@{
            SiteUrl           = $Site
            PolicyName        = $Policy.Name
            RetentionDuration = $Rule.RetentionDuration
            Action            = $Rule.RetentionComplianceAction
            Locked            = $Policy.RestrictiveRetention
            PolicyEnabled     = $Policy.Enabled
        }
    }
}

$AllSites             = Get-SPOSite -Limit All
$CoveredUrls          = $SiteRetention.SiteUrl | Select-Object -Unique
$SitesWithoutCoverage = $AllSites | Where-Object { $_.Url -notin $CoveredUrls -and $CoveredUrls -notcontains 'All' }

Write-FsiEvidence -Object $SiteRetention         -Name 'site-retention-mapping' -EvidencePath $EvidencePath | Out-Null
Write-FsiEvidence -Object $SitesWithoutCoverage  -Name 'sites-without-coverage' -EvidencePath $EvidencePath | Out-Null

Write-Host "Sites without retention coverage: $($SitesWithoutCoverage.Count)" -ForegroundColor Yellow

SitesWithoutCoverage is the gap list — every site here that is also a Copilot/agent knowledge source (per Control 4.1) is a finding.


Cleanup

Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
Disconnect-SPOService -ErrorAction SilentlyContinue

Back to Control 4.3 | Portal Walkthrough | Verification Testing | Troubleshooting


Updated: April 2026 | Version: v1.4.0