Control 1.10 — PowerShell Setup: Communication Compliance Monitoring
Control: 1.10 Communication Compliance Monitoring
Baseline: PowerShell baseline (_shared/powershell-baseline.md)
Audience: M365 administrator at a US financial services organization (FINRA / SEC / GLBA / OCC / Fed SR 26-2 (formerly SR 11-7) / CFTC oversight) operating Microsoft 365 Copilot, Agent Builder, and Microsoft Copilot Studio agents.
Cloud: Commercial (Global) — the deployment surface for US financial-services customers.
Required modules: ExchangeOnlineManagement ≥ 3.5.0 (provides both Connect-ExchangeOnline and Connect-IPPSSession).
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), transcript capture, and SHA-256 evidence emission. Snippets below assume you have already complied with that baseline.
Scope of this file. PowerShell automation for Microsoft Purview Communication Compliance (CC) as it applies to Copilot, Agent Builder, and Copilot Studio agent communications. PowerShell coverage for CC is deliberately partial:
- PowerShell is not supported for creating, updating, or deleting Communication Compliance policies, classifiers, OCR settings, priority user groups, adaptive scopes, or reviewer dispositions. All CC policy CRUD must be performed in the Microsoft Purview portal. See Microsoft Learn — Create and manage communication compliance policies.
- PowerShell is supported for the legacy Supervisory Review family (
*-SupervisoryReviewPolicyV2,*-SupervisoryReviewRule,Get-SupervisoryReviewActivity,Get-SupervisoryReviewReport). Supervisory Review is the cmdlet surface that backs the older Microsoft 365 supervision policies and remains the authoritative PowerShell-accessible source of reviewer activity for evidence collection.- Audit Log evidence (
Search-UnifiedAuditLogforSupervisionRuleMatch,SupervisionPolicyCreated,SupervisionPolicyUpdated,SupervisionPolicyDeleted,SupervisoryReviewTag) is the cross-cutting evidence path used by FSI auditors when they need a tenant-wide event stream rather than per-policy reviewer activity.Do not treat this playbook as a substitute for portal-driven CC policy administration. Treat it as the evidence, reconciliation, and supervisory-review automation layer for Control 1.10.
0. Wrong-shell trap (READ FIRST)
Communication Compliance and Supervisory Review cmdlets live in two separate PowerShell sessions. Running a cmdlet in the wrong session produces either a CommandNotFoundException or, worse, silent zero results that look like clean evidence. Every script block in this playbook is labelled with its required session — do not strip those labels when copying.
| Cmdlet family | Required session | Module | If you run it in the wrong session |
|---|---|---|---|
*-SupervisoryReviewPolicyV2, *-SupervisoryReviewRule, Get-SupervisoryReviewActivity, Get-SupervisoryReviewReport |
Connect-IPPSSession (Security & Compliance / IPPS) |
ExchangeOnlineManagement |
CommandNotFoundException from Connect-ExchangeOnline. |
Search-UnifiedAuditLog for CC operations (Supervision*, SupervisoryReviewTag) |
Connect-IPPSSession (IPPS) |
ExchangeOnlineManagement |
Returns rows from Connect-ExchangeOnline for some record types but silently misses Communication Compliance operations indexed only on the IPPS endpoint. |
Get-RoleGroup, Get-RoleGroupMember, Add-RoleGroupMember, Remove-RoleGroupMember for Communication Compliance role groups |
Connect-IPPSSession (IPPS) |
ExchangeOnlineManagement |
Exchange Get-RoleGroup exists but resolves a different role-group catalog and silently returns 0 hits for *Communication Compliance*. |
Get-AdminAuditLogConfig (to verify UnifiedAuditLogIngestionEnabled) |
Connect-ExchangeOnline |
ExchangeOnlineManagement |
From Connect-IPPSSession, returns False for UnifiedAuditLogIngestionEnabled even when audit ingestion is on — Control 1.7 evidence becomes false-clean. |
Always assert session state at the top of every script via
Get-ConnectionInformation(provided byExchangeOnlineManagement3.x). TheInitialize-Cc110Sessionhelper in Section 1 does this for you.
1. Pre-flight
Every Control 1.10 PowerShell session must start with the same five steps:
- Pin the
ExchangeOnlineManagementmodule version (CAB-approved). - Open a Security & Compliance PowerShell session.
- Connect to IPPS (Security & Compliance) — supervisory review and CC audit operations are IPPS-only.
- Verify the caller is a member of
Communication Compliance Admins(and optionallyCommunication Compliance Investigatorsfor evidence collection runs). - Verify the tenant has the licensing required for Communication Compliance (Microsoft 365 E5 / E5 Compliance / Insider Risk Management add-on).
The helper below is a single drop-in pre-flight. Save it as Initialize-Cc110Session.ps1 in your evidence-collection module.
#Requires -Version 7.2
#Requires -Modules @{ ModuleName = 'ExchangeOnlineManagement'; RequiredVersion = '3.5.0' }
function Initialize-Cc110Session {
<#
.SYNOPSIS
Pre-flight for Control 1.10 (Communication Compliance Monitoring).
.DESCRIPTION
Opens an IPPS session if not already
connected, asserts module version pin, asserts caller role membership
(Communication Compliance Admins), and emits a session-state PSCustomObject
for downstream scripts to consume. Read-only — no tenant mutation.
.PARAMETER UserPrincipalName
UPN used to authenticate to IPPS.
.PARAMETER Cloud
Microsoft 365 cloud the tenant is in. Default: Commercial.
.PARAMETER RequiredRoleGroup
Role group caller must be a member of. Default: 'Communication Compliance Admins'.
.EXAMPLE
$ctx = Initialize-Cc110Session -UserPrincipalName admin@contoso.com
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $UserPrincipalName,
[string] $Cloud = 'Commercial',
[string] $RequiredRoleGroup = 'Communication Compliance Admins'
)
$ErrorActionPreference = 'Stop'
# 1. Module pin assertion
$exo = Get-Module -ListAvailable -Name ExchangeOnlineManagement |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $exo) {
throw "ExchangeOnlineManagement module not installed. Install pinned version per CAB approval."
}
if ($exo.Version -lt [version]'3.5.0') {
throw "ExchangeOnlineManagement $($exo.Version) is below the 3.5.0 minimum required for IPPS REST + CC operations."
}
# 2. Set connection endpoint
$endpoint = @{ Uri = 'https://ps.compliance.protection.outlook.com/powershell-liveid/'
Aad = 'https://login.microsoftonline.com/organizations' }
# 3. Open IPPS session (idempotent — only connects if no live IPPS session)
$existing = Get-ConnectionInformation -ErrorAction SilentlyContinue |
Where-Object { $_.ConnectionUri -like '*compliance.protection*' -and $_.State -eq 'Connected' }
if (-not $existing) {
Connect-IPPSSession `
-UserPrincipalName $UserPrincipalName `
-ConnectionUri $endpoint.Uri `
-AzureADAuthorizationEndpointUri $endpoint.Aad | Out-Null
}
# 4. Role membership check (caller must be in $RequiredRoleGroup)
$callerUpn = $UserPrincipalName
$members = Get-RoleGroupMember -Identity $RequiredRoleGroup -ErrorAction Stop
$isMember = $members | Where-Object {
$_.PrimarySmtpAddress -eq $callerUpn -or $_.WindowsLiveID -eq $callerUpn -or $_.Name -eq $callerUpn
}
if (-not $isMember) {
throw "Caller $callerUpn is not a member of '$RequiredRoleGroup'. Communication Compliance cmdlets will fail or return partial results."
}
# 5. License surface check (informational — CC requires E5/E5 Compliance/IRM add-on)
# Licensing detail belongs in Control 2.1; here we only flag the CC service plan.
$ccPolicies = Get-SupervisoryReviewPolicyV2 -ErrorAction SilentlyContinue
if ($null -eq $ccPolicies) {
Write-Warning "Get-SupervisoryReviewPolicyV2 returned null. Tenant may lack the Communication Compliance / Insider Risk Management license. Verify in Microsoft 365 admin center before treating zero results as evidence."
}
[PSCustomObject]@{
Cloud = $Cloud
ConnectionUri = $endpoint.Uri
Aad = $endpoint.Aad
Caller = $callerUpn
RequiredRoleGroup= $RequiredRoleGroup
ModuleVersion = $exo.Version.ToString()
ConnectedAtUtc = (Get-Date).ToUniversalTime()
TenantId = (Get-ConnectionInformation | Select-Object -First 1).TenantID
}
}
Why role membership is a hard pre-flight.
Get-SupervisoryReviewPolicyV2returns an empty list without throwing when the caller lacks permission. Without the membership assertion, an unauthorized run produces a zero-row evidence file that looks identical to a tenant with no policies — the exact pattern auditors flag as fabricated false-clean evidence.
2. PowerShell coverage boundary — what the shell can and cannot do for CC
This table is the single source of truth for what belongs in PowerShell vs. what belongs in the Microsoft Purview portal for Control 1.10. It is paraphrased from Microsoft Learn — Communication Compliance — Create and manage policies and Permissions. Reverify before each change window — Microsoft has been moving CC functionality between surfaces.
| Capability | PowerShell? | Where it lives |
|---|---|---|
| Create / update / delete Communication Compliance policies (modern CC, including Copilot interactions template) | No | Microsoft Purview portal → Communication compliance → Policies |
| Configure trainable classifiers on a CC policy | No | Purview portal |
| Configure OCR on a CC policy | No | Purview portal |
| Configure Priority User Groups | No | Purview portal |
| Configure adaptive scopes assigned to a CC policy | No (use Purview portal for the assignment; adaptive scope objects themselves are PS-accessible via Get-AdaptiveScope for inventory only) |
Purview portal |
| Reviewer disposition in the alert queue (resolve / escalate / tag) | No | Purview portal |
| Read / export reviewer activity for a supervisory review policy | Yes — Get-SupervisoryReviewActivity -PolicyId -StartDate -EndDate |
IPPS PowerShell |
| Generate the supervisory review report | Yes — Get-SupervisoryReviewReport |
IPPS PowerShell |
| Inventory supervisory review policies / rules | Yes — Get-SupervisoryReviewPolicyV2, Get-SupervisoryReviewRule |
IPPS PowerShell |
| Create a supervisory review policy or rule (legacy supervision surface, still PS-supported) | Yes — New-SupervisoryReviewPolicyV2, New-SupervisoryReviewRule |
IPPS PowerShell |
Audit-log evidence stream for CC operations (SupervisionRuleMatch, SupervisionPolicyCreated, SupervisionPolicyUpdated, SupervisionPolicyDeleted, SupervisoryReviewTag) |
Yes — Search-UnifiedAuditLog (paged) |
IPPS PowerShell (UAL via IPPS endpoint) |
| Communication Compliance role-group membership (Admins / Investigators / Analysts / Viewers) | Yes — Get-RoleGroup, Get-RoleGroupMember, Add-RoleGroupMember, Remove-RoleGroupMember |
IPPS PowerShell |
Configure tenant-wide audit ingestion (UnifiedAuditLogIngestionEnabled) |
Yes — but that is Control 1.7, not 1.10 | Exchange Online PowerShell |
Do not promote PowerShell as a substitute for portal CC policy CRUD. If a sub-script in this playbook appears to "create a CC policy" via
New-SupervisoryReviewPolicyV2, it is creating a supervisory review policy — a related but distinct object surface. Use it deliberately and document the choice in your change ticket.
3. Inventory (read-only)
All commands in this section run from Connect-IPPSSession (use Initialize-Cc110Session first). Output is read-only and safe to run during business hours.
3.1 Inventory supervisory review policies
# Session: IPPS
$ctx = Initialize-Cc110Session -UserPrincipalName $env:USERPRINCIPALNAME -Cloud Commercial
$policies = Get-SupervisoryReviewPolicyV2 -ErrorAction Stop
$policies |
Select-Object Name, Enabled, Reviewers, PreservationPeriodInDays, UserReportingWorkloads, WhenCreatedUTC, WhenChangedUTC, Guid |
Sort-Object Name |
Format-Table -AutoSize
Schema reference.
New-SupervisoryReviewPolicyV2defines reviewers via-Reviewers <String[]>(an array of UPNs). Reviewer assignment lives on the policy, not on the rule. If you see a script using-ReviewerEmail, it is wrong — that parameter does not exist.
3.2 Inventory supervisory review rules per policy
# Session: IPPS
foreach ($p in $policies) {
$rules = Get-SupervisoryReviewRule -Policy $p.Guid -ErrorAction SilentlyContinue
[PSCustomObject]@{
PolicyName = $p.Name
PolicyGuid = $p.Guid
RuleCount = ($rules | Measure-Object).Count
Rules = $rules | Select-Object Name, SamplingRate, ContentSources, Condition, Ocr
}
} | ConvertTo-Json -Depth 6
3.3 Snapshot Communication Compliance role-group membership
# Session: IPPS
$ccRoleGroups = @(
'Communication Compliance',
'Communication Compliance Admins',
'Communication Compliance Analysts',
'Communication Compliance Investigators',
'Communication Compliance Viewers'
)
$snapshot = foreach ($g in $ccRoleGroups) {
$rg = Get-RoleGroup -Identity $g -ErrorAction SilentlyContinue
if (-not $rg) {
[PSCustomObject]@{ RoleGroup = $g; Exists = $false; Members = @() }
continue
}
$members = Get-RoleGroupMember -Identity $g -ErrorAction Stop |
Select-Object Name, PrimarySmtpAddress, RecipientType
[PSCustomObject]@{
RoleGroup = $g
Exists = $true
Members = $members
Count = ($members | Measure-Object).Count
}
}
$snapshot | ConvertTo-Json -Depth 5
Why all five role groups. Microsoft consolidated the CC role groups in mid-2023 (the umbrella
Communication Compliancerole group was added). Tenants provisioned before that change still expose the four originals; tenants provisioned after may only expose the umbrella plus a subset. Snapshot all five so the diff between two evidence runs is unambiguous.
4. Supervisory review CRUD (state-changing)
These sections modify tenant state. Each function is wrapped in [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')], captures a before-snapshot, and uses -ErrorAction Stop with try/catch — never SilentlyContinue on a mutation.
4.1 Create a supervisory review policy
# Session: IPPS
function New-Cc110SupervisoryReviewPolicy {
<#
.SYNOPSIS
Create a supervisory review policy with reviewers assigned at the policy level.
.NOTES
This is the *legacy supervision* surface. Modern Communication Compliance
policies (including the Copilot interactions template) cannot be created
via PowerShell — use the Purview portal. See Section 2 for the boundary.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string[]] $Reviewers,
[int] $PreservationPeriodInDays = 365,
[string] $Comment = "Created by FSI Control 1.10 automation",
[string] $EvidencePath = ".\evidence\1.10"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $EvidencePath "transcript-policy-create-$ts.log") -IncludeInvocationHeader
try {
# BEFORE snapshot
$before = Get-SupervisoryReviewPolicyV2 -ErrorAction SilentlyContinue
$before | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "before-policies-$ts.json") -Encoding UTF8
if ($PSCmdlet.ShouldProcess("$Name", "Create supervisory review policy with $($Reviewers.Count) reviewer(s)")) {
$created = New-SupervisoryReviewPolicyV2 `
-Name $Name `
-Reviewers $Reviewers `
-PreservationPeriodInDays $PreservationPeriodInDays `
-Comment $Comment `
-ErrorAction Stop
}
# AFTER snapshot — only on success
$after = Get-SupervisoryReviewPolicyV2 -Identity $Name -ErrorAction Stop
$after | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "after-policy-$Name-$ts.json") -Encoding UTF8
[PSCustomObject]@{
Result = 'PASS'
PolicyName = $after.Name
PolicyGuid = $after.Guid
Reviewers = $after.Reviewers
CreatedAtUtc= (Get-Date).ToUniversalTime()
}
}
catch {
# No PASS banner — propagate the failure so the run is recorded as FAIL.
Write-Error "Supervisory review policy creation FAILED for '$Name': $_"
[PSCustomObject]@{
Result = 'FAIL'
PolicyName= $Name
Error = $_.Exception.Message
}
}
finally {
Stop-Transcript | Out-Null
}
}
4.2 Add a rule to a supervisory review policy
# Session: IPPS
function Add-Cc110SupervisoryReviewRule {
<#
.SYNOPSIS
Add a rule to an existing supervisory review policy.
.NOTES
New-SupervisoryReviewRule does NOT take -Reviewers; reviewers live on the
policy (Section 4.1). This rule defines what content gets sampled and the
sampling rate.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $RuleName,
[Parameter(Mandatory)] [string] $PolicyIdentity, # accepts Name or Guid
[ValidateRange(1,100)] [int] $SamplingRate = 10,
[string[]] $ContentSources = @('Exchange','Teams'),
[string] $Condition,
[bool] $Ocr = $true,
[string] $EvidencePath = ".\evidence\1.10"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $EvidencePath "transcript-rule-create-$ts.log") -IncludeInvocationHeader
try {
$before = Get-SupervisoryReviewRule -Policy $PolicyIdentity -ErrorAction SilentlyContinue
$before | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "before-rules-$ts.json") -Encoding UTF8
if ($PSCmdlet.ShouldProcess("$RuleName on $PolicyIdentity", "Create supervisory review rule")) {
$params = @{
Name = $RuleName
Policy = $PolicyIdentity
SamplingRate = $SamplingRate
ContentSources = $ContentSources
Ocr = $Ocr
ErrorAction = 'Stop'
}
if ($Condition) { $params.Condition = $Condition }
$created = New-SupervisoryReviewRule @params
}
$after = Get-SupervisoryReviewRule -Policy $PolicyIdentity -ErrorAction Stop
$after | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "after-rules-$ts.json") -Encoding UTF8
[PSCustomObject]@{ Result='PASS'; RuleName=$RuleName; Policy=$PolicyIdentity; SamplingRate=$SamplingRate }
}
catch {
Write-Error "Supervisory review rule creation FAILED for '$RuleName' on '$PolicyIdentity': $_"
[PSCustomObject]@{ Result='FAIL'; RuleName=$RuleName; Error=$_.Exception.Message }
}
finally {
Stop-Transcript | Out-Null
}
}
4.3 Update an existing supervisory review rule
# Session: IPPS
function Set-Cc110SupervisoryReviewRule {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $RuleIdentity,
[int] $SamplingRate,
[string[]] $ContentSources,
[string] $Condition,
[string] $EvidencePath = ".\evidence\1.10"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $EvidencePath "transcript-rule-update-$ts.log") -IncludeInvocationHeader
try {
$before = Get-SupervisoryReviewRule -Identity $RuleIdentity -ErrorAction Stop
$before | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "before-rule-$ts.json") -Encoding UTF8
$params = @{ Identity = $RuleIdentity; ErrorAction = 'Stop' }
if ($PSBoundParameters.ContainsKey('SamplingRate')) { $params.SamplingRate = $SamplingRate }
if ($PSBoundParameters.ContainsKey('ContentSources')) { $params.ContentSources = $ContentSources }
if ($PSBoundParameters.ContainsKey('Condition')) { $params.Condition = $Condition }
if ($PSCmdlet.ShouldProcess($RuleIdentity, "Update supervisory review rule")) {
Set-SupervisoryReviewRule @params
}
$after = Get-SupervisoryReviewRule -Identity $RuleIdentity -ErrorAction Stop
$after | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $EvidencePath "after-rule-$ts.json") -Encoding UTF8
[PSCustomObject]@{ Result='PASS'; RuleIdentity=$RuleIdentity }
}
catch {
Write-Error "Supervisory review rule update FAILED for '$RuleIdentity': $_"
[PSCustomObject]@{ Result='FAIL'; RuleIdentity=$RuleIdentity; Error=$_.Exception.Message }
}
finally {
Stop-Transcript | Out-Null
}
}
4.4 Add or remove members from a CC role group
# Session: IPPS
function Set-Cc110RoleGroupMembership {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [ValidateSet(
'Communication Compliance',
'Communication Compliance Admins',
'Communication Compliance Analysts',
'Communication Compliance Investigators',
'Communication Compliance Viewers')]
[string] $RoleGroup,
[Parameter(Mandatory)] [ValidateSet('Add','Remove')] [string] $Action,
[Parameter(Mandatory)] [string] $Member,
[string] $EvidencePath = ".\evidence\1.10"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
try {
$before = Get-RoleGroupMember -Identity $RoleGroup -ErrorAction Stop
$before | Select-Object Name, PrimarySmtpAddress |
ConvertTo-Json -Depth 4 |
Set-Content (Join-Path $EvidencePath "before-rg-$RoleGroup-$ts.json") -Encoding UTF8
if ($PSCmdlet.ShouldProcess("$Member", "$Action $RoleGroup")) {
switch ($Action) {
'Add' { Add-RoleGroupMember -Identity $RoleGroup -Member $Member -ErrorAction Stop }
'Remove' { Remove-RoleGroupMember -Identity $RoleGroup -Member $Member -Confirm:$false -ErrorAction Stop }
}
}
$after = Get-RoleGroupMember -Identity $RoleGroup -ErrorAction Stop
$after | Select-Object Name, PrimarySmtpAddress |
ConvertTo-Json -Depth 4 |
Set-Content (Join-Path $EvidencePath "after-rg-$RoleGroup-$ts.json") -Encoding UTF8
$delta = Compare-Object -ReferenceObject $before -DifferenceObject $after -Property PrimarySmtpAddress
[PSCustomObject]@{
Result = 'PASS'
RoleGroup = $RoleGroup
Action = $Action
Member = $Member
Delta = $delta
}
}
catch {
Write-Error "Role-group membership change FAILED ($Action $Member on $RoleGroup): $_"
[PSCustomObject]@{ Result='FAIL'; RoleGroup=$RoleGroup; Action=$Action; Member=$Member; Error=$_.Exception.Message }
}
}
Why no
SilentlyContinue. The original Control 1.10 script suppressedAdd-RoleGroupMemberfailures with-ErrorAction SilentlyContinueand then printed a hard-coded[PASS]banner. The result was an evidence file that always claimed success even when the cmdlet rejected the member (license missing, role-group locked, principal not found). Auditors treat that pattern as fabricated evidence. Always use-ErrorAction Stopin atry/catchand only emit aPASSrow when the post-mutation state matches intent.
5. Evidence collection — Audit log and supervisory review activity
This section is the audit-defensible evidence path for Control 1.10. It runs read-only, captures a transcript, paginates the Unified Audit Log without silent truncation, calls Get-SupervisoryReviewActivity per policy, exports JSON + CSV, and writes a manifest.json with SHA-256 hashes.
5.1 Paged Unified Audit Log collector for CC operations
The CC operations indexed in the Unified Audit Log are (verified against Microsoft Learn — Audit log activities § Communication compliance):
| Operation | Description |
|---|---|
SupervisionRuleMatch |
A user sent a message that matches a CC policy condition. |
SupervisionPolicyCreated |
A CC admin created a CC policy. |
SupervisionPolicyUpdated |
A CC admin updated a CC policy. |
SupervisionPolicyDeleted |
A CC admin deleted a CC policy. |
SupervisoryReviewTag |
A reviewer applied a tag to a message (Compliant / Non-compliant / Questionable / Resolved). |
Critical:
Search-UnifiedAuditLogis hard-capped at 5,000 rows per call. A single-call-ResultSize 5000silently truncates at the cap and returns no error. The collector below uses the documented session-paging pattern (-SessionId <guid>+-SessionCommand ReturnLargeSet), loops until an empty page returns, and hard-fails if the per-session ceiling (50,000 rows per session) is reached without exhaustion — at that point you must narrow the date window and re-run.
# Session: IPPS
function Get-Cc110AuditEvidence {
<#
.SYNOPSIS
Paged Unified Audit Log collector for Communication Compliance operations.
.DESCRIPTION
Iterates Search-UnifiedAuditLog using session-paging until an empty page
is returned. Hard-fails if the per-session ceiling is hit without
exhaustion (caller must narrow date range). Writes JSON + CSV with
SHA-256 sidecars and updates manifest.json.
.PARAMETER StartUtc
UTC start of the audit window. Cannot be more than 180 days in the past
for standard tenants (longer with audit retention add-on — Control 1.7).
.PARAMETER EndUtc
UTC end of the audit window.
.PARAMETER OutputDirectory
Where to land evidence files. Should be a WORM-eligible path
(Purview retention lock or Azure Storage immutability).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [datetime] $StartUtc,
[Parameter(Mandatory)] [datetime] $EndUtc,
[Parameter(Mandatory)] [string] $OutputDirectory,
[string[]] $Operations = @(
'SupervisionRuleMatch',
'SupervisionPolicyCreated',
'SupervisionPolicyUpdated',
'SupervisionPolicyDeleted',
'SupervisoryReviewTag'
)
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$runId = [guid]::NewGuid().Guid
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $OutputDirectory "transcript-ual-$ts.log") -IncludeInvocationHeader
try {
$sessionId = "cc110-$runId"
$all = New-Object System.Collections.Generic.List[object]
$pageIndex = 0
$maxPages = 10 # 10 pages × 5000 rows = 50,000 row session ceiling
do {
$pageIndex++
$page = Search-UnifiedAuditLog `
-StartDate $StartUtc `
-EndDate $EndUtc `
-Operations $Operations `
-SessionId $sessionId `
-SessionCommand ReturnLargeSet `
-ResultSize 5000 `
-ErrorAction Stop
if ($null -eq $page -or $page.Count -eq 0) { break }
$all.AddRange($page)
Write-Verbose "Page $pageIndex returned $($page.Count) rows; cumulative=$($all.Count)"
if ($pageIndex -ge $maxPages -and $page.Count -eq 5000) {
throw "Session ceiling reached ($($all.Count) rows) without exhaustion. Narrow the date window and re-run; treating partial results as evidence is not safe."
}
} while ($true)
# Emit JSON
$jsonPath = Join-Path $OutputDirectory "ual-cc110-$ts.json"
$all | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8
# Emit CSV (flattened AuditData JSON for spreadsheet review — NOT a retention store)
$csvPath = Join-Path $OutputDirectory "ual-cc110-$ts.csv"
$all | Select-Object CreationDate, UserIds, Operations, RecordType, ResultIndex, ResultCount, Identity, AuditData |
Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
# Hashes + manifest
$jsonHash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
$csvHash = (Get-FileHash -Path $csvPath -Algorithm SHA256).Hash
Set-Content -Path "$jsonPath.sha256" -Value $jsonHash -Encoding ASCII
Set-Content -Path "$csvPath.sha256" -Value $csvHash -Encoding ASCII
$manifest = [PSCustomObject]@{
runId = $runId
control = '1.10'
artifact = 'communication-compliance-ual'
tenantId = (Get-ConnectionInformation | Select-Object -First 1).TenantID
cloud = (Get-ConnectionInformation | Select-Object -First 1).ConnectionUri
runner = "$env:USERDOMAIN\$env:USERNAME"
startUtc = $StartUtc.ToString('o')
endUtc = $EndUtc.ToString('o')
params = @{ Operations = $Operations }
outputs = @(
@{ file = (Split-Path $jsonPath -Leaf); sha256 = $jsonHash; bytes = (Get-Item $jsonPath).Length }
@{ file = (Split-Path $csvPath -Leaf); sha256 = $csvHash; bytes = (Get-Item $csvPath ).Length }
)
rowCount = $all.Count
pagesConsumed = $pageIndex
generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
}
$manifestPath = Join-Path $OutputDirectory "manifest-ual-$ts.json"
$manifest | ConvertTo-Json -Depth 6 | Set-Content -Path $manifestPath -Encoding UTF8
$manifest
}
finally {
Stop-Transcript | Out-Null
}
}
5.2 Per-policy reviewer activity collector
Get-SupervisoryReviewActivity is the per-policy evidence path — distinct from UAL because it reflects reviewer dispositions (Compliant / Non-compliant / Questionable / Resolved) joined to the message item (ItemSubject, ActivityId, ActionType, ActionAppliedBy, ItemStatusAfterAction). Run it per supervisory review policy when an auditor asks for the reviewer trail behind a specific policy.
# Session: IPPS
function Get-Cc110SupervisoryReviewActivity {
<#
.SYNOPSIS
Per-policy supervisory review activity collector for Control 1.10 evidence.
.PARAMETER PolicyId
Policy GUID from Get-SupervisoryReviewPolicyV2.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $PolicyId,
[Parameter(Mandatory)] [datetime] $StartUtc,
[Parameter(Mandatory)] [datetime] $EndUtc,
[Parameter(Mandatory)] [string] $OutputDirectory
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$runId = [guid]::NewGuid().Guid
Start-Transcript -Path (Join-Path $OutputDirectory "transcript-srvactivity-$PolicyId-$ts.log") -IncludeInvocationHeader
try {
$rows = Get-SupervisoryReviewActivity `
-PolicyId $PolicyId `
-StartDate $StartUtc `
-EndDate $EndUtc `
-ErrorAction Stop |
Sort-Object Timestamp -Descending |
Select-Object PolicyId, ItemSubject, ActivityId, Timestamp, ActionType, ActionAppliedBy, ItemStatusAfterAction
$jsonPath = Join-Path $OutputDirectory "srvactivity-$PolicyId-$ts.json"
$csvPath = Join-Path $OutputDirectory "srvactivity-$PolicyId-$ts.csv"
$rows | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath -Encoding UTF8
$rows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
$jsonHash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
$csvHash = (Get-FileHash -Path $csvPath -Algorithm SHA256).Hash
Set-Content -Path "$jsonPath.sha256" -Value $jsonHash -Encoding ASCII
Set-Content -Path "$csvPath.sha256" -Value $csvHash -Encoding ASCII
$manifest = [PSCustomObject]@{
runId = $runId
control = '1.10'
artifact = 'supervisory-review-activity'
policyId = $PolicyId
tenantId = (Get-ConnectionInformation | Select-Object -First 1).TenantID
cloud = (Get-ConnectionInformation | Select-Object -First 1).ConnectionUri
runner = "$env:USERDOMAIN\$env:USERNAME"
startUtc = $StartUtc.ToString('o')
endUtc = $EndUtc.ToString('o')
outputs = @(
@{ file = (Split-Path $jsonPath -Leaf); sha256 = $jsonHash; bytes = (Get-Item $jsonPath).Length }
@{ file = (Split-Path $csvPath -Leaf); sha256 = $csvHash; bytes = (Get-Item $csvPath ).Length }
)
rowCount = ($rows | Measure-Object).Count
generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
}
$manifest | ConvertTo-Json -Depth 6 |
Set-Content -Path (Join-Path $OutputDirectory "manifest-srvactivity-$PolicyId-$ts.json") -Encoding UTF8
$manifest
}
finally {
Stop-Transcript | Out-Null
}
}
Records-retention caveat (FINRA 4511 / SEC 17a-4(b)(4)). Exporting CSV / JSON to a local file system does not by itself satisfy SEC 17a-4(b)(4) WORM requirements or FINRA 4511 record-keeping rules. Records retention is the responsibility of Control 1.9 (Records Retention) and the underlying Control 1.7 (Audit Logging). Land the artifacts produced by this section into a Microsoft Purview retention-locked repository or an Azure Storage container with an immutability policy, and reference the storage path in your change ticket. The SHA-256 sidecars in this section provide content-integrity evidence; they do not provide WORM.
5.3 End-to-end evidence run
# Session: IPPS
$ctx = Initialize-Cc110Session -UserPrincipalName admin@contoso.com -Cloud Commercial
$evidenceRoot = "\\wormshare\fsi-evidence\1.10\$(Get-Date -Format 'yyyy-MM-dd')"
# UAL sweep — last 7 days
Get-Cc110AuditEvidence `
-StartUtc (Get-Date).ToUniversalTime().AddDays(-7) `
-EndUtc (Get-Date).ToUniversalTime() `
-OutputDirectory $evidenceRoot
# Per-policy supervisory review activity — every CC policy in the tenant
Get-SupervisoryReviewPolicyV2 | ForEach-Object {
Get-Cc110SupervisoryReviewActivity `
-PolicyId $_.Guid `
-StartUtc (Get-Date).ToUniversalTime().AddDays(-30) `
-EndUtc (Get-Date).ToUniversalTime() `
-OutputDirectory $evidenceRoot
}
6. Reviewer reconciliation
Auditors routinely ask "who is allowed to review messages flagged by policy X, and is that the same set of people who actually applied tags last quarter?" This section produces a single artifact answering both halves.
# Session: IPPS
function Get-Cc110ReviewerReconciliation {
<#
.SYNOPSIS
For each supervisory review policy, compare the reviewer set defined on
the policy to the actual ActionAppliedBy values from the past N days,
and to current Communication Compliance role-group membership.
#>
[CmdletBinding()]
param(
[int] $DaysBack = 90,
[string] $OutputDirectory = ".\evidence\1.10"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$start = (Get-Date).ToUniversalTime().AddDays(-$DaysBack)
$end = (Get-Date).ToUniversalTime()
$ccAdmins = (Get-RoleGroupMember -Identity 'Communication Compliance Admins' -ErrorAction SilentlyContinue).PrimarySmtpAddress
$ccInvest = (Get-RoleGroupMember -Identity 'Communication Compliance Investigators' -ErrorAction SilentlyContinue).PrimarySmtpAddress
$report = foreach ($p in Get-SupervisoryReviewPolicyV2) {
$activity = Get-SupervisoryReviewActivity -PolicyId $p.Guid -StartDate $start -EndDate $end -ErrorAction SilentlyContinue
$actualReviewers = $activity | Select-Object -ExpandProperty ActionAppliedBy -Unique
$declared = @($p.Reviewers)
[PSCustomObject]@{
Policy = $p.Name
PolicyGuid = $p.Guid
DeclaredReviewers = $declared
ActualReviewersDays = $DaysBack
ActualReviewers = $actualReviewers
DeclaredButNeverActed = $declared | Where-Object { $_ -notin $actualReviewers }
ActedButNotDeclared = $actualReviewers | Where-Object { $_ -notin $declared }
CcAdminsOverlap = $declared | Where-Object { $_ -in $ccAdmins }
CcInvestigatorsOverlap = $declared | Where-Object { $_ -in $ccInvest }
}
}
$jsonPath = Join-Path $OutputDirectory "reviewer-reconciliation-$ts.json"
$report | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath -Encoding UTF8
$hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
Set-Content -Path "$jsonPath.sha256" -Value $hash -Encoding ASCII
$report
}
Use the
ActedButNotDeclaredcolumn as a flag: anyone who tagged messages on a policy but is not in the policy's declared reviewer list is either (a) a permitted CC Admin acting on behalf of a reviewer — record the rationale, or (b) a permission gap that needs Control 1.10 + Control 2.8 (Access Control and Segregation of Duties) follow-up. Anyone inDeclaredButNeverActedfor 90+ days is a reviewer-rotation candidate — surface them in your monthly attestation.