Control 4.6 – PowerShell Setup: Grounding Scope Governance
DANGER — Mutation safety required. This playbook configures tenant-wide search behaviour (Restricted SharePoint Search), per-site discoverability (Restricted Content Discoverability), and Power Platform DLP for the SharePoint connector consumed by Copilot Studio knowledge sources. A misconfiguration can either (a) hide legitimate content from Microsoft 365 Copilot and grounded agents or (b) expose oversharing-risk content to agent grounding. Every mutating script in this suite uses
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]. Always run with-WhatIffirst, capture the transcript, review the before-snapshot, and only then re-run with-Confirm:$falseinside a change window.
Control: 4.6 – Grounding Scope Governance
Pillar: Pillar 4 — SharePoint
Audience: SharePoint Admin, Power Platform Admin, Compliance/Records Admin (read-only DAG)
Prerequisites: Read _shared/powershell-baseline.md first — module pinning, edition guards, sovereign endpoints, and the Write-FsiEvidence SHA-256 helper used throughout this playbook are defined there.
This playbook delivers the six-script PowerShell suite that supports Control 4.6 by:
- Inventorying current grounding-relevant configuration (Data Access Governance reports, RSS mode, RCD per-site flags, DLP policies covering the SharePoint connector).
- Curating and applying the Restricted SharePoint Search (RSS) allow-list (≤100 sites cap, hub-aware).
- Applying Restricted Content Discoverability (RCD) per site plus optional delegation.
- Applying a Power Platform DLP policy that classifies the SharePoint connector consumed by Copilot Studio.
- Reconciling intended vs effective state (Get-after-Set on real properties).
- Emitting tamper-evident SHA-256 evidence manifests for audit (supports SEC 17a-4 / FINRA 4511 / SOX 404 record-keeping; this playbook does not by itself satisfy any regulation).
Section 0 — Wrong-shell trap: session/edition matrix
Each module in this suite has a different PowerShell edition requirement. Running the wrong cmdlet in the wrong shell silently fails or returns stale data. Use a dedicated session per module.
| # | Module | Required edition | Connect cmdlet (commercial) | Notes |
|---|---|---|---|---|
| 1 | Microsoft.Online.SharePoint.PowerShell (SPO Mgmt Shell) |
Windows PowerShell 5.1 preferred. PowerShell 7.x requires Import-Module Microsoft.Online.SharePoint.PowerShell -UseWindowsPowerShell. |
Connect-SPOService -Url https://<tenant>-admin.sharepoint.com |
Owns: Set-SPOTenantRestrictedSearchMode, *-SPOTenantRestrictedSearchAllowedList, Set-SPOSite -RestrictContentOrgWideSearch, Get-SPODataAccessGovernanceInsight, Start-SPORestrictedContentDiscoverabilityReport. |
| 2 | PnP.PowerShell v2+ |
PowerShell 7.2+ Core only. Will not load on 5.1. | Connect-PnPOnline -Url https://<tenant>.sharepoint.com -ClientId <Entra-app-GUID> -Interactive |
v2 mandates -ClientId (Entra app registration). v1.x is end-of-life. Used here only for site enumeration helpers. |
| 3 | Microsoft.PowerApps.Administration.PowerShell |
Windows PowerShell 5.1 (Desktop) ONLY. Silent failures under PowerShell 7. | Add-PowerAppsAccount -Endpoint prod |
Owns: New-DlpPolicy, Get-DlpPolicy, Set-DlpPolicy, Get-AdminPowerAppConnector. |
| 4 | ExchangeOnlineManagement (Unified Audit Log) |
PowerShell 5.1 or 7.x both supported. | Connect-ExchangeOnline |
Owns: Search-UnifiedAuditLog used by Audit-GroundingChanges.ps1. |
| 5 | Microsoft.Graph (optional, for site lookups by Group ID) |
PowerShell 7.x preferred. | Connect-MgGraph -Scopes Sites.Read.All |
Optional — only used in §3 if you need to resolve M365 Group sites. |
Rule: open two PowerShell windows for this playbook — one Windows PowerShell 5.1 (rows 1, 3, 4) and one PowerShell 7 (rows 2, 5 if used). Pin module versions per _shared/powershell-baseline.md §1.
Section 1 — Initialize-Agt46Session pre-flight
Run this dot-sourced helper at the top of every session before any of the numbered scripts. It enforces edition guards, sovereign endpoint resolution, output folder layout, transcript start, and module version pinning.
# Initialize-Agt46Session.ps1
[CmdletBinding()]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD','China')]
[string]$Cloud = 'Commercial',
[Parameter(Mandatory)]
[string]$TenantName, # e.g. 'contoso' (no .onmicrosoft.com)
[Parameter(Mandatory)]
[string]$EntraAppClientId, # Entra app registration GUID for PnP v2
[string]$OutputRoot = "$PSScriptRoot\..\output\4.6",
[ValidateSet('SPO','PowerApps','Exchange','Graph','All')]
[string[]]$Modules = @('SPO')
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# --- Edition guards ---------------------------------------------------------
$psMajor = $PSVersionTable.PSVersion.Major
$psEdition = $PSVersionTable.PSEdition
if ($Modules -contains 'PowerApps' -or $Modules -contains 'All') {
if ($psEdition -ne 'Desktop') {
throw "Microsoft.PowerApps.Administration.PowerShell requires Windows PowerShell 5.1 (Desktop). Current: $psEdition $psMajor. Open a Windows PowerShell 5.1 console and re-run."
}
}
if ($Modules -contains 'PnP') {
if ($psMajor -lt 7) {
throw "PnP.PowerShell v2+ requires PowerShell 7.2+. Current: $psEdition $psMajor."
}
}
# --- Sovereign endpoint matrix ---------------------------------------------
$endpoints = @{
Commercial = @{ SPOAdmin='https://{0}-admin.sharepoint.com'; SPORegion=$null; PnPEnv='Production'; PowerAppsEndpoint='prod' }
GCC = @{ SPOAdmin='https://{0}-admin.sharepoint.com'; SPORegion=$null; PnPEnv='USGovernment'; PowerAppsEndpoint='usgov' }
GCCHigh = @{ SPOAdmin='https://{0}-admin.sharepoint.us'; SPORegion='ITAR'; PnPEnv='USGovernmentHigh'; PowerAppsEndpoint='usgovhigh' }
DoD = @{ SPOAdmin='https://{0}-admin.dps.mil'; SPORegion='ITAR'; PnPEnv='USGovernmentDoD'; PowerAppsEndpoint='dod' }
China = @{ SPOAdmin='https://{0}-admin.sharepoint.cn'; SPORegion=$null; PnPEnv='China'; PowerAppsEndpoint='china' }
}
$ep = $endpoints[$Cloud]
$adminUrl = $ep.SPOAdmin -f $TenantName
# --- Output folder + transcript --------------------------------------------
$runId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + ([guid]::NewGuid().ToString('N').Substring(0,8))
$runDir = Join-Path $OutputRoot $runId
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
$transcript = Join-Path $runDir 'transcript.log'
Start-Transcript -Path $transcript -IncludeInvocationHeader | Out-Null
# --- Module version pinning -------------------------------------------------
$pins = @{
'Microsoft.Online.SharePoint.PowerShell' = '16.0.25212.12000'
'PnP.PowerShell' = '2.12.0'
'Microsoft.PowerApps.Administration.PowerShell' = '2.0.198'
'ExchangeOnlineManagement' = '3.5.1'
'Microsoft.Graph.Authentication' = '2.19.0'
'Microsoft.Graph.Sites' = '2.19.0'
}
foreach ($m in $pins.Keys) {
if (Get-Module -ListAvailable -Name $m | Where-Object Version -eq $pins[$m]) {
Write-Verbose "Module $m@$($pins[$m]) present."
} else {
Write-Warning "Module $m@$($pins[$m]) not installed at pinned version. Install via: Install-Module $m -RequiredVersion $($pins[$m]) -Scope CurrentUser"
}
}
# --- Connect ---------------------------------------------------------------
if ($Modules -contains 'SPO' -or $Modules -contains 'All') {
Import-Module Microsoft.Online.SharePoint.PowerShell -DisableNameChecking -ErrorAction Stop
if ($ep.SPORegion) {
Connect-SPOService -Url $adminUrl -Region $ep.SPORegion
} else {
Connect-SPOService -Url $adminUrl
}
}
if ($Modules -contains 'PowerApps' -or $Modules -contains 'All') {
Import-Module Microsoft.PowerApps.Administration.PowerShell -ErrorAction Stop
Add-PowerAppsAccount -Endpoint $ep.PowerAppsEndpoint | Out-Null
}
if ($Modules -contains 'Exchange' -or $Modules -contains 'All') {
Import-Module ExchangeOnlineManagement -ErrorAction Stop
Connect-ExchangeOnline -ShowBanner:$false
}
# --- Return session context ------------------------------------------------
[pscustomobject]@{
Cloud = $Cloud
TenantName = $TenantName
AdminUrl = $adminUrl
SPORegion = $ep.SPORegion
PnPEnv = $ep.PnPEnv
PAEndpoint = $ep.PowerAppsEndpoint
EntraAppId = $EntraAppClientId
RunId = $runId
RunDir = $runDir
Transcript = $transcript
StartedUtc = (Get-Date).ToUniversalTime()
}
Usage:
$ctx = .\Initialize-Agt46Session.ps1 `
-Cloud GCCHigh `
-TenantName contoso `
-EntraAppClientId 'a1b2c3d4-...' `
-Modules SPO,Exchange
$ctx is then passed to every numbered script. The $ctx.RunDir folder is the only location any script writes to.
Section 2 — Scope boundary table: RCD vs RSS vs RAC vs DAG
Operators routinely conflate these four mechanisms. Choose the wrong one and you either over-restrict (breaking legitimate Copilot grounding) or under-restrict (leaking oversharing-risk content into agent answers).
| Mechanism | Scope | Effect on Copilot grounding | Cmdlet surface | When to use |
|---|---|---|---|---|
| RCD — Restricted Content Discoverability | Per site | Site contents excluded from org-wide search results, including Copilot grounding. Direct-link access still works. | Set-SPOSite -Identity <url> -RestrictContentOrgWideSearch $true; Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $true |
Surgical: a small number of sensitive sites you want excluded from Copilot/agent answers. |
| RSS — Restricted SharePoint Search | Tenant, with allow-list ≤100 sites | When Enabled: Copilot grounding restricted to sites on the allow-list plus OneDrive/email/chat. Default search UI restricted to user's frequent sites. | Set-SPOTenantRestrictedSearchMode -Mode Enabled\|Disabled; Add/Remove/Get-SPOTenantRestrictedSearchAllowedList -SitesList |
Bridging control during Copilot rollout while you remediate oversharing org-wide. |
| RAC — Restricted Access Control | Per site | Hard access policy: only members of a specified Entra group/Team can access the site at all. Stronger than RCD. | Set-SPOSite -Identity <url> -RestrictedAccessControl $true; Set-SPOTenantRestrictedAccessControlPolicy ... |
Confidential sites where even direct-link access must be blocked for non-members. |
| DAG — Data Access Governance | Reporting only (read-only) | No mutation. Surfaces oversharing-risk sites (everyone-except-external-users sharing, anonymous links, etc.) so you can decide whether to apply RCD/RSS/RAC. | Get-SPODataAccessGovernanceInsight -ReportEntity <enum>; Start-SPORestrictedContentDiscoverabilityReport |
Continuous monitoring; feeds the inventory in §3. |
Decision rule for this playbook: start with DAG (§3 inventory) → choose RSS allow-list strategy (§4) → apply RCD to anything that must be excluded but is not a candidate for the RSS allow-list (§5) → wrap the SharePoint connector to Copilot Studio with DLP (§6).
Section 3 — 1-Inventory.ps1 (DAG report extraction + baseline)
Read-only. Produces a baseline JSON snapshot of grounding-relevant configuration before any mutation.
# 1-Inventory.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Ctx,
# ReportEntity values per Get-SPODataAccessGovernanceInsight enum
[ValidateSet(
'Everyone','EveryoneExceptExternalUsers','EveryoneExceptExternalUsersAtSite',
'EveryoneExceptExternalUsersForItems','PermissionedUsers','PermissionsReport',
'SensitivityLabelForFiles','SharingLinks_Anyone','SharingLinks_Guests',
'SharingLinks_PeopleInYourOrg'
)]
[string[]]$DagReports = @(
'SharingLinks_Anyone',
'SharingLinks_PeopleInYourOrg',
'EveryoneExceptExternalUsersAtSite',
'SensitivityLabelForFiles'
)
)
. "$PSScriptRoot\Write-FsiEvidence.ps1" # from _shared/powershell-baseline.md §5
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
Write-Host "[1-Inventory] Run $($Ctx.RunId) -> $out"
# --- 1. RSS tenant mode ---------------------------------------------------
$rssMode = Get-SPOTenantRestrictedSearchMode # returns 'Enabled' or 'Disabled' string
$rssMode | ConvertTo-Json | Out-File (Join-Path $out 'rss-mode.json')
# --- 2. RSS allow-list ----------------------------------------------------
$rssAllow = Get-SPOTenantRestrictedSearchAllowedList
$rssAllow | ConvertTo-Json -Depth 4 | Out-File (Join-Path $out 'rss-allowlist.json')
# --- 3. RCD per-site flags (sample by enumerating all sites) -------------
# Note: Get-SPOSite is paged; for very large tenants restrict via -Filter or -Template.
$sites = Get-SPOSite -Limit All -IncludePersonalSite:$false |
Select-Object Url, Title, Template, Owner, StorageUsageCurrent,
RestrictContentOrgWideSearch
$sites | Export-Csv (Join-Path $out 'sites-baseline.csv') -NoTypeInformation -Encoding UTF8
($sites | Where-Object RestrictContentOrgWideSearch) |
Export-Csv (Join-Path $out 'sites-rcd-on.csv') -NoTypeInformation -Encoding UTF8
# --- 4. RCD delegation flag ----------------------------------------------
$tenant = Get-SPOTenant
$tenant | Select-Object DelegateRestrictedContentDiscoverabilityManagement,
SearchResolveExactEmailOrUPN |
ConvertTo-Json | Out-File (Join-Path $out 'rcd-delegation.json')
# --- 5. DAG insights ------------------------------------------------------
foreach ($rpt in $DagReports) {
Write-Host " DAG: $rpt"
try {
$insights = Get-SPODataAccessGovernanceInsight -ReportEntity $rpt -Workload SharePoint -ReportType Snapshot
$insights | Export-Csv (Join-Path $out "dag-$rpt.csv") -NoTypeInformation -Encoding UTF8
} catch {
Write-Warning "DAG report $rpt failed: $($_.Exception.Message). (DAG is unavailable in some sovereign clouds — see §11.)"
}
}
# --- 6. SHA-256 evidence manifest ----------------------------------------
Write-FsiEvidence -RunDir $out -ScriptName '1-Inventory.ps1' -Context $Ctx
Outputs (all in $Ctx.RunDir):
rss-mode.json,rss-allowlist.jsonsites-baseline.csv,sites-rcd-on.csvrcd-delegation.jsondag-<entity>.csv(one per DAG report)evidence-manifest.json(SHA-256 hashes of every file above)
Section 4 — 2-Curate-RSSAllowList.ps1 (≤100-site cap, hub-aware)
Builds the RSS allow-list from a curated CSV. Enforces the 100-site cap (hub-associated sites do not count against the cap — only the hub itself does). Mutating; runs -WhatIf by default.
# 2-Curate-RSSAllowList.ps1
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] $Ctx,
# CSV with columns: Url,Justification,DataOwner,SoDApprover,Category
[Parameter(Mandatory)]
[string]$AllowListCsvPath,
# If set, also enable RSS tenant mode after applying the list.
[switch]$EnableRssMode
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
# --- Validate CSV ---------------------------------------------------------
$rows = Import-Csv $AllowListCsvPath
$required = 'Url','Justification','DataOwner','SoDApprover','Category'
foreach ($col in $required) {
if (-not ($rows | Get-Member -Name $col -MemberType NoteProperty)) {
throw "AllowList CSV missing required column: $col"
}
}
foreach ($r in $rows) {
foreach ($col in $required) {
if ([string]::IsNullOrWhiteSpace($r.$col)) {
throw "Row for $($r.Url): empty value in column $col."
}
}
if ($r.DataOwner -ieq $r.SoDApprover) {
throw "SoD violation row for $($r.Url): DataOwner and SoDApprover cannot be the same person."
}
}
# --- Hub-aware count ------------------------------------------------------
$intended = $rows.Url | Sort-Object -Unique
Write-Host "[2-Curate] Intended sites: $($intended.Count)"
# Resolve hub status: hub-associated sites do NOT count, but the hub does.
$hubMap = @{}
foreach ($u in $intended) {
try {
$s = Get-SPOSite -Identity $u -Detailed
$hubMap[$u] = [pscustomobject]@{
Url = $u
HubSiteId = $s.HubSiteId
IsHub = ($s.IsHubSite -eq $true)
}
} catch {
Write-Warning "Cannot resolve $u (skipped from cap calculation): $($_.Exception.Message)"
}
}
$countsTowardCap = @($hubMap.Values | Where-Object {
$_.IsHub -or [string]::IsNullOrEmpty($_.HubSiteId) -or $_.HubSiteId -eq '00000000-0000-0000-0000-000000000000'
}).Count
Write-Host "[2-Curate] Sites counted against 100-cap: $countsTowardCap (hub-associated sites do not count)."
if ($countsTowardCap -gt 100) {
throw "Allow-list exceeds RSS 100-site cap ($countsTowardCap). Consolidate via hub association."
}
# --- Snapshot before -----------------------------------------------------
$before = Get-SPOTenantRestrictedSearchAllowedList
$before | ConvertTo-Json -Depth 4 | Out-File (Join-Path $out 'rss-allowlist-before.json')
$rows | Export-Csv (Join-Path $out 'rss-allowlist-intended.csv') -NoTypeInformation -Encoding UTF8
# --- Apply ---------------------------------------------------------------
if ($PSCmdlet.ShouldProcess("RSS allow-list ($($intended.Count) sites)", "Add-SPOTenantRestrictedSearchAllowedList")) {
Add-SPOTenantRestrictedSearchAllowedList -SitesList $intended
}
if ($EnableRssMode -and $PSCmdlet.ShouldProcess("Tenant", "Set-SPOTenantRestrictedSearchMode -Mode Enabled")) {
Set-SPOTenantRestrictedSearchMode -Mode Enabled
}
# --- Snapshot after ------------------------------------------------------
$after = Get-SPOTenantRestrictedSearchAllowedList
$after | ConvertTo-Json -Depth 4 | Out-File (Join-Path $out 'rss-allowlist-after.json')
Write-FsiEvidence -RunDir $out -ScriptName '2-Curate-RSSAllowList.ps1' -Context $Ctx
Notes:
- Always rehearse with
-WhatIf(the defaultConfirmImpact='High'plus omitting-Confirm:$falsegives an interactive prompt). - The CSV must include
DataOwnerandSoDApproveras distinct identities — enforces segregation-of-duties for the allow-list (supports Control 1.5 evidence). -SitesListFileUrl <localCsvPath> [-ContainsHeader $true]is also supported by the underlying cmdlet if you prefer file-based input — see Microsoft Learn forAdd-SPOTenantRestrictedSearchAllowedList.
Section 5 — 3-Apply-RCD.ps1 (per-site + delegation)
Applies Restricted Content Discoverability per site from a curated CSV; optionally toggles delegation so site collection admins can self-manage RCD.
# 3-Apply-RCD.ps1
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] $Ctx,
# CSV columns: Url,Action(set|unset),Justification,DataOwner,SoDApprover
[Parameter(Mandatory)] [string]$RcdCsvPath,
[ValidateSet('Enable','Disable','NoChange')]
[string]$Delegation = 'NoChange'
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
$rows = Import-Csv $RcdCsvPath
foreach ($r in $rows) {
if ([string]::IsNullOrWhiteSpace($r.Url) -or $r.Action -notin 'set','unset') {
throw "Invalid row: $($r | ConvertTo-Json -Compress)"
}
if ($r.DataOwner -ieq $r.SoDApprover) {
throw "SoD violation for $($r.Url)."
}
}
# Snapshot before
$urls = $rows.Url | Sort-Object -Unique
$before = foreach ($u in $urls) {
try { Get-SPOSite -Identity $u | Select Url, RestrictContentOrgWideSearch }
catch { [pscustomobject]@{ Url=$u; RestrictContentOrgWideSearch='UNREADABLE' } }
}
$before | Export-Csv (Join-Path $out 'rcd-sites-before.csv') -NoTypeInformation -Encoding UTF8
# Apply per site
foreach ($r in $rows) {
$desired = ($r.Action -eq 'set')
if ($PSCmdlet.ShouldProcess($r.Url, "Set-SPOSite -RestrictContentOrgWideSearch `$$desired")) {
try {
Set-SPOSite -Identity $r.Url -RestrictContentOrgWideSearch $desired
} catch {
Write-Warning "Failed $($r.Url): $($_.Exception.Message)"
}
}
}
# Optional delegation toggle
switch ($Delegation) {
'Enable' { if ($PSCmdlet.ShouldProcess('Tenant','Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $true')) { Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $true } }
'Disable' { if ($PSCmdlet.ShouldProcess('Tenant','Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $false')) { Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $false } }
}
# Snapshot after
$after = foreach ($u in $urls) {
try { Get-SPOSite -Identity $u | Select Url, RestrictContentOrgWideSearch }
catch { [pscustomobject]@{ Url=$u; RestrictContentOrgWideSearch='UNREADABLE' } }
}
$after | Export-Csv (Join-Path $out 'rcd-sites-after.csv') -NoTypeInformation -Encoding UTF8
Write-FsiEvidence -RunDir $out -ScriptName '3-Apply-RCD.ps1' -Context $Ctx
Section 6 — 4-Apply-SPConnectorDLP.ps1 (Power Platform DLP for SharePoint connector)
Wraps the SharePoint connector consumed by Copilot Studio knowledge sources in a Power Platform DLP policy. Connector ID is a parameter — do not hardcode. Run in Windows PowerShell 5.1 only.
6.1 — Discover the connector ID first (one-time)
The SharePoint connector's invariant Name (e.g. shared_sharepointonline) is documented but should always be verified in your tenant because some sovereign clouds expose connector variants with different names. PPAC also shows it under Data → Connectors.
# Run once to confirm the connector ID for this tenant + cloud.
Get-AdminPowerAppConnector |
Where-Object { $_.DisplayName -match 'SharePoint' -or $_.DisplayName -match 'Copilot' } |
Select-Object DisplayName, Name, Publisher | Format-Table -AutoSize
Verify the result against PPAC (https://admin.powerplatform.microsoft.com → Data → Connectors) before passing to the script below.
6.2 — Apply policy
# 4-Apply-SPConnectorDLP.ps1
#requires -PSEdition Desktop # Power Platform admin module is Desktop-only.
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] $Ctx,
[Parameter(Mandatory)] [string]$PolicyDisplayName, # e.g. 'FSI-4.6-CopilotKnowledgeSources'
# Verified via discovery snippet in §6.1.
[Parameter(Mandatory)] [string]$SharePointConnectorId, # typically 'shared_sharepointonline'
[ValidateSet('AllEnvironments','OnlyEnvironments','ExceptEnvironments')]
[string]$EnvironmentType = 'OnlyEnvironments',
[string[]]$EnvironmentIds = @(), # Required when scope != AllEnvironments
[ValidateSet('Confidential','General','Blocked')]
[string]$SharePointClassification = 'Confidential',
[ValidateSet('Confidential','General','Blocked')]
[string]$DefaultClassification = 'General'
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
# --- Snapshot before -----------------------------------------------------
$before = Get-DlpPolicy
$before | ConvertTo-Json -Depth 6 | Out-File (Join-Path $out 'dlp-before.json')
# --- Create policy --------------------------------------------------------
if ($PSCmdlet.ShouldProcess($PolicyDisplayName, "New-DlpPolicy -EnvironmentType $EnvironmentType")) {
$newArgs = @{
DisplayName = $PolicyDisplayName
EnvironmentType = $EnvironmentType
DefaultConnectorClassification = $DefaultClassification
}
if ($EnvironmentType -ne 'AllEnvironments') {
if (-not $EnvironmentIds) { throw "EnvironmentIds required when EnvironmentType=$EnvironmentType." }
$newArgs.Environments = $EnvironmentIds | ForEach-Object {
@{ id = $_ ; name = $_ ; type = 'Microsoft.PowerApps/scopes/environments' }
}
}
$policy = New-DlpPolicy @newArgs
}
# --- Move SharePoint connector into the chosen group ---------------------
if ($PSCmdlet.ShouldProcess($SharePointConnectorId, "Add-CustomConnectorToPolicy -GroupName $SharePointClassification")) {
Add-ConnectorToBusinessDataGroup -PolicyName $policy.PolicyName -ConnectorName $SharePointConnectorId
# Note: `Add-ConnectorToBusinessDataGroup` places the connector in the Confidential group;
# if your target classification is General/Blocked, use the appropriate move cmdlet:
# `Remove-ConnectorFromBusinessDataGroup` then `Add-ConnectorToNonBusinessDataGroup` / `Add-ConnectorToBlockedList`.
}
# --- Snapshot after ------------------------------------------------------
$after = Get-DlpPolicy
$after | ConvertTo-Json -Depth 6 | Out-File (Join-Path $out 'dlp-after.json')
Write-FsiEvidence -RunDir $out -ScriptName '4-Apply-SPConnectorDLP.ps1' -Context $Ctx
Why parameterise the connector ID: the invariant name has historically changed during connector renames; sovereign-cloud variants may differ. Hardcoding causes silent drift between docs and reality. The §6.1 discovery snippet is the source of truth for your tenant.
Section 7 — 5-Reconcile.ps1 (Get-after-Set on real properties)
Verifies intended state against effective state. Emits [OK], [WARN], [ALERT] lines and a JSON reconciliation report. Read-only — no SupportsShouldProcess.
# 5-Reconcile.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Ctx,
[Parameter(Mandatory)] [string]$IntendedRssAllowListCsv,
[Parameter(Mandatory)] [string]$IntendedRcdCsv,
[string]$IntendedDlpPolicyName,
[string]$IntendedSharePointConnectorId
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
$report = [System.Collections.Generic.List[object]]::new()
function Add-Finding($Severity, $Area, $Detail) {
$line = "[$Severity] $Area :: $Detail"
Write-Host $line
$report.Add([pscustomobject]@{ Severity=$Severity; Area=$Area; Detail=$Detail; TsUtc=(Get-Date).ToUniversalTime() })
}
# --- 1. RSS mode ---------------------------------------------------------
$mode = Get-SPOTenantRestrictedSearchMode
if ($mode -eq 'Enabled') { Add-Finding 'OK' 'RSS-Mode' "RSS mode = Enabled" }
else { Add-Finding 'WARN' 'RSS-Mode' "RSS mode = $mode (expected Enabled if rollout complete)" }
# --- 2. RSS allow-list ---------------------------------------------------
$intendedRss = (Import-Csv $IntendedRssAllowListCsv).Url | Sort-Object -Unique
$effectiveRss = (Get-SPOTenantRestrictedSearchAllowedList).Sites | Sort-Object -Unique
$missing = $intendedRss | Where-Object { $_ -notin $effectiveRss }
$extra = $effectiveRss | Where-Object { $_ -notin $intendedRss }
foreach ($m in $missing) { Add-Finding 'ALERT' 'RSS-AllowList' "Intended site missing from allow-list: $m" }
foreach ($x in $extra) { Add-Finding 'WARN' 'RSS-AllowList' "Allow-list contains site not in intended CSV: $x" }
if (-not $missing -and -not $extra) { Add-Finding 'OK' 'RSS-AllowList' "Allow-list matches intended ($($intendedRss.Count) sites)" }
# --- 3. RCD per site -----------------------------------------------------
$rcdRows = Import-Csv $IntendedRcdCsv
foreach ($r in $rcdRows) {
$desired = ($r.Action -eq 'set')
try {
$effective = (Get-SPOSite -Identity $r.Url).RestrictContentOrgWideSearch
if ($effective -eq $desired) {
Add-Finding 'OK' 'RCD-Site' "$($r.Url) RestrictContentOrgWideSearch=$effective"
} else {
Add-Finding 'ALERT' 'RCD-Site' "$($r.Url) intended=$desired effective=$effective"
}
} catch {
Add-Finding 'ALERT' 'RCD-Site' "$($r.Url) UNREADABLE: $($_.Exception.Message)"
}
}
# --- 4. DLP policy --------------------------------------------------------
if ($IntendedDlpPolicyName) {
$pol = Get-DlpPolicy | Where-Object DisplayName -eq $IntendedDlpPolicyName
if (-not $pol) {
Add-Finding 'ALERT' 'DLP-Policy' "Policy '$IntendedDlpPolicyName' not found."
} else {
Add-Finding 'OK' 'DLP-Policy' "Policy '$IntendedDlpPolicyName' present (Name=$($pol.PolicyName))."
if ($IntendedSharePointConnectorId) {
$confidential = $pol.ConnectorGroups | Where-Object Classification -eq 'Confidential'
$hit = $confidential.Connectors | Where-Object Id -match [regex]::Escape($IntendedSharePointConnectorId)
if ($hit) { Add-Finding 'OK' 'DLP-Connector' "$IntendedSharePointConnectorId in Confidential group." }
else { Add-Finding 'ALERT' 'DLP-Connector' "$IntendedSharePointConnectorId NOT in Confidential group." }
}
}
}
$report | Export-Csv (Join-Path $out 'reconcile.csv') -NoTypeInformation -Encoding UTF8
$report | ConvertTo-Json -Depth 4 | Out-File (Join-Path $out 'reconcile.json')
Write-FsiEvidence -RunDir $out -ScriptName '5-Reconcile.ps1' -Context $Ctx
$alerts = ($report | Where-Object Severity -eq 'ALERT').Count
if ($alerts -gt 0) { exit 2 } else { exit 0 }
Non-zero exit (2) on any [ALERT] lets schedulers (e.g. Azure Automation, scheduled task) raise an incident.
Section 8 — Audit-GroundingChanges.ps1 (Unified Audit Log collector)
Pulls grounding-relevant audit records from the Unified Audit Log. Paged, with a 50,000-row safety ceiling per run to prevent runaway pulls.
# Audit-GroundingChanges.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Ctx,
[DateTime]$StartUtc = (Get-Date).ToUniversalTime().AddDays(-7),
[DateTime]$EndUtc = (Get-Date).ToUniversalTime(),
[int]$PageSize = 5000,
[int]$MaxRows = 50000
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
$ops = @(
'TenantRestrictedSearchModeUpdated',
'TenantRestrictedSearchAllowedListUpdated',
'SiteRestrictedContentDiscoverabilityUpdated',
'TenantDelegateRestrictedContentDiscoverabilityManagementUpdated',
'DlpPolicyCreated','DlpPolicyUpdated','DlpPolicyDeleted',
'CopilotInteraction'
)
$all = [System.Collections.Generic.List[object]]::new()
$sessionId = [guid]::NewGuid().ToString()
$page = 0
do {
$page++
$batch = Search-UnifiedAuditLog `
-StartDate $StartUtc -EndDate $EndUtc `
-Operations $ops `
-ResultSize $PageSize `
-SessionId $sessionId -SessionCommand ReturnLargeSet
if ($batch) { $all.AddRange($batch) }
Write-Host " page $page collected $($batch.Count) (total $($all.Count))"
if ($all.Count -ge $MaxRows) {
Write-Warning "Hit MaxRows ceiling ($MaxRows). Re-run with narrower window."
break
}
} while ($batch -and $batch.Count -eq $PageSize)
$all | Export-Csv (Join-Path $out 'audit-grounding.csv') -NoTypeInformation -Encoding UTF8
$all | ConvertTo-Json -Depth 6 | Out-File (Join-Path $out 'audit-grounding.json')
Write-FsiEvidence -RunDir $out -ScriptName 'Audit-GroundingChanges.ps1' -Context $Ctx
Notes:
Search-UnifiedAuditLogis rate-limited; large windows must be split.- Audit availability and retention depend on licence (E5 / Audit Premium) — see Control 1.7.
CopilotInteractionrecords grounding-source citations; correlate against RCD/RSS changes when investigating an incident (Control 1.7 + the AI Incident Response Playbook).
Section 9 — SHA-256 evidence manifest schema + validator
Every script in this suite calls Write-FsiEvidence (defined in _shared/powershell-baseline.md §5). The emitted manifest schema:
{
"schemaVersion": "1.0",
"control": "4.6",
"scriptName": "2-Curate-RSSAllowList.ps1",
"runId": "20260415-093212-7f3a9b21",
"runDir": "C:\\fsi\\agt-output\\4.6\\20260415-093212-7f3a9b21",
"tenantName": "contoso",
"cloud": "GCCHigh",
"startedUtc": "2026-04-15T09:32:12Z",
"completedUtc": "2026-04-15T09:34:48Z",
"operator": "DOMAIN\\jane.doe",
"files": [
{ "name": "rss-allowlist-before.json", "bytes": 18421, "sha256": "f3a1...e29c" },
{ "name": "rss-allowlist-intended.csv", "bytes": 12044, "sha256": "9b40...a715" },
{ "name": "rss-allowlist-after.json", "bytes": 18602, "sha256": "1c77...88e0" },
{ "name": "transcript.log", "bytes": 41108, "sha256": "ad0c...3392" }
]
}
Validator (run before any audit hand-over):
# Verify-EvidenceManifest.ps1
param([Parameter(Mandatory)][string]$ManifestPath)
$m = Get-Content $ManifestPath -Raw | ConvertFrom-Json
$root = Split-Path $ManifestPath -Parent
$bad = 0
foreach ($f in $m.files) {
$p = Join-Path $root $f.name
if (-not (Test-Path $p)) { Write-Warning "MISSING $($f.name)"; $bad++; continue }
$h = (Get-FileHash $p -Algorithm SHA256).Hash.ToLower()
if ($h -ne $f.sha256.ToLower()) { Write-Warning "HASH MISMATCH $($f.name) actual=$h"; $bad++ }
}
if ($bad -gt 0) { throw "$bad evidence file(s) failed verification." }
"OK: $($m.files.Count) files verified for run $($m.runId)."
Hand the manifest plus the file set to records management (supports SEC 17a-4 / FINRA 4511 evidentiary requirements; does not by itself satisfy any regulation — see Control 1.7 for the records-management governance wrap).
Section 10 — Rollback-Agt46.ps1 + §10a explicit disconnect
10. Rollback
# Rollback-Agt46.ps1
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] $Ctx,
[Parameter(Mandatory)] [string]$BeforeRunDir, # the $RunDir of a prior baseline run
[string]$DlpPolicyDisplayNameToRemove
)
. "$PSScriptRoot\Write-FsiEvidence.ps1"
$ErrorActionPreference = 'Stop'
$out = $Ctx.RunDir
# 1. RSS mode
$beforeMode = Get-Content (Join-Path $BeforeRunDir 'rss-mode.json') -Raw | ConvertFrom-Json
if ($PSCmdlet.ShouldProcess('Tenant',"Set-SPOTenantRestrictedSearchMode -Mode $beforeMode")) {
Set-SPOTenantRestrictedSearchMode -Mode $beforeMode
}
# 2. RSS allow-list (replace with prior contents)
$beforeAllow = Get-Content (Join-Path $BeforeRunDir 'rss-allowlist.json') -Raw | ConvertFrom-Json
$current = Get-SPOTenantRestrictedSearchAllowedList
if ($current.Sites) {
if ($PSCmdlet.ShouldProcess('AllowList','Remove-SPOTenantRestrictedSearchAllowedList -SitesList $current.Sites')) {
Remove-SPOTenantRestrictedSearchAllowedList -SitesList @($current.Sites)
}
}
if ($beforeAllow.Sites) {
if ($PSCmdlet.ShouldProcess('AllowList','Add-SPOTenantRestrictedSearchAllowedList -SitesList $beforeAllow.Sites')) {
Add-SPOTenantRestrictedSearchAllowedList -SitesList @($beforeAllow.Sites)
}
}
# 3. RCD per-site (restore from baseline CSV)
$beforeSites = Import-Csv (Join-Path $BeforeRunDir 'sites-baseline.csv')
foreach ($s in $beforeSites) {
$desired = [bool]::Parse($s.RestrictContentOrgWideSearch)
try {
$cur = (Get-SPOSite -Identity $s.Url).RestrictContentOrgWideSearch
if ($cur -ne $desired) {
if ($PSCmdlet.ShouldProcess($s.Url,"Set-SPOSite -RestrictContentOrgWideSearch $desired")) {
Set-SPOSite -Identity $s.Url -RestrictContentOrgWideSearch $desired
}
}
} catch { Write-Warning "Skip $($s.Url): $($_.Exception.Message)" }
}
# 4. DLP policy removal (optional)
if ($DlpPolicyDisplayNameToRemove) {
$pol = Get-DlpPolicy | Where-Object DisplayName -eq $DlpPolicyDisplayNameToRemove
if ($pol -and $PSCmdlet.ShouldProcess($pol.DisplayName,'Remove-DlpPolicy')) {
Remove-DlpPolicy -PolicyName $pol.PolicyName
}
}
Write-FsiEvidence -RunDir $out -ScriptName 'Rollback-Agt46.ps1' -Context $Ctx
10a. Explicit disconnect at end of session
Always disconnect — leaving stale tokens around is an audit finding under Control 1.14 (data minimisation):
try { Disconnect-SPOService } catch {}
try { Disconnect-PnPOnline } catch {}
try { Remove-PowerAppsAccount } catch {}
try { Disconnect-ExchangeOnline -Confirm:$false } catch {}
try { Disconnect-MgGraph } catch {}
Stop-Transcript | Out-Null
Section 11 — Sovereign cloud matrix
| Cloud | Connect-SPOService -Region |
Connect-PnPOnline -AzureEnvironment |
Add-PowerAppsAccount -Endpoint |
DAG availability | Notes |
|---|---|---|---|---|---|
| Commercial | (omit) | (default Production) |
prod |
Yes | Baseline. |
| GCC | (omit) | USGovernment |
usgov |
Yes | SPO admin host: <tenant>-admin.sharepoint.com. |
| GCC High | ITAR |
USGovernmentHigh |
usgovhigh |
Yes | SPO admin host: <tenant>-admin.sharepoint.us. Must specify -Region ITAR. |
| DoD | ITAR |
USGovernmentDoD |
dod |
Yes | SPO admin host: <tenant>-admin.dps.mil. Must specify -Region ITAR. |
| China (21Vianet) | (cloud-specific) | China |
china |
No (DAG unavailable per Microsoft Learn) | Replace DAG step in §3 with manual Sites-of-interest CSV; rest of suite functions. |
The Initialize-Agt46Session helper (§1) parameterises all of the above via -Cloud.
Section 12 — Anti-pattern catalog (read before running)
Every item in this list has been observed in field engagements. Each maps to a Write-Warning or hard throw somewhere in this suite.
Set-SPOTenant -EnableRestrictedSearchAllList— does not exist. The correct cmdlet isSet-SPOTenantRestrictedSearchMode -Mode Enabled|Disabled.Add-SPOTenantRestrictedSearchAllowedList -SiteUrl—-SiteUrlparameter does not exist. Use-SitesList @(...)or-SitesListFileUrl <path>.Add-SPOTenantRestrictedSearchAllowedListSites— wrong cmdlet name; the correct name isAdd-SPOTenantRestrictedSearchAllowedList(noSitessuffix).- Running
New-DlpPolicy/Get-AdminPowerAppConnectorin PowerShell 7 —Microsoft.PowerApps.Administration.PowerShellis Desktop-edition only and fails silently. Always use Windows PowerShell 5.1. - Omitting
-Region ITARon GCC High / DoD —Connect-SPOServicewill appear to succeed against the wrong region and return zero data. Always pass-Region ITARfor those clouds. - Hardcoding
shared_sharepointonline— connector invariant names have changed historically and differ across sovereign clouds. Discover (§6.1) and parameterise. - Inferring site
ContentCategoryfrom URL substrings (e.g. assuming/sites/legal-*is "Legal Confidential") — URL conventions drift; categorise from sensitivity labels and site metadata, not regex on URL. - Counting hub-associated sites against the 100-site RSS cap — they do not count; only the hub itself does. The §4 cap calculation reflects this.
- Rerunning
Add-SPOTenantRestrictedSearchAllowedListinstead ofRemove-then-Addwhen the intent is replacement —Addis additive, so the list grows past the cap. - Running mutating scripts without
-WhatIffirst — theConfirmImpact='High'prompts can be muscle-memoried away. Always rehearse with-WhatIf, review the before-snapshot, then re-run. - Using PnP.PowerShell v1.x — end of life. v2+ requires
-ClientId <Entra app GUID>; certificate or interactive auth. - Skipping the SHA-256 evidence manifest — hand-typed CSVs are not audit evidence. Always run
Verify-EvidenceManifest.ps1(§9) before hand-over. - Pulling Unified Audit Log without paging or a row ceiling —
Search-UnifiedAuditLogwill time out or paginate forever. Use-SessionId/-SessionCommand ReturnLargeSetand the 50,000-row ceiling in §8. - Treating DAG output as a remediation queue — DAG is informational. Each flagged site needs a human decision (RCD, RSS allow-list, RAC, sensitivity label, owner contact) — see Control 4.1.
- Forgetting to
Disconnect-SPOService/Remove-PowerAppsAccount/Disconnect-ExchangeOnline— stale tokens linger. Always run §10a at end of session. - Reading
Get-SPOTenantRestrictedSearchModeas a boolean — it returns the string'Enabled'or'Disabled', not$true/$false. Compare with-eq 'Enabled'. - Setting
RestrictContentOrgWideSearchto a string ('true') — the parameter is[bool]. Pass$true/$false, not strings. - Allowing the same identity to be DataOwner and SoDApprover — segregation-of-duties violation; the §4 / §5 scripts hard-throw on this.
Section 13 — Cross-links
| Asset | Why it matters here |
|---|---|
| Control 4.6 — Grounding Scope Governance | Parent control: scope, regulatory references, governance levels. |
| Control 4.1 — SharePoint IAG / Restricted Content Discovery | Upstream IAG capabilities feeding §3 inventory. |
| Control 4.8 — Item-level Permission Scanning for Agent Knowledge Sources | Item-level oversharing inside sites that are RSS-allowlisted. |
| Control 1.5 — DLP and Sensitivity Labels | Sensitivity-label inputs to DAG; DLP policy in §6 complements purview DLP. |
| Control 1.7 — Comprehensive Audit Logging and Compliance | UAL availability, retention, evidentiary handling for §8 + §9. |
| Control 1.14 — Data Minimization and Agent Scope Control | Disconnect/token hygiene (§10a); minimum-scope grounding philosophy. |
| Control 2.16 — RAG Source Integrity Validation | Downstream: validates that allow-listed sources remain trustworthy. |
| AI Incident Response Playbook | When 5-Reconcile.ps1 or §8 audit pulls reveal unauthorised changes. |
_shared/powershell-baseline.md |
Module pinning, edition guards, sovereign endpoints, Write-FsiEvidence helper used everywhere above. |
Updated: April 2026 | Version: v1.4.0