Skip to content

PowerShell Setup: Control 1.3 - SharePoint Content Governance and Permissions

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, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show abbreviated patterns; the baseline is authoritative.

Last Updated: July 2026 Modules Required: Microsoft.Online.SharePoint.PowerShell, PnP.PowerShell (v2+ — requires Entra app registration), Microsoft.Graph (Identity.Governance, Sites, Groups), Microsoft.Graph.Beta (Identity.SignIns — provides Get-MgBetaInformationProtectionPolicyLabel used in §4b), ExchangeOnlineManagement (only if pairing with retention) PowerShell Edition: PowerShell 7.2+ for PnP.PowerShell v2 and Microsoft.Graph. Microsoft.Online.SharePoint.PowerShell runs on both Desktop 5.1 and Core 7+.


0. Prerequisites and connection

# Pin versions per the FSI PowerShell baseline. Examples below; substitute CAB-approved versions.
Install-Module -Name Microsoft.Online.SharePoint.PowerShell -RequiredVersion '16.0.25515.12000' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name PnP.PowerShell                         -RequiredVersion '2.12.0'           -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name Microsoft.Graph                        -RequiredVersion '2.25.0'           -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name Microsoft.Graph.Beta                    -RequiredVersion '2.25.0'           -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense   # Get-MgBetaInformationProtectionPolicyLabel (Microsoft.Graph.Beta.Identity.SignIns)

# Commercial connection
$TenantName  = 'contoso'
$AdminUrl    = "https://$TenantName-admin.sharepoint.com"

Connect-SPOService -Url $AdminUrl                                  # interactive MFA in browser
# Group.ReadWrite.All: required to write container labels via Update-MgGroup (assignedLabels).
# InformationProtectionPolicy.Read: required to read published sensitivity labels (Get-MgBetaInformationProtectionPolicyLabel).
Connect-MgGraph -Scopes 'Sites.Read.All','Group.ReadWrite.All','InformationProtectionPolicy.Read','AccessReview.ReadWrite.All' -NoWelcome
# For PnP v2, pre-register an Entra app and consent the SharePoint scopes you intend to use:
# Connect-PnPOnline -Url $AdminUrl -ClientId $PnPClientId -Interactive

Microsoft Learn now calls for the latest SharePoint Online Management Shell when configuring Restricted Content Discovery. Keep the CAB-approved Microsoft.Online.SharePoint.PowerShell pin current enough to expose the RAC / RCD / Restricted Search cmdlets used below; if a cmdlet or parameter is missing, fail closed and use the SharePoint admin center until the module is updated.


1. Tenant-level sharing baseline (idempotent)

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [ValidateSet('Zone1','Zone2','Zone3')]
    [string]$Zone = 'Zone3'
)

$desired = switch ($Zone) {
    'Zone1' { @{ SharingCapability = 'ExternalUserSharingOnly';         RequireAcceptingAccountMatchInvitedAccount = $true; DefaultSharingLinkType = 'Internal'; DefaultLinkPermission = 'View'; FileAnonymousLinkType = 'View'; FolderAnonymousLinkType = 'View'; RequireAnonymousLinksExpireInDays = 30 } }
    'Zone2' { @{ SharingCapability = 'ExistingExternalUserSharingOnly'; RequireAcceptingAccountMatchInvitedAccount = $true; DefaultSharingLinkType = 'Internal'; DefaultLinkPermission = 'View'; FileAnonymousLinkType = 'View'; FolderAnonymousLinkType = 'View'; RequireAnonymousLinksExpireInDays = 7  } }
    'Zone3' { @{ SharingCapability = 'Disabled';                        RequireAcceptingAccountMatchInvitedAccount = $true; DefaultSharingLinkType = 'Internal'; DefaultLinkPermission = 'View'; FileAnonymousLinkType = 'View'; FolderAnonymousLinkType = 'View'; RequireAnonymousLinksExpireInDays = 0  } }
}

$current = Get-SPOTenant
$drift   = $desired.GetEnumerator() | Where-Object { $current.$($_.Key) -ne $_.Value }

if (-not $drift) {
    Write-Host "[PASS] Tenant sharing already at $Zone baseline." -ForegroundColor Green
    return
}

Write-Host "[CHANGE] Tenant sharing drift detected:" -ForegroundColor Yellow
$drift | ForEach-Object { Write-Host ("  {0}: {1} -> {2}" -f $_.Key, $current.$($_.Key), $_.Value) }

if ($PSCmdlet.ShouldProcess('Tenant', "Apply $Zone sharing baseline")) {
    Set-SPOTenant @desired
    Write-Host "[DONE] Tenant sharing baseline applied." -ForegroundColor Green
}

Always run with -WhatIf first. Pair the change with a CAB ticket — Set-SPOTenant mutates tenant-global state.


2. Inventory agent grounding sites with sharing posture

$InventoryPath = "C:\Governance\1.3\agent-sites-$(Get-Date -Format 'yyyy-MM-dd').csv"
New-Item -ItemType Directory -Force -Path (Split-Path $InventoryPath) | Out-Null

# Optionally narrow to an explicit allow-list maintained by the AI Governance Lead
$AllowList = Get-Content "C:\Governance\1.3\agent-grounding-sites.txt" -ErrorAction SilentlyContinue

$sites = Get-SPOSite -Limit All -IncludePersonalSite:$false -Detailed |
    Where-Object { -not $AllowList -or ($AllowList -contains $_.Url) } |
    Select-Object Url, Title, Owner, Template, SharingCapability,
                  ConditionalAccessPolicy, SensitivityLabel,
                  RestrictedAccessControl,
                  @{ N='RestrictContentOrgWideSearch'; E={ $_.RestrictContentOrgWideSearch } },
                  StorageUsageCurrent, LastContentModifiedDate

$sites | Export-Csv -Path $InventoryPath -NoTypeInformation -Encoding utf8
$sha = (Get-FileHash -Path $InventoryPath -Algorithm SHA256).Hash
"$sha  $InventoryPath" | Out-File "$InventoryPath.sha256"

Write-Host "[DONE] Exported $($sites.Count) sites to $InventoryPath (SHA256 emitted)." -ForegroundColor Green

3. Remove Everyone and Everyone except external users from a site

function Remove-BroadClaim {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory)] [string]$SiteUrl
    )

    $claims = @(
        @{ Name = 'Everyone';                       LoginName = 'c:0(.s|true' },
        @{ Name = 'Everyone except external users'; LoginName = "c:0-.f|rolemanager|spo-grid-all-users/$((Get-SPOTenant).RootSiteUrl -replace 'https://([^\.]+)\..*','$1')" }
    )

    foreach ($claim in $claims) {
        $found = Get-SPOUser -Site $SiteUrl -Limit All -ErrorAction SilentlyContinue |
                 Where-Object { $_.LoginName -eq $claim.LoginName -or $_.LoginName -match 'spo-grid-all-users|^c:0\(\.s\|true$' }

        if (-not $found) {
            Write-Host "[OK] $($claim.Name) not present on $SiteUrl" -ForegroundColor Gray
            continue
        }

        if ($PSCmdlet.ShouldProcess($SiteUrl, "Remove claim $($claim.Name)")) {
            try {
                Remove-SPOUser -Site $SiteUrl -LoginName $found.LoginName -ErrorAction Stop
                Write-Host "[DONE] Removed $($claim.Name) from $SiteUrl" -ForegroundColor Yellow
            } catch {
                Write-Warning "Failed to remove $($claim.Name) from $SiteUrl: $($_.Exception.Message)"
            }
        }
    }
}

# Apply across the agent grounding inventory
Get-Content "C:\Governance\1.3\agent-grounding-sites.txt" |
    ForEach-Object { Remove-BroadClaim -SiteUrl $_ -WhatIf }   # remove -WhatIf after CAB approval

The exact LoginName for Everyone except external users includes the tenant short name; the function above derives it from Get-SPOTenant. If your tenant uses a vanity domain, validate the claim string with Get-SPOUser -Site $SiteUrl | Where-Object LoginName -like '*spo-grid-all-users*' before running in production.


4. Apply Zone 3 site posture (sharing, label, RAC, RCD)

function Set-AgentGroundingSite {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)][string]$SiteUrl,
        [Parameter(Mandatory)][ValidateSet('Zone1','Zone2','Zone3')][string]$Zone,
        [string]$SensitivityLabelName,        # e.g. 'Confidential-FSI'
        [string[]]$RestrictAccessGroupIds,    # required for Zone3 RAC; up to 10 group GUIDs (FSI recommends one)
        [switch]$EnableRCD
    )

    $site = Get-SPOSite -Identity $SiteUrl -Detailed -ErrorAction Stop

    # 4a — sharing capability
    $desiredSharing = @{ Zone1 = 'ExternalUserSharingOnly'; Zone2 = 'ExistingExternalUserSharingOnly'; Zone3 = 'Disabled' }[$Zone]
    if ($site.SharingCapability -ne $desiredSharing -and $PSCmdlet.ShouldProcess($SiteUrl, "Set SharingCapability=$desiredSharing")) {
        Set-SPOSite -Identity $SiteUrl -SharingCapability $desiredSharing -DisableSharingForNonOwners
        Write-Host "[DONE] $SiteUrl SharingCapability -> $desiredSharing" -ForegroundColor Yellow
    }

    # 4b — container sensitivity label (Microsoft.Graph route preferred)
    if ($SensitivityLabelName -and $site.SensitivityLabel -ne $SensitivityLabelName -and $PSCmdlet.ShouldProcess($SiteUrl, "Apply label $SensitivityLabelName")) {
        $groupId = (Get-SPOSite -Identity $SiteUrl -Detailed).GroupId
        if ($groupId -and $groupId -ne [Guid]::Empty) {
            # MicrosoftGraphInformationProtectionLabel exposes .Name (not .DisplayName) per Graph Beta SDK.
            $allLabels     = Get-MgBetaInformationProtectionPolicyLabel -All
            $matchedLabels = @($allLabels | Where-Object { $_.Name -eq $SensitivityLabelName })
            if ($matchedLabels.Count -eq 1) {
                Update-MgGroup -GroupId $groupId -BodyParameter @{ assignedLabels = @(@{ labelId = $matchedLabels[0].Id }) }
                Write-Host "[DONE] Label $SensitivityLabelName applied to group $groupId" -ForegroundColor Yellow
            } elseif ($matchedLabels.Count -eq 0) {
                $available = ($allLabels | Where-Object { $_.Name } | Select-Object -ExpandProperty Name) -join ', '
                Write-Warning "Label '$SensitivityLabelName' not found in published policy. Available names: $available"
            } else {
                Write-Warning "Label '$SensitivityLabelName' matched $($matchedLabels.Count) entries; cannot apply ambiguously. Verify label names in the Microsoft Purview compliance portal."
            }
        } else {
            Write-Warning "$SiteUrl is not group-connected; apply container label via SharePoint admin UI."
        }
    }

    # 4c — Restricted Access Control (Zone 3)
    if ($Zone -eq 'Zone3') {
        if (-not $RestrictAccessGroupIds) { throw "RestrictAccessGroupIds is mandatory for Zone3." }
        if ($RestrictAccessGroupIds.Count -gt 10) { throw "Restricted Access Control binds at most 10 groups per site." }
        if ($PSCmdlet.ShouldProcess($SiteUrl, "Enable RAC bound to $($RestrictAccessGroupIds.Count) group(s)")) {
            # Enable site access restriction, then bind the allowed Microsoft 365 / Entra security group GUID(s).
            Set-SPOSite -Identity $SiteUrl -RestrictedAccessControl $true
            Set-SPOSite -Identity $SiteUrl -AddRestrictedAccessControlGroups $RestrictAccessGroupIds
            # Use -RestrictedAccessControlGroups to replace the full set; -RemoveRestrictedAccessControlGroups to unbind.
            Write-Host "[DONE] RAC enabled on $SiteUrl bound to $($RestrictAccessGroupIds.Count) group(s)." -ForegroundColor Yellow
        }
    }

    # 4d — Restricted Content Discovery (suppress from search/Copilot)
    if ($EnableRCD -and $PSCmdlet.ShouldProcess($SiteUrl, "Enable Restricted Content Discovery")) {
        Set-SPOSite -Identity $SiteUrl -RestrictContentOrgWideSearch $true
        Write-Host "[DONE] RCD enabled on $SiteUrl" -ForegroundColor Yellow
    }
}

# Example
Set-AgentGroundingSite -SiteUrl 'https://contoso.sharepoint.com/sites/Agent-CustomerService' `
                       -Zone Zone3 `
                       -SensitivityLabelName 'Confidential-FSI' `
                       -RestrictAccessGroupIds 'a3f1c2b4-...' `
                       -EnableRCD `
                       -WhatIf

Restricted Access Control binds up to 10 Microsoft 365 / Entra security groups per site; FSI hardening prefers a single named group for least privilege. Verify the parameter surface with Get-Help Set-SPOSite -Parameter AddRestrictedAccessControlGroups after each module update, and read back the binding with Get-SPOSite -Identity $SiteUrl | Select-Object RestrictedAccessControl, RestrictedAccessControlGroups. If the parameter is not present, upgrade the SharePoint Online Management Shell or fall back to the SharePoint admin center flyout (Step 5 of the Portal Walkthrough).


5. Restricted SharePoint Search allow-list (temporary safeguard)

Restricted SharePoint Search is retiring

Per Microsoft Learn, Restricted SharePoint Search (RSS) is retiring: starting July 31, 2026, new enablement is blocked. Treat RSS strictly as a short-lived bridge and plan the migration to comprehensive data controls — primarily Restricted Content Discovery (RCD) (§4d / Portal Step 6) — for durable content-discoverability governance. Do not adopt RSS as a new control after the retirement date.

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [string[]]$AllowedSites = @(
        'https://contoso.sharepoint.com/sites/Agent-CustomerService',
        'https://contoso.sharepoint.com/sites/Agent-Compliance'
    )
)

if ($AllowedSites.Count -gt 100) { throw "Restricted SharePoint Search supports at most 100 sites in the allow list." }

# Confirm current mode before changing it.
Get-SPOTenantRestrictedSearchMode

if ($PSCmdlet.ShouldProcess('Tenant', 'Enable Restricted SharePoint Search')) {
    # Enabling starts with an empty allow list; add the sanctioned sites afterwards.
    Set-SPOTenantRestrictedSearchMode -Mode Enabled
    Add-SPOTenantRestrictedSearchAllowedList -SitesList $AllowedSites
    Write-Host "[DONE] Restricted SharePoint Search enabled with $($AllowedSites.Count) sites." -ForegroundColor Green
    Write-Warning "Restricted SharePoint Search is retiring (new enablement blocked from 2026-07-31). Schedule the move to Restricted Content Discovery (RCD)."
}

Confirm the RSS cmdlet surface with Get-Command -Module Microsoft.Online.SharePoint.PowerShell *RestrictedSearch* against your pinned module version. The mode is set with Set-SPOTenantRestrictedSearchMode -Mode {Disabled|Enabled} (read back with Get-SPOTenantRestrictedSearchMode); the allow list is managed with Add-SPOTenantRestrictedSearchAllowedList / Remove-SPOTenantRestrictedSearchAllowedList and inspected with Get-SPOTenantRestrictedSearchAllowedList. The older Set-SPOTenant -RestrictedSharePointSearchEnabled toggle is superseded by the mode cmdlet.


6. Permission report for evidence (per-site)

function Get-AgentSitePermissionReport {
    param([Parameter(Mandatory)][string[]]$SiteUrls)

    $report = foreach ($url in $SiteUrls) {
        try {
            $site  = Get-SPOSite -Identity $url -Detailed -ErrorAction Stop
            $users = Get-SPOUser -Site $url -Limit All -ErrorAction Stop
            foreach ($u in $users) {
                [PSCustomObject]@{
                    Site               = $url
                    SiteSensitivity    = $site.SensitivityLabel
                    SharingCapability  = $site.SharingCapability
                    LoginName          = $u.LoginName
                    DisplayName        = $u.DisplayName
                    IsSiteAdmin        = $u.IsSiteAdmin
                    Groups             = ($u.Groups -join '; ')
                    IsBroadClaim       = $u.LoginName -match 'spo-grid-all-users|^c:0\(\.s\|true$'
                }
            }
        } catch {
            Write-Warning "Skipped $url : $($_.Exception.Message)"
        }
    }

    $out = "C:\Governance\1.3\permissions-$(Get-Date -Format 'yyyy-MM-dd').csv"
    $report | Export-Csv -Path $out -NoTypeInformation -Encoding utf8
    $sha = (Get-FileHash -Path $out -Algorithm SHA256).Hash
    "$sha  $out" | Out-File "$out.sha256"
    Write-Host "[DONE] Permission report: $out (SHA256 emitted)." -ForegroundColor Green
    return $out
}

Get-Content "C:\Governance\1.3\agent-grounding-sites.txt" | Get-AgentSitePermissionReport

7. Create access reviews on M365 groups via Microsoft Graph

# Requires: Connect-MgGraph -Scopes 'AccessReview.ReadWrite.All','Group.Read.All'
$GroupIds = @('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')

$reviewBody = @{
    displayName        = 'Agent grounding sites — quarterly access review'
    descriptionForAdmins = 'Quarterly recertification of M365 groups backing Copilot Studio agent grounding sites (Control 1.3).'
    descriptionForReviewers = 'Confirm each member still requires access to the agent grounding site backing this group. Justification required.'
    scope = @{
        '@odata.type' = '#microsoft.graph.principalResourceMembershipsScope'
        principalScopes = @(@{ '@odata.type' = '#microsoft.graph.accessReviewQueryScope'; query = '/users'; queryType = 'MicrosoftGraph' })
        resourceScopes  = $GroupIds | ForEach-Object { @{ '@odata.type' = '#microsoft.graph.accessReviewQueryScope'; query = "/groups/$_"; queryType = 'MicrosoftGraph' } }
    }
    reviewers           = @(@{ query = "/groups/<governance-group-id>/transitiveMembers"; queryType = 'MicrosoftGraph' })
    settings = @{
        mailNotificationsEnabled    = $true
        reminderNotificationsEnabled = $true
        justificationRequiredOnApproval = $true
        defaultDecisionEnabled      = $true
        defaultDecision             = 'Deny'
        instanceDurationInDays      = 14
        autoApplyDecisionsEnabled   = $true
        recommendationsEnabled      = $true
        recurrence = @{
            pattern = @{ type = 'absoluteMonthly'; interval = 3 }
            range   = @{ type = 'noEnd'; startDate = (Get-Date -Format 'yyyy-MM-dd') }
        }
    }
}

New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $reviewBody

8. Drift-detection scheduled job (idempotent)

Run nightly under a least-privileged service account; raise an incident if any Zone 3 site drifts.

$Inventory = Import-Csv "C:\Governance\1.3\agent-grounding-sites.csv"  # columns: Url,Zone,ExpectedLabel
$violations = foreach ($row in $Inventory) {
    try {
        $s = Get-SPOSite -Identity $row.Url -Detailed
        $broad = Get-SPOUser -Site $row.Url -Limit All | Where-Object LoginName -match 'spo-grid-all-users|^c:0\(\.s\|true$'
        $issues = New-Object System.Collections.Generic.List[string]
        if ($row.Zone -eq 'Zone3' -and $s.SharingCapability -ne 'Disabled') { $issues.Add("SharingCapability=$($s.SharingCapability)") }
        if ($row.ExpectedLabel -and $s.SensitivityLabel -ne $row.ExpectedLabel) { $issues.Add("Label=$($s.SensitivityLabel)") }
        if ($broad) { $issues.Add("BroadClaim=$($broad.LoginName -join ',')") }
        if ($issues.Count) { [PSCustomObject]@{ Url=$row.Url; Zone=$row.Zone; Issues=($issues -join '; ') } }
    } catch { Write-Warning "Probe failed for $($row.Url): $($_.Exception.Message)" }
}

if ($violations) {
    $out = "C:\Governance\1.3\drift-$(Get-Date -Format 'yyyy-MM-dd-HHmm').csv"
    $violations | Export-Csv -Path $out -NoTypeInformation -Encoding utf8
    # Send to ITSM / Sentinel / Compliance Officer mailbox per your runbook
    Write-Host "[ALERT] $($violations.Count) drift events written to $out" -ForegroundColor Red
    exit 2
} else {
    Write-Host "[PASS] No drift detected." -ForegroundColor Green
    exit 0
}

9. End-to-end orchestration (read-only by default)

<#
.SYNOPSIS
    Orchestrates Control 1.3 baseline checks and (optionally) remediation.

.PARAMETER SitesPath
    File listing the agent grounding sites, one URL per line.

.PARAMETER Zone
    Governance zone for the in-scope sites. Defaults to Zone3.

.PARAMETER Apply
    Switch — when set, mutating operations (claim removal, RAC, RCD) execute.
    When omitted, the script runs in read-only / WhatIf mode.

.EXAMPLE
    .\Invoke-Control-1.3.ps1 -SitesPath 'C:\Governance\1.3\agent-grounding-sites.txt'
    .\Invoke-Control-1.3.ps1 -SitesPath '...' -Zone Zone3 -Apply
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)][string]$SitesPath,
    [ValidateSet('Zone1','Zone2','Zone3')][string]$Zone = 'Zone3',
    [switch]$Apply
)

$WhatIfPreference = -not $Apply

$sites = Get-Content $SitesPath
Write-Host "=== Control 1.3 :: $($sites.Count) sites :: $Zone :: Apply=$Apply ===" -ForegroundColor Cyan

# 1. Tenant baseline
& $PSScriptRoot\01-Set-TenantSharing.ps1 -Zone $Zone

# 2. Inventory
& $PSScriptRoot\02-Inventory.ps1 -SitesPath $SitesPath

# 3+4. Per-site posture
foreach ($url in $sites) {
    Remove-BroadClaim -SiteUrl $url
    Set-AgentGroundingSite -SiteUrl $url -Zone $Zone -EnableRCD:($Zone -eq 'Zone3')
}

# 6. Permission report
Get-AgentSitePermissionReport -SiteUrls $sites | Out-Null

Write-Host "Control 1.3 orchestration complete." -ForegroundColor Cyan

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


Updated: May 2026 | Version: v1.6.2 | UI Verification Status: Current