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

Last Updated: April 2026 Modules Required: Microsoft.Online.SharePoint.PowerShell, PnP.PowerShell (v2+ — requires Entra app registration), Microsoft.Graph (Identity.Governance, Sites, Groups), 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

# Sovereign-cloud aware connection (Commercial example)
$TenantName  = 'contoso'
$AdminUrl    = "https://$TenantName-admin.sharepoint.com"

Connect-SPOService -Url $AdminUrl                                  # interactive MFA in browser
Connect-MgGraph -Scopes 'Sites.Read.All','Group.Read.All','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

For GCC / GCC High / DoD, append the appropriate -Region parameter to Connect-SPOService and use the matching Connect-MgGraph -Environment value (see baseline §3).


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]$RestrictAccessGroupId,       # required for Zone3 RAC
        [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) {
            $label = Get-MgBetaInformationProtectionPolicyLabel -All | Where-Object DisplayName -eq $SensitivityLabelName
            if ($label) {
                Update-MgGroup -GroupId $groupId -BodyParameter @{ assignedLabels = @(@{ labelId = $label.Id }) }
                Write-Host "[DONE] Label $SensitivityLabelName applied to group $groupId" -ForegroundColor Yellow
            } else {
                Write-Warning "Label $SensitivityLabelName not found in published policy."
            }
        } 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 $RestrictAccessGroupId) { throw "RestrictAccessGroupId is mandatory for Zone3." }
        if ($PSCmdlet.ShouldProcess($SiteUrl, "Enable RAC bound to group $RestrictAccessGroupId")) {
            Set-SPOSite -Identity $SiteUrl -RestrictedAccessControl $true
            Add-SPOSiteCollectionAppCatalog -Site $SiteUrl -ErrorAction SilentlyContinue | Out-Null
            Set-SPOSiteGroup -Site $SiteUrl -Identity 'RestrictedAccessControlGroups' -Users @($RestrictAccessGroupId) -ErrorAction SilentlyContinue
            # SAM RAC API surface evolves; if Set-SPOSiteGroup is unavailable, manage via the SharePoint admin UI flyout.
            Write-Host "[DONE] RAC enabled on $SiteUrl" -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' `
                       -RestrictAccessGroupId 'a3f1c2b4-...' `
                       -EnableRCD `
                       -WhatIf

The exact RAC parameter surface on Set-SPOSite shipped over multiple SharePoint Online Management Shell releases. Verify with Get-Help Set-SPOSite -Parameter RestrictedAccessControl after each module update; if the parameter is not present, fall back to the SharePoint admin center flyout (Step 5 of the Portal Walkthrough).


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

[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." }

if ($PSCmdlet.ShouldProcess('Tenant', 'Enable Restricted SharePoint Search')) {
    Set-SPOTenant -RestrictedSharePointSearchEnabled $true
    foreach ($url in $AllowedSites) {
        Add-SPOTenantRestrictedSearchAllowedList -SitesList $url
    }
    Write-Host "[DONE] Restricted SharePoint Search enabled with $($AllowedSites.Count) sites." -ForegroundColor Green
    Write-Warning "Restricted SharePoint Search is a temporary safeguard. Schedule a follow-up to disable once sites are remediated."
}

Cmdlet names (Add-SPOTenantRestrictedSearchAllowedList, Set-SPOTenant -RestrictedSharePointSearchEnabled) shipped during the Microsoft 365 Copilot rollout. Confirm with Get-Command -Module Microsoft.Online.SharePoint.PowerShell *RestrictedSearch* against your pinned module version.


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: April 2026 | Version: v1.4.0