Control 1.7 — PowerShell Setup: Comprehensive Audit Logging and Compliance Automation
Scope. This playbook automates the audit capture and preservation plane for Control 1.7 across Microsoft Purview Audit (Standard, Premium, and the 10-Year Audit Log Retention add-on), Microsoft 365 Copilot and agent record types (
CopilotInteraction,ConnectedAIAppInteraction,AIAppInteraction(PAYG),MicrosoftCopilotStudio), Audit pay-as-you-go (PAYG) AI App Interaction enablement, Dataverse per-table auditing for the six Copilot Studio entities, the Microsoft Graph audit log API (the strategic forward path asSearch-UnifiedAuditLogenters maintenance), the Microsoft 365 Substrate content tier reachable through DSPM for AI / eDiscovery Premium / Communication Compliance, and an SEC 17a-4(f)-compatible preservation pipeline into Azure immutable blob storage in US financial services tenants. It assumes you have already read../../_shared/powershell-baseline.md(referenced below as BL-§N) and the parent control specification../../../controls/pillar-1-security/1.7-comprehensive-audit-logging-and-compliance.md.What this playbook is. A reproducible, fail-closed audit-plane harness that (a) pins module / CLI versions; (b) bootstraps two distinct connections (Exchange Online and Security & Compliance PowerShell — the same cmdlet name returns different and silently misleading values from the wrong session) plus a Microsoft Graph context for the new Audit Search API and a Power Apps Administration session for Dataverse per-table audit; (c) verifies tenant-level unified audit log ingestion, Audit (Premium) license entitlement, the 10-Year Audit Log Retention add-on, and PAYG opt-in for
AIAppInteraction; (d) enumerates the four Copilot / agent record types with paginated, large-set, date-windowed search; (e) confirms Dataverse per-entity audit is on for the six Copilot Studio entities (bot,botcomponent,botcomponentcollection,conversationtranscript,aiplugin,aipluginauth) in every applicable environment; (f) verifies a 17a-4(f) preservation pipeline is exporting unified audit results into an attested archive (Azure immutable blob with a locked time-based retention policy, or a third-party books-and-records connector); (g) verifies the Sentinel Office 365 connector is ingestingCopilotInteractionevents end-to-end; and (h) emits a SHA-256-hashed evidence manifest aligned to the Control 3.1 canonical reconciliation schema.What this playbook is not. It is not, by itself, a SEC Rule 17a-4(f)-compliant electronic recordkeeping system. Microsoft 365 Audit (including the 10-year add-on) is record-CAPTURE operational telemetry; preservation requires either WORM storage of the books-and-records record set (e.g., Azure immutable blob storage with a time-based retention policy in a locked state — Cohasset-attested for SEC 17a-4(f), CFTC 1.31, and FINRA 4511 — see Microsoft Learn: Immutable storage for Azure blob data) or the audit-trail alternative under the October 2022 amendments to Rule 17a-4(f) (compliance date 3 May 2023). This harness verifies that the export pipeline exists and is healthy; it does not, by itself, guarantee attestation, eliminate preservation risk, or replace the Designated Executive Officer (DEO) representation or Designated Third Party (DTP) undertaking required under the audit-trail alternative.
Hedged language reminder. Output of this harness supports compliance with FINRA Rule 4511, FINRA Rule 3110, FINRA Regulatory Notice 25-07 (RFC), SEC Rule 17a-3, SEC Rule 17a-4(b)(4) / 17a-4(f), SOX 302/404, GLBA 501(b), OCC Bulletin 2011-12, Fed SR 11-7, NIST AI RMF MEASURE 2.7 / GOVERN 1.4, CFTC Rule 1.31, and NYDFS 23 NYCRR 500.06. It does not, by itself, ensure a passing examination, guarantee completeness of the books-and-records record set, or eliminate the risk that a record type is silently re-classified between Audit Standard, Audit Premium, and Audit PAYG between Microsoft releases. Implementation requires that organizations verify Audit Premium licensing, PAYG opt-in scope, and Dataverse audit settings at every change window, and that they treat any preview surface (the Microsoft Graph audit search query API,
agentSignIn,MicrosoftServicePrincipalSignInLogs) as additive evidence rather than the sole source of truth.
| Field | Value |
|---|---|
| Control ID | 1.7 |
| Pillar | 1 — Security |
| Playbook | PowerShell Setup |
| PowerShell Edition | 7.4 LTS Core (orchestrator, Graph, Az.Storage); 5.1 Desktop (Power Apps Administration sub-shell for Dataverse audit, JSON-bridged); both Desktop and Core supported for Connect-ExchangeOnline and Connect-IPPSSession (verify against your CAB-pinned ExchangeOnlineManagement version) |
| Sovereign Clouds | Commercial, GCC, GCC High, DoD — see §2 sovereign matrix; sovereign feature gaps for PAYG and the new Audit Search Graph API documented in §5 and §7 |
| Last UI Verified | April 2026 |
| Companion Playbooks | portal-walkthrough.md · verification-testing.md (planned) · troubleshooting.md |
| Related Controls | 1.5 · 1.6 · 1.10 · 1.19 · 2.6 · 2.12 · 3.4 · 3.9 |
§0 — Wrong-shell trap and audit-plane false-clean defects (READ FIRST)
The defining fact of Control 1.7. Audit configuration cmdlets in ExchangeOnlineManagement exist in both the Exchange Online (Connect-ExchangeOnline) and the Security & Compliance (Connect-IPPSSession) PowerShell connections, with the same names and different return semantics. Per Microsoft Learn, Get-AdminAuditLogConfig.UnifiedAuditLogIngestionEnabled always returns $false from the Security & Compliance session, regardless of the actual tenant state, because the property is a property of the Exchange Online configuration object — not the compliance one. A script that does not assert which session it is reading from is producing audit-grade misinformation.
A script that ignores this reality produces a false-clean audit posture — the worst Control 1.7 outcome. False-clean audit posture means examiners are told records were captured when they were not, breaks SEC 17a-4(b)(4) communications-retention attestation for broker-dealer Copilot rollouts, leaves FINRA Rule 3110 supervisory queues operating on incomplete sets, and invalidates the Cohasset attestation chain that downstream SEC 17a-4(f) preservation depends on.
Why this section exists. Eight classes of silent failure produce false-clean audit-plane output in Control 1.7 specifically:
- Wrong-shell trap on
Get-AdminAuditLogConfig. Always returns$falseforUnifiedAuditLogIngestionEnabledfrom the Security & Compliance session. Helpers in §3 hard-fail unless the active session URI matchesoutlook.office365.(com|us). - Invalid
-RecordTypereturns zero rows silently. Earlier playbooks shipped-RecordType PowerPlatformAdminActivity; that name is not in theAuditLogRecordTypeenumeration. Some module versions silently return zero rows for invalid RecordType values. The canonical names areMicrosoftCopilotStudio,PowerPlatformAdminEnvironment,PowerPlatformAdministratorActivity,PowerPlatformServiceActivity,MicrosoftFlow,PowerAppsApp,MicrosoftPowerBIAudits,CopilotInteraction,ConnectedAIAppInteraction,AIAppInteraction. Validate against the live enum at runtime. Search-UnifiedAuditLog-ResultSizetruncation. Default-ResultSize 100; per-call cap 5,000; per-session ceiling 50,000. Single-shot calls silently drop records past those limits. Use-SessionCommand ReturnLargeSetwith a fresh-SessionIdper date window, and split windows when the session ceiling is approached.Search-UnifiedAuditLogis in maintenance. Microsoft has signalled the Audit Search Graph API (/security/auditLog/queries) as the strategic forward path.Search-AdminAuditLogwas already deprecated 15 September 2024. New investment should target the Graph API; this playbook ships both paths and labels the legacy one.- Audit Premium silently downgrades to Audit Standard. If a user does not have an Audit (Premium) entitlement, their
CopilotInteractionrecords fall back to 180-day retention regardless of any custom retention policy. License reconciliation (§4) must be tenant-wide before retention claims. - PAYG record types require explicit opt-in.
AIAppInteractionand someConnectedAIAppInteractionscenarios (non-Microsoft AI apps surfaced via Connected AI App) fall under Audit pay-as-you-go billing. Until the PAYG meter is turned on, these records do not flow at all. - Dataverse per-table audit is environment-scoped. Tenant-level "Start auditing" enables the capability; per-table auditing on the six Copilot Studio entities must be enabled per environment, per table. A solution-installed entity in a new environment does not inherit table audit settings from the source environment.
- Single-credential read+write. Running this harness with the same service principal that modifies audit configuration breaks SOX 404 separation of duties — the principal that produces the evidence could be the principal that altered the configuration the evidence describes. Use a separate
agt17-audit-readeraudit-only principal.
Top false-clean defects unique to audit-plane automation.
| # | Defect | What it looks like | How this playbook traps it |
|---|---|---|---|
| 1 | Get-AdminAuditLogConfig from Connect-IPPSSession |
UnifiedAuditLogIngestionEnabled = False regardless of truth |
§3 Assert-FsiExoSession hard-fails on session URI mismatch |
| 2 | Search-UnifiedAuditLog -RecordType PowerPlatformAdminActivity |
Returns zero rows; exit 0 | §7 enum validation against [Microsoft.Office.CompliancePolicy.PSCmdlets.AuditRecordType] |
| 3 | Search-UnifiedAuditLog without pagination |
Caps at 100 / 5,000 / 50,000 silently | §7 Search-FsiAuditLogPaged with ReturnLargeSet and date-window splitter |
| 4 | Custom retention policy without per-user Audit Premium | Records fall to 180 days | §4 Get-FsiAuditPremiumStatus cross-reconciles SkuId set |
| 5 | AIAppInteraction queried but PAYG never enabled |
Empty result interpreted as "no shadow AI" | §5 Get-FsiAuditPaygEnablement reads PAYG opt-in state |
| 6 | Mailbox audit bypass set on a regulated user | Search-UnifiedAuditLog returns no mailbox events for that user |
§6 Get-FsiMailboxAuditBypass enumerates non-zero bypass associations |
| 7 | Tenant-level Dataverse audit on, per-table audit off | UAL has Dataverse signal but content lacks before/after values | §9 Get-FsiCopilotStudioDataverseAudit walks six entities per environment |
| 8 | "Audit captured = books-and-records preserved" assumption | 17a-4(f) attestation chain broken | §10 Test-FsiAuditTo17a4Preservation verifies immutable container with locked time-based policy |
| 9 | Sentinel connector enabled but no analytics rule on CopilotInteraction |
Events ingested, never alerted | §11 Test-FsiAuditToSentinel verifies connector + rule presence |
| 10 | Connect-MgGraph without -Environment USGov on a sovereign tenant |
Audit Search Graph API returns commercial-tenant scope; zero results | §2 sovereign bootstrap fails closed when cloud discriminator mismatches |
Required shell guard (run this at the top of every Control 1.7 session).
# Save as: scripts/Assert-Agt17Shell.ps1
[CmdletBinding()]
[OutputType([void])]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt [version]'7.4.0') {
Write-Error "Control 1.7 orchestrator requires PowerShell 7.4 LTS Core (pwsh). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). The Power Apps Administration leg in §9 will be spawned into a Windows PowerShell 5.1 child process per BL-§2."
exit 2
}
# Stale Windows PowerShell module shadowing trap
$bad = Get-Module -ListAvailable -Name 'Microsoft.Graph' |
Where-Object { $_.Version.Major -lt 2 }
if ($bad) {
Write-Error "Stale v1 Microsoft.Graph visible: $($bad.Version -join ', '). Uninstall before continuing — autoload would silently shadow the pinned v2 modules and produce wrong-shape audit output."
exit 2
}
Write-Verbose "Control 1.7 shell guard passed: pwsh $($PSVersionTable.PSVersion)"
§1 — Module, CLI, and permission matrix
Why this section exists. Audit-plane evidence is reproducible only when versions are declared and emitted into the SHA-256-hashed manifest (§12). Microsoft ships breaking shape changes across ExchangeOnlineManagement minor versions on the audit cmdlet surface, and the Microsoft Graph audit search query API is itself preview-adjacent — pin the SDK version your CAB has approved. See BL-§1 for the canonical pinning pattern.
1.1 Pinned PowerShell modules
| Module | Edition | Approved Pinning Pattern | Purpose |
|---|---|---|---|
ExchangeOnlineManagement |
Core or Desktop (verify per version) | Install-Module ExchangeOnlineManagement -RequiredVersion '<CAB version>' -Repository PSGallery -Scope CurrentUser |
Connect-ExchangeOnline for Get/Set-AdminAuditLogConfig, Get/Set-MailboxAuditBypassAssociation. Separate Connect-IPPSSession for compliance cmdlets (Search-UnifiedAuditLog, Get-UnifiedAuditLogRetentionPolicy, New-ComplianceCase, New-ComplianceSearch). See Connect to Exchange Online PowerShell and Connect to Security & Compliance PowerShell. |
Microsoft.Graph.Reports |
Core | Install-Module Microsoft.Graph.Reports -RequiredVersion '<CAB version>' -Repository PSGallery -Scope CurrentUser |
Get-MgAuditLogDirectoryAudit, Get-MgAuditLogSignIn — strategic forward path replacing legacy Search-AdminAuditLog. See Microsoft Graph audit logs. |
Microsoft.Graph.Beta.Reports |
Core | Same pattern; beta | Audit Search query API (/security/auditLog/queries) — preview surface for the new Microsoft 365 audit search experience. See auditLogQuery resource type (beta). Treat as additive evidence pending GA. |
Microsoft.Graph.Authentication |
Core | Pinned with the meta module | Connect-MgGraph with sovereign -Environment per BL-§3. |
Microsoft.Graph.Users / Microsoft.Graph.Identity.DirectoryManagement |
Core | Pinned with the meta module | License entitlement reconciliation (§4) and tenant SKU enumeration. |
Microsoft.PowerApps.Administration.PowerShell |
Desktop only (PS 5.1) per BL-§2 | Install-Module Microsoft.PowerApps.Administration.PowerShell -RequiredVersion '<CAB version>' -Repository PSGallery -Scope CurrentUser |
Enumerate Power Platform environments for §9 Dataverse per-table audit walk. Silently returns empty arrays under PowerShell 7 — spawn a 5.1 child process. |
Az.Accounts, Az.Storage |
Core | Install-Module Az.Storage -RequiredVersion '<CAB version>' |
§10 17a-4(f) preservation pipeline: enumerate immutable containers, verify time-based retention is locked, validate export blobs. |
Az.OperationalInsights, Az.SecurityInsights |
Core | Same pattern | §11 Sentinel Office 365 connector + analytics rule discovery. |
Microsoft Power Platform CLI (pac) |
n/a | Pinned via MSI / dotnet tool install --version |
Optional alternative for §9 Dataverse audit (pac admin list, pac org settings list). Sovereign-aware via pac auth create --cloud UsGov | UsGovHigh | DoD. |
1.2 Permission matrix (least-privilege; separate read and write principals)
| Surface | Read role | Write role | Notes |
|---|---|---|---|
| Tenant unified audit log status | View-Only Audit Logs (Exchange role) |
Audit Logs (Exchange role); enabling/disabling requires Organization Configuration |
Compliance Admin alone is not sufficient to author retention policies — see Microsoft Learn. |
| Audit search (legacy) | View-Only Audit Logs |
n/a | Assigned in Exchange admin center role groups. |
| Audit search (Graph) | AuditLog.Read.All (delegated or app) |
n/a | Graph application permission requires admin consent; verify (Get-MgContext).Scopes after connect. |
| Audit retention policies | View-Only Audit Logs |
Organization Configuration (Exchange) |
Get/New/Set/Remove-UnifiedAuditLogRetentionPolicy. |
| Mailbox audit bypass | View-Only Recipients |
Recipient Management |
Get/Set-MailboxAuditBypassAssociation. |
| License entitlement | Directory.Read.All, Organization.Read.All |
n/a | Get-MgSubscribedSku, Get-MgUser -Property AssignedLicenses. |
| Dataverse per-table audit | Power Platform Administrator (read) |
System Administrator on the environment (write) |
Requires Dataverse Web API or Microsoft.PowerApps.Administration.PowerShell. |
| 17a-4(f) preservation pipeline | Storage Blob Data Reader on the immutable container |
Storage Account Contributor on the storage account; immutable policy lock is one-way |
Time-based retention policies must be in the Locked state to satisfy 17a-4(f). |
| Sentinel forwarding | Microsoft Sentinel Reader |
Microsoft Sentinel Contributor |
Office 365 connector status + analytics rule enumeration. |
SOX 404 separation of duties. This playbook assumes agt17-audit-reader (read-only across all surfaces above) is distinct from any principal that mutates audit configuration. The reader principal authenticates with a certificate (no client secret) per BL-§3.
§2 — Sovereign-aware bootstrap
Why this section exists. A Connect-ExchangeOnline without -ExchangeEnvironmentName O365USGovGCCHigh or a Connect-MgGraph without -Environment USGov on a sovereign tenant authenticates against commercial endpoints and returns zero results with exit code 0. See BL-§3 for the canonical sovereign matrix.
2.1 Cloud profile resolver
function Resolve-Agt17CloudProfile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud
)
$map = @{
Commercial = @{ Exo = 'O365Default'; Ipps = 'O365Default'; Graph = 'Global'; Az = 'AzureCloud'; Pac = 'Public' }
GCC = @{ Exo = 'O365USGovGCCHigh'; Ipps = 'O365USGovGCCHigh'; Graph = 'USGov'; Az = 'AzureUSGovernment'; Pac = 'UsGov' } # GCC commonly uses commercial endpoints; verify per tenant
GCCHigh = @{ Exo = 'O365USGovGCCHigh'; Ipps = 'O365USGovGCCHigh'; Graph = 'USGov'; Az = 'AzureUSGovernment'; Pac = 'UsGovHigh' }
DoD = @{ Exo = 'O365USGovDoD'; Ipps = 'O365USGovDoD'; Graph = 'USGovDOD'; Az = 'AzureUSGovernment'; Pac = 'DoD' }
}
[pscustomobject]$map[$Cloud]
}
2.2 Two distinct connections (interactive flow)
function Connect-Agt17Audit {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud,
[Parameter(Mandatory)] [string]$UserPrincipalName
)
$profile = Resolve-Agt17CloudProfile -Cloud $Cloud
if ($PSCmdlet.ShouldProcess('Exchange Online','Connect-ExchangeOnline')) {
Connect-ExchangeOnline -ExchangeEnvironmentName $profile.Exo -UserPrincipalName $UserPrincipalName -ShowBanner:$false
}
if ($PSCmdlet.ShouldProcess('Security & Compliance','Connect-IPPSSession')) {
Connect-IPPSSession -ConnectionUri (
switch ($Cloud) {
'Commercial' { 'https://ps.compliance.protection.outlook.com/PowerShell-LiveId' }
'GCC' { 'https://ps.compliance.protection.outlook.com/PowerShell-LiveId' }
'GCCHigh' { 'https://ps.compliance.protection.office365.us/PowerShell-LiveId' }
'DoD' { 'https://l5.ps.compliance.protection.office365.us/PowerShell-LiveId' }
}
) -UserPrincipalName $UserPrincipalName
}
if ($PSCmdlet.ShouldProcess('Microsoft Graph','Connect-MgGraph')) {
Connect-MgGraph -Environment $profile.Graph -Scopes @(
'AuditLog.Read.All','Directory.Read.All','Organization.Read.All','User.Read.All','SecurityEvents.Read.All'
) -NoWelcome
}
if ($PSCmdlet.ShouldProcess('Azure','Connect-AzAccount')) {
Connect-AzAccount -Environment $profile.Az | Out-Null
}
}
2.3 Service-principal-with-certificate flow (recommended for unattended)
Do not ship plaintext client secrets. Use a certificate from Key Vault or the local certificate store; rotate per BL-§4.
function Connect-Agt17AuditAsApp {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud,
[Parameter(Mandatory)] [string]$AppId,
[Parameter(Mandatory)] [string]$CertificateThumbprint,
[Parameter(Mandatory)] [string]$Organization, # e.g., 'contoso.onmicrosoft.us'
[Parameter(Mandatory)] [string]$TenantId
)
$profile = Resolve-Agt17CloudProfile -Cloud $Cloud
if ($PSCmdlet.ShouldProcess('Exchange Online','Connect-ExchangeOnline (cert)')) {
Connect-ExchangeOnline -ExchangeEnvironmentName $profile.Exo `
-AppId $AppId -CertificateThumbprint $CertificateThumbprint -Organization $Organization -ShowBanner:$false
}
# NOTE (April 2026): Connect-IPPSSession certificate-based app-only auth is documented for commercial;
# parity in sovereign clouds shifts between module versions — verify per release. Until parity is confirmed
# in your tenant, run §7 compliance searches under a delegated session.
if ($PSCmdlet.ShouldProcess('Microsoft Graph','Connect-MgGraph (cert)')) {
Connect-MgGraph -Environment $profile.Graph -ClientId $AppId -CertificateThumbprint $CertificateThumbprint -TenantId $TenantId -NoWelcome
}
# Verify granted scopes match requested scopes (BL-§3 false-clean trap)
$missing = @('AuditLog.Read.All','Directory.Read.All','Organization.Read.All') |
Where-Object { $_ -notin (Get-MgContext).Scopes }
if ($missing) { throw "Granted Graph scopes missing: $($missing -join ', '). Admin consent not granted on this app." }
}
2.4 Defensive session-URI assertion (called by every helper)
function Assert-FsiExoSession {
[CmdletBinding()]
param()
$conn = Get-ConnectionInformation | Where-Object State -eq 'Connected' |
Where-Object { $_.ConnectionUri -match 'outlook\.office365\.(com|us)' } |
Select-Object -First 1
if (-not $conn) {
throw "No Exchange Online session detected. Get-AdminAuditLogConfig from a Security & Compliance session ALWAYS reports UnifiedAuditLogIngestionEnabled=False. Run Connect-Agt17Audit first."
}
return $conn
}
§3 — Tenant unified audit log enablement (Get-FsiAuditCopilotEnablement)
Why this section exists. Without unified audit log ingestion enabled at the tenant, none of the Copilot or agent record types in §7 ever materialise. Status checks must run from the EXO session (BL-§0; §0 trap #1).
3.1 Read-only enablement check
function Get-FsiAuditCopilotEnablement {
<#
.SYNOPSIS
Reports whether unified audit log ingestion is enabled and Copilot/agent record types are flowing.
.OUTPUTS
[pscustomobject] with Status in {Clean, Anomaly, Pending, NotApplicable, Error}.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[int]$LookbackHours = 24,
[string[]]$RecordTypes = @('CopilotInteraction','ConnectedAIAppInteraction','MicrosoftCopilotStudio')
)
try {
$conn = Assert-FsiExoSession
$cfg = Get-AdminAuditLogConfig
$ingestEnabled = [bool]$cfg.UnifiedAuditLogIngestionEnabled
$start = (Get-Date).ToUniversalTime().AddHours(-$LookbackHours)
$end = (Get-Date).ToUniversalTime()
$flowing = foreach ($rt in $RecordTypes) {
$hit = Search-UnifiedAuditLog -StartDate $start -EndDate $end -RecordType $rt -ResultSize 1 -ErrorAction SilentlyContinue
[pscustomobject]@{ RecordType = $rt; SeenInLookback = [bool]$hit }
}
$status = if (-not $ingestEnabled) { 'Anomaly' }
elseif ($flowing.Where({-not $_.SeenInLookback}).Count -gt 0) { 'Pending' }
else { 'Clean' }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Get-FsiAuditCopilotEnablement'
Status = $status
SessionUri = $conn.ConnectionUri
UnifiedAuditLogIngestionEnabled = $ingestEnabled
AdminAuditLogEnabled = [bool]$cfg.AdminAuditLogEnabled
RecordTypeFlow = $flowing
LookbackStartUtc = $start.ToString('o')
LookbackEndUtc = $end.ToString('o')
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = if (-not $ingestEnabled) { 'Tenant unified audit log ingestion is OFF — Copilot/agent records are not captured. Coordinate enablement per portal-walkthrough.md.' }
elseif ($status -eq 'Pending') { 'Ingestion enabled but one or more Copilot/agent RecordTypes show no activity in the lookback window. Could be propagation lag (allow up to 60 minutes after enablement) or genuinely zero usage; do not stamp Clean until corroborated.' }
else { 'Ingestion enabled and Copilot/agent record types observed in lookback.' }
}
} catch {
[pscustomobject]@{
ControlId = '1.7'; Helper = 'Get-FsiAuditCopilotEnablement'; Status = 'Error'
ErrorMessage = $_.Exception.Message; CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
}
}
}
3.2 Idempotent enable (mutation — confirm before applying)
function Enable-FsiUnifiedAudit {
<#
.SYNOPSIS
Idempotent enablement of unified audit log ingestion. Reads current state first; only mutates on drift.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([string]$EvidencePath = ".\evidence\1.7")
$conn = Assert-FsiExoSession
$before = Get-AdminAuditLogConfig
if ($before.UnifiedAuditLogIngestionEnabled) {
Write-Verbose "Already enabled; no mutation."
return [pscustomobject]@{ Status='Clean'; Action='NoOp'; SessionUri=$conn.ConnectionUri }
}
if ($PSCmdlet.ShouldProcess('Tenant', 'Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true')) {
Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true
Write-Warning 'Submitted. Per Microsoft Learn, allow up to 60 minutes for configuration propagation; ingestion of events takes longer. Re-run Get-FsiAuditCopilotEnablement after the propagation window before stamping Clean.'
return [pscustomobject]@{ Status='Pending'; Action='Set'; SessionUri=$conn.ConnectionUri }
}
}
Always invoke first with -WhatIf (BL-§4).
§4 — Audit (Premium) and 10-Year Audit Log Retention add-on entitlement (Get-FsiAuditPremiumStatus)
Why this section exists. A custom 1-year or 10-year retention policy in §7 silently downgrades to 180-day Audit Standard fallback for any user lacking the Audit (Premium) entitlement. The single most common Control 1.7 finding is "retention policy in place, license gap means actual retention < policy". Reconcile licenses before claiming retention coverage to examiners.
function Get-FsiAuditPremiumStatus {
<#
.SYNOPSIS
Reconciles per-user Audit (Premium) and 10-Year Audit Log Retention add-on entitlement against the
Copilot population. Any Copilot user without Audit Premium is a finding.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param([string]$EvidencePath = ".\evidence\1.7")
try {
# SkuPartNumber values change occasionally — verify against Get-MgSubscribedSku in your tenant.
$auditPremiumSkus = @(
'SPE_E5', # Microsoft 365 E5
'ENTERPRISEPREMIUM', # Office 365 E5
'INFORMATION_PROTECTION_COMPLIANCE', # Microsoft Purview Suite (verify)
'M365_E5_COMPLIANCE', # legacy
'M365_E5_AUDIT' # E5 eDiscovery and Audit add-on (verify)
)
$tenYearAddOn = @('M365_AUDIT_PREMIUM_10YR') # verify SkuPartNumber in tenant
$copilotSkus = @('Microsoft_365_Copilot')
$skus = Get-MgSubscribedSku | Select-Object SkuId, SkuPartNumber
$report = Get-MgUser -All -Property Id,UserPrincipalName,AssignedLicenses | ForEach-Object {
$assigned = $_.AssignedLicenses.SkuId
$names = $skus | Where-Object SkuId -in $assigned | Select-Object -ExpandProperty SkuPartNumber
[pscustomobject]@{
UserPrincipalName = $_.UserPrincipalName
HasCopilot = [bool]($names | Where-Object { $_ -in $copilotSkus })
HasAuditPremium = [bool]($names | Where-Object { $_ -in $auditPremiumSkus })
Has10YearRetention = [bool]($names | Where-Object { $_ -in $tenYearAddOn })
}
}
$gaps = $report | Where-Object { $_.HasCopilot -and (-not $_.HasAuditPremium -or -not $_.Has10YearRetention) }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Get-FsiAuditPremiumStatus'
Status = if ($gaps) { 'Anomaly' } else { 'Clean' }
CopilotUserCount = ($report | Where-Object HasCopilot).Count
AuditPremiumGap = ($gaps | Where-Object { -not $_.HasAuditPremium }).Count
TenYearGap = ($gaps | Where-Object { -not $_.Has10YearRetention }).Count
GapSample = $gaps | Select-Object -First 25
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = if ($gaps) { 'Copilot users without Audit Premium silently fall back to 180-day retention regardless of any custom retention policy. Resolve license gaps before claiming retention coverage to examiners.' } else { 'Per-user license entitlement aligned with retention policy.' }
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Get-FsiAuditPremiumStatus'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
§5 — Audit pay-as-you-go (PAYG) AI App Interaction enablement (Get-FsiAuditPaygEnablement)
Why this section exists. Per Microsoft Learn (April 2026), AIAppInteraction (and some ConnectedAIAppInteraction scenarios for non-Microsoft AI apps surfaced via Connected AI App) is captured under Audit pay-as-you-go billing. Until the PAYG meter is opted in, these records do not flow at all — and an empty Search-UnifiedAuditLog -RecordType AIAppInteraction result is misread as "no shadow AI use" instead of "PAYG capture is off". PAYG-captured records have a 180-day retention floor regardless of Audit Premium licensing.
function Get-FsiAuditPaygEnablement {
<#
.SYNOPSIS
Reports the Audit PAYG opt-in state for AIAppInteraction (and confirms an Azure subscription is bound).
.NOTES
Microsoft Learn: 'Manage pay-as-you-go billing for Microsoft Purview' for the canonical opt-in path
(Purview portal > Settings > Billing > Pay-as-you-go services). The portal is the source of truth;
this helper inspects observable signals only (record-type flow + Azure billing binding presence).
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud = 'Commercial',
[int]$LookbackDays = 30
)
try {
# Sovereign-cloud applicability: Audit PAYG availability in GCC High / DoD has historically lagged commercial.
# Verify against current Microsoft Learn before stamping NotApplicable.
if ($Cloud -in 'GCCHigh','DoD') {
return [pscustomobject]@{
ControlId='1.7'; Helper='Get-FsiAuditPaygEnablement'; Status='NotApplicable'
Cloud=$Cloud; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o')
Note='Audit PAYG (AIAppInteraction) availability lags in sovereign clouds as of April 2026. Verify Microsoft Learn before relying on this RecordType in GCC High / DoD; until parity, treat shadow-AI capture as Defender for Cloud Apps responsibility.'
}
}
$start = (Get-Date).ToUniversalTime().AddDays(-$LookbackDays)
$end = (Get-Date).ToUniversalTime()
$hit = Search-UnifiedAuditLog -StartDate $start -EndDate $end -RecordType AIAppInteraction -ResultSize 1 -ErrorAction SilentlyContinue
$observed = [bool]$hit
# Azure billing binding (best-effort signal)
$billingBound = $null
try {
$billingBound = [bool](Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 1)
} catch { $billingBound = $null }
$status = if ($observed) { 'Clean' }
elseif ($billingBound -eq $false) { 'Anomaly' }
else { 'Pending' }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Get-FsiAuditPaygEnablement'
Status = $status
Cloud = $Cloud
AIAppInteractionObservedInLookback = $observed
AzureBillingBound = $billingBound
LookbackDays = $LookbackDays
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = if ($status -eq 'Anomaly') { 'No Azure subscription detected for PAYG meter binding. AIAppInteraction capture is almost certainly off; opt in via Purview portal > Settings > Billing > Pay-as-you-go services.' }
elseif ($status -eq 'Pending') { 'PAYG opt-in cannot be confirmed from PowerShell alone; verify in Purview portal. Empty AIAppInteraction results may indicate either no shadow-AI use OR PAYG off — do not interpret as Clean without portal confirmation.' }
else { 'AIAppInteraction records observed; PAYG capture is active. Note: PAYG-captured records carry a 180-day retention floor regardless of Audit Premium licensing.' }
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Get-FsiAuditPaygEnablement'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
§6 — Mailbox audit bypass review (Get-FsiMailboxAuditBypass)
Why this section exists. A mailbox with a non-zero AuditBypassEnabled association does not emit mailbox events to the unified audit log — even if the user is otherwise in scope for Audit Premium and Copilot. Bypass is occasionally used legitimately for service accounts, but a regulated Copilot user with bypass enabled is a FINRA Rule 4511 / SEC 17a-4 finding.
function Get-FsiMailboxAuditBypass {
[CmdletBinding()]
[OutputType([pscustomobject])]
param([string[]]$RegulatedUserPrincipalNames)
try {
Assert-FsiExoSession | Out-Null
$bypassed = Get-MailboxAuditBypassAssociation -ResultSize Unlimited |
Where-Object { $_.AuditBypassEnabled -eq $true } |
Select-Object Identity, AuditBypassEnabled, WhenChangedUTC
$regulatedHits = if ($RegulatedUserPrincipalNames) {
$bypassed | Where-Object { $_.Identity -in $RegulatedUserPrincipalNames }
} else { @() }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Get-FsiMailboxAuditBypass'
Status = if ($regulatedHits) { 'Anomaly' } elseif ($bypassed) { 'Pending' } else { 'Clean' }
BypassedCount = ($bypassed | Measure-Object).Count
RegulatedBypassed = $regulatedHits
BypassedSample = $bypassed | Select-Object -First 25
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = if ($regulatedHits) { 'Regulated user(s) have mailbox audit bypass enabled — mailbox-side audit is suppressed. Remove bypass and document the change in the change-management ticket.' }
elseif ($bypassed) { 'Service-account bypass detected. Review the list against the approved service-account inventory; remove any unrecognised entries.' }
else { 'No mailbox audit bypass associations.' }
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Get-FsiMailboxAuditBypass'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
To remove bypass on a regulated user (mutation, idempotent get-then-set, confirm prompt):
function Remove-FsiMailboxAuditBypass {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([Parameter(Mandatory)] [string]$Identity)
Assert-FsiExoSession | Out-Null
$current = Get-MailboxAuditBypassAssociation -Identity $Identity -ErrorAction Stop
if (-not $current.AuditBypassEnabled) {
return [pscustomobject]@{ Status='Clean'; Action='NoOp'; Identity=$Identity }
}
if ($PSCmdlet.ShouldProcess($Identity,'Set-MailboxAuditBypassAssociation -AuditBypassEnabled $false')) {
Set-MailboxAuditBypassAssociation -Identity $Identity -AuditBypassEnabled $false
}
}
§7 — Copilot / agent record-type queries (legacy + Graph forward path)
Why this section exists. The Search-UnifiedAuditLog cmdlet is in maintenance — Search-AdminAuditLog was already deprecated 15 September 2024 — and Microsoft's strategic forward path is the Audit Search Graph API (/security/auditLog/queries). New automation should target Graph; this playbook ships both paths and labels the legacy one. See Microsoft Graph audit logs reference and auditLogQuery resource type (beta).
7.1 Legacy path: paginated, large-set, date-windowed search
function Search-FsiAuditLogPaged {
<#
.SYNOPSIS
Paginated Search-UnifiedAuditLog wrapper. Validates RecordType enum, uses ReturnLargeSet with a fresh
SessionId per date window, and splits the window when the 50,000 session ceiling is approached.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [datetime]$StartDate,
[Parameter(Mandatory)] [datetime]$EndDate,
[Parameter(Mandatory)] [string[]]$RecordTypes,
[string[]]$UserIds,
[string[]]$Operations,
[int]$PageSize = 5000
)
Assert-FsiExoSession | Out-Null
try {
$valid = [Enum]::GetNames([Microsoft.Office.CompliancePolicy.PSCmdlets.AuditRecordType])
$invalid = $RecordTypes | Where-Object { $_ -notin $valid }
if ($invalid) { throw "Invalid RecordType(s): $($invalid -join ', ')" }
} catch [System.Management.Automation.RuntimeException] {
Write-Warning "Could not enumerate AuditRecordType statically — proceeding (older module). Verify results > 0 against a known-good event before trusting."
}
$results = New-Object System.Collections.ArrayList
$sessionId = [guid]::NewGuid().ToString()
$window = New-TimeSpan -Days 1
$cursor = $StartDate
while ($cursor -lt $EndDate) {
$windowEnd = if ($cursor.Add($window) -lt $EndDate) { $cursor.Add($window) } else { $EndDate }
$windowResults = New-Object System.Collections.ArrayList
do {
$batch = Search-UnifiedAuditLog -StartDate $cursor -EndDate $windowEnd `
-RecordType $RecordTypes -UserIds $UserIds -Operations $Operations `
-ResultSize $PageSize -SessionId $sessionId -SessionCommand ReturnLargeSet
if ($batch) { [void]$windowResults.AddRange($batch) }
} while ($batch -and $batch.Count -gt 0)
if ($windowResults.Count -ge 49000) {
Write-Warning "Window $cursor..$windowEnd hit session ceiling ($($windowResults.Count)). Halving window and retrying."
$window = [TimeSpan]::FromTicks([Math]::Max(1, $window.Ticks / 2))
continue
}
[void]$results.AddRange($windowResults)
$cursor = $windowEnd
$sessionId = [guid]::NewGuid().ToString()
}
return $results
}
# Examples — the four Copilot/agent record types
$copilot = Search-FsiAuditLogPaged -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -RecordTypes 'CopilotInteraction'
$connectedAI = Search-FsiAuditLogPaged -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -RecordTypes 'ConnectedAIAppInteraction'
$payg = Search-FsiAuditLogPaged -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -RecordTypes 'AIAppInteraction' # PAYG — see §5
$studio = Search-FsiAuditLogPaged -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -RecordTypes 'MicrosoftCopilotStudio'
7.2 Forward path: Microsoft Graph audit endpoints
# Directory audits (admin and configuration changes)
Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge $((Get-Date).AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ'))" -Top 100
# Sign-in logs (correlate agent identity sign-ins per Control 1.2 §8)
Get-MgAuditLogSignIn -Filter "createdDateTime ge $((Get-Date).AddDays(-1).ToString('yyyy-MM-ddTHH:mm:ssZ'))" -Top 100
# Audit Search query API (beta) — strategic forward path for unified audit search
$query = @{
'@odata.type' = '#microsoft.graph.security.auditLogQuery'
displayName = 'Control 1.7 — CopilotInteraction last 24h'
filterStartDateTime = (Get-Date).AddHours(-24).ToString('o')
filterEndDateTime = (Get-Date).ToString('o')
recordTypeFilters = @('copilotInteraction')
operationFilters = @()
}
$created = Invoke-MgGraphRequest -Method POST `
-Uri 'https://graph.microsoft.com/beta/security/auditLog/queries' `
-Body ($query | ConvertTo-Json) -ContentType 'application/json'
# Poll status, then page records
do {
Start-Sleep -Seconds 10
$state = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/security/auditLog/queries/$($created.id)"
} while ($state.status -in 'notStarted','running')
$records = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/security/auditLog/queries/$($created.id)/records?`$top=100"
Search-UnifiedAuditLog deprecation status
As of the April 2026 verification window, Search-UnifiedAuditLog itself remains supported, but Search-AdminAuditLog was deprecated 15 September 2024 and Microsoft's strategic forward path for programmatic audit search is the Audit Search Graph API (beta). New automation should target Graph; treat Search-UnifiedAuditLog as legacy for net-new investment, and continue to verify the parity matrix against Microsoft Learn at every change window.
§8 — Cross-source content retrieval (DSPM for AI / eDiscovery Premium / Communication Compliance)
Why this section exists. The CopilotInteraction audit record is metadata only — UserId, AgentId, Messages[].ID, IsPrompt, detection flags. The prompt and response text lives in the Microsoft 365 Substrate (the per-user Copilot interaction history mailbox) and is reachable through:
| Surface | Hook | Use case |
|---|---|---|
| DSPM for AI (Control 1.6) | Compliance portal → DSPM for AI → Activity explorer; PowerShell surface is limited — DSPM for AI is currently portal-led | Compliance manager review of Copilot transcripts in-line with the audit record |
| eDiscovery (Premium) (Control 1.19) | New-ComplianceCase, New-CaseHoldPolicy, New-ComplianceSearch with Copilot-scoped location |
Legal hold, collection, and review across custodians |
| Communication Compliance (Control 1.10) | New-SupervisoryReviewPolicyV2 with Copilot conditions |
FINRA Rule 3110 supervisory review of AI-generated communications |
8.1 eDiscovery Premium scoping example
# Run from Connect-IPPSSession session (NOT Connect-ExchangeOnline)
$caseName = "Control-1.7-Copilot-Investigation-$(Get-Date -Format 'yyyyMMdd')"
if (-not (Get-ComplianceCase -Identity $caseName -ErrorAction SilentlyContinue)) {
New-ComplianceCase -Name $caseName -CaseType AdvancedEdiscovery `
-Description 'Control 1.7 — Copilot interaction collection for examination'
}
# Scope: per-user Copilot interaction mailbox locations
$custodians = @('jane.doe@contoso.com','john.roe@contoso.com')
New-ComplianceSearch -Name "$caseName-Search" `
-ExchangeLocation $custodians `
-ContentMatchQuery 'kind:microsoftteams OR itemclass:IPM.SkypeTeams.Message OR itemclass:IPM.Note.Microsoft.Conversation*'
Hedged note. This pattern supports the content-tier obligations under SEC 17a-4(b)(4) and FINRA Rule 4511 when paired with §10 preservation. It does not, by itself, guarantee completeness of the books-and-records record set; verify scope against the Substrate documentation in Microsoft Learn: Audit logs for Copilot and AI activities at every change window.
8.2 Communication Compliance Copilot policy stub
# Policy authoring is portal-led; the PowerShell surface (New-SupervisoryReviewPolicyV2) is documented but
# Copilot-specific condition templates evolve quickly. See Control 1.10 playbook for the canonical authoring
# walkthrough; this stub is a hook for the cross-source evidence pack.
New-SupervisoryReviewPolicyV2 -Name 'FSI-Copilot-Supervision' `
-Reviewers 'supervisor.group@contoso.com' -ReviewPercentage 100 -Confirm:$false -WhatIf
§9 — Dataverse per-table audit on the six Copilot Studio entities (Get-FsiCopilotStudioDataverseAudit)
Why this section exists. Tenant-level "Start auditing" in the Power Platform Admin Center enables the capability; per-table auditing on the six Copilot Studio entities — bot, botcomponent, botcomponentcollection, conversationtranscript, aiplugin, aipluginauth — must be enabled per environment, per table. A solution-installed entity in a new environment does not inherit table audit settings from the source environment.
The Power Apps Administration cmdlets that enumerate environments are Desktop-only (BL-§2; §0 trap). Spawn a Windows PowerShell 5.1 child process and bridge the result as JSON.
9.1 Read state across all environments
function Get-FsiCopilotStudioDataverseAudit {
<#
.SYNOPSIS
Reports per-environment, per-table audit state for the six Copilot Studio Dataverse entities.
.NOTES
Spawns a Windows PowerShell 5.1 child for the Microsoft.PowerApps.Administration.PowerShell leg.
Per-table audit is queried via the Dataverse Web API (entity metadata IsAuditEnabled property).
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud = 'Commercial'
)
try {
$profile = Resolve-Agt17CloudProfile -Cloud $Cloud
$entities = @('bot','botcomponent','botcomponentcollection','conversationtranscript','aiplugin','aipluginauth')
# Spawn PS 5.1 child to enumerate environments
$child = @"
Add-PowerAppsAccount -Endpoint '$($profile.Pac.ToLower())' | Out-Null
Get-AdminPowerAppEnvironment |
Where-Object { `$_.CommonDataServiceDatabaseProvisioningState -eq 'Succeeded' } |
Select-Object EnvironmentName, DisplayName, @{n='WebApiUrl';e={`$_.Internal.properties.linkedEnvironmentMetadata.instanceApiUrl}} |
ConvertTo-Json -Depth 4
"@
$envsJson = & powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $child
$envs = $envsJson | ConvertFrom-Json
$rows = foreach ($env in @($envs)) {
# Per-environment org-level audit setting
$orgUri = "$($env.WebApiUrl)/api/data/v9.2/organizations?`$select=isauditenabled,organizationid"
$org = (Invoke-MgGraphRequest -Method GET -Uri $orgUri -ErrorAction SilentlyContinue).value | Select-Object -First 1
foreach ($e in $entities) {
$metaUri = "$($env.WebApiUrl)/api/data/v9.2/EntityDefinitions(LogicalName='$e')?`$select=LogicalName,IsAuditEnabled"
try {
$meta = Invoke-MgGraphRequest -Method GET -Uri $metaUri -ErrorAction Stop
[pscustomobject]@{
EnvironmentName = $env.EnvironmentName
DisplayName = $env.DisplayName
OrgAuditEnabled = [bool]$org.isauditenabled
Entity = $e
EntityAuditEnabled = [bool]$meta.IsAuditEnabled.Value
}
} catch {
[pscustomobject]@{
EnvironmentName = $env.EnvironmentName
DisplayName = $env.DisplayName
OrgAuditEnabled = [bool]$org.isauditenabled
Entity = $e
EntityAuditEnabled = $null
Error = $_.Exception.Message
}
}
}
}
$gaps = $rows | Where-Object { -not $_.OrgAuditEnabled -or -not $_.EntityAuditEnabled }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Get-FsiCopilotStudioDataverseAudit'
Status = if ($gaps) { 'Anomaly' } else { 'Clean' }
EnvironmentCount = ($envs | Measure-Object).Count
Rows = $rows
GapCount = ($gaps | Measure-Object).Count
CapturedAtUtc= (Get-Date).ToUniversalTime().ToString('o')
Note = 'Per Microsoft Learn (May 2026 Dataverse change), before-and-after field change values will no longer flow to Microsoft Purview audit. Programs depending on field-level change records should retrieve them directly from Dataverse APIs in addition to UAL.'
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Get-FsiCopilotStudioDataverseAudit'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
9.2 Idempotent enable across environments (mutation)
function Enable-FsiCopilotStudioDataverseAudit {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([Parameter(Mandatory)] [string]$EnvironmentWebApiUrl)
$entities = @('bot','botcomponent','botcomponentcollection','conversationtranscript','aiplugin','aipluginauth')
foreach ($e in $entities) {
$metaUri = "$EnvironmentWebApiUrl/api/data/v9.2/EntityDefinitions(LogicalName='$e')"
$current = Invoke-MgGraphRequest -Method GET -Uri "$metaUri`?`$select=LogicalName,IsAuditEnabled"
if ($current.IsAuditEnabled.Value) { Write-Verbose "$e already audited; skipping."; continue }
if ($PSCmdlet.ShouldProcess("$EnvironmentWebApiUrl :: $e", 'PATCH IsAuditEnabled=true')) {
Invoke-MgGraphRequest -Method PATCH -Uri $metaUri `
-Body (@{ IsAuditEnabled = @{ Value = $true; CanBeChanged = $true; ManagedPropertyLogicalName = 'canmodifyauditsettings' } } | ConvertTo-Json -Depth 4) `
-ContentType 'application/json'
}
}
}
PAC CLI alternative: pac org settings update --name IsAuditEnabled --value true per environment.
§10 — SEC 17a-4(f) preservation pipeline (Test-FsiAuditTo17a4Preservation)
Why this section exists. Microsoft 365 Audit (Standard, Premium, 10-year add-on) is record-CAPTURE telemetry, not a 17a-4(f)-compliant electronic recordkeeping system. For broker-dealers, FCMs, swap dealers, and CPOs subject to SEC 17a-4 / FINRA 4511 / CFTC 1.31, preservation must be satisfied through either WORM storage (Azure immutable blob with a locked time-based retention policy — see Microsoft Learn: Immutable storage for Azure blob data) or the audit-trail alternative under the October 2022 amendments to Rule 17a-4(f).
This helper verifies that an export pipeline exists into an attested archive. It does not, by itself, guarantee 17a-4(f) compliance — Cohasset attestation, Designated Executive Officer (DEO) representation or Designated Third Party (DTP) undertaking, and a documented books-and-records record set are out-of-scope for PowerShell verification.
10.1 Reusable preservation export helper
function Export-FsiAuditTo17a4 {
<#
.SYNOPSIS
Exports a Search-FsiAuditLogPaged result set to an Azure immutable blob container with a locked
time-based retention policy. Computes SHA-256 manifest per BL-§5.
.NOTES
Hedged: supports SEC 17a-4(f) preservation when configured per Cohasset-attested guidance and
combined with the firm's books-and-records record set. Does not, by itself, guarantee compliance.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium')]
param(
[Parameter(Mandatory)] [object[]]$Records,
[Parameter(Mandatory)] [string]$StorageAccountName,
[Parameter(Mandatory)] [string]$ContainerName,
[Parameter(Mandatory)] [string]$EvidencePrefix,
[int]$RetentionYears = 7
)
$ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount
# Verify container has an immutable storage policy in the Locked state
$policy = Get-AzRmStorageContainerImmutabilityPolicy `
-ResourceGroupName (Get-AzStorageAccount | Where-Object StorageAccountName -eq $StorageAccountName).ResourceGroupName `
-StorageAccountName $StorageAccountName -ContainerName $ContainerName -ErrorAction Stop
if ($policy.State -ne 'Locked') {
throw "Container '$ContainerName' has no LOCKED time-based immutability policy (current state: $($policy.State)). 17a-4(f) preservation requires the Locked state — Unlocked policies can be deleted by storage account contributors and do not satisfy WORM."
}
if ($policy.ImmutabilityPeriodSinceCreationInDays -lt ($RetentionYears * 365)) {
throw "Container immutability period ($($policy.ImmutabilityPeriodSinceCreationInDays) days) is shorter than required retention ($($RetentionYears * 365) days)."
}
$ts = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$name = "$EvidencePrefix-$ts.json"
$tmp = Join-Path ([IO.Path]::GetTempPath()) $name
try {
$Records | ConvertTo-Json -Depth 30 | Set-Content -Path $tmp -Encoding UTF8
$hash = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash
if ($PSCmdlet.ShouldProcess("$StorageAccountName/$ContainerName/$name","Set-AzStorageBlobContent")) {
Set-AzStorageBlobContent -Context $ctx -Container $ContainerName -File $tmp -Blob $name -Force | Out-Null
}
[pscustomobject]@{
Blob = "$ContainerName/$name"; Sha256 = $hash; Bytes = (Get-Item $tmp).Length
ImmutabilityState = $policy.State; RetentionDays = $policy.ImmutabilityPeriodSinceCreationInDays
CapturedAtUtc = $ts
}
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
10.2 Pipeline health check
function Test-FsiAuditTo17a4Preservation {
<#
.SYNOPSIS
Verifies a 17a-4(f)-compatible preservation pipeline exists for unified-audit exports.
Detects either Azure immutable blob storage (locked time-based policy) or an annotated
third-party connector reference.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[string]$StorageAccountName,
[string]$ContainerName,
[int]$MinRetentionYears = 7,
[string]$ThirdPartyConnectorAnnotationPath # optional path to JSON describing a vendor archive (Smarsh / Global Relay / Proofpoint / Mimecast / Bloomberg Vault / Veritas)
)
try {
$azureBranch = $null
if ($StorageAccountName -and $ContainerName) {
$rg = (Get-AzStorageAccount | Where-Object StorageAccountName -eq $StorageAccountName).ResourceGroupName
$policy = Get-AzRmStorageContainerImmutabilityPolicy -ResourceGroupName $rg -StorageAccountName $StorageAccountName -ContainerName $ContainerName -ErrorAction SilentlyContinue
$azureBranch = [pscustomobject]@{
StorageAccount = $StorageAccountName
Container = $ContainerName
PolicyState = $policy.State
RetentionDays = $policy.ImmutabilityPeriodSinceCreationInDays
MeetsRetention = ($policy.ImmutabilityPeriodSinceCreationInDays -ge ($MinRetentionYears * 365))
Locked = ($policy.State -eq 'Locked')
}
}
$vendorBranch = $null
if ($ThirdPartyConnectorAnnotationPath -and (Test-Path $ThirdPartyConnectorAnnotationPath)) {
$vendorBranch = Get-Content $ThirdPartyConnectorAnnotationPath -Raw | ConvertFrom-Json
}
$passes = ($azureBranch -and $azureBranch.Locked -and $azureBranch.MeetsRetention) -or
($vendorBranch -and $vendorBranch.AttestationOnFile -eq $true)
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Test-FsiAuditTo17a4Preservation'
Status = if ($passes) { 'Clean' } elseif ($azureBranch -or $vendorBranch) { 'Anomaly' } else { 'Pending' }
AzureBranch = $azureBranch
VendorBranch = $vendorBranch
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = 'Supports SEC 17a-4(f) preservation when configured per Cohasset-attested guidance and combined with the firm''s books-and-records record set. Does not, by itself, guarantee compliance — DEO representation or DTP undertaking and an independent records-management assessment remain out-of-scope for PowerShell verification.'
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Test-FsiAuditTo17a4Preservation'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
§11 — Sentinel forwarding sanity check (Test-FsiAuditToSentinel)
Why this section exists. Cross-control verification with Control 3.9 (Microsoft Sentinel Integration). The Office 365 connector can be enabled with the connector status reporting healthy while no analytics rule on CopilotInteraction exists — events flow into the Log Analytics workspace, and nobody is alerted.
function Test-FsiAuditToSentinel {
<#
.SYNOPSIS
Verifies the Sentinel Office 365 connector is enabled AND at least one analytics rule references
CopilotInteraction. Cross-references Control 3.9.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [string]$ResourceGroupName,
[Parameter(Mandatory)] [string]$WorkspaceName,
[ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud = 'Commercial'
)
try {
if ($Cloud -in 'DoD') {
return [pscustomobject]@{
ControlId='1.7'; Helper='Test-FsiAuditToSentinel'; Status='NotApplicable'
Note='Microsoft Sentinel availability and connector parity in DoD require per-tenant verification at every change window; helper returns NotApplicable until you confirm parity in Microsoft Learn for your tenant.'
CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o')
}
}
$connector = Get-AzSentinelDataConnector -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName -ErrorAction SilentlyContinue |
Where-Object { $_.Kind -eq 'Office365' }
$rules = Get-AzSentinelAlertRule -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName -ErrorAction SilentlyContinue
$copilotRule = $rules | Where-Object { $_.Query -match 'CopilotInteraction' }
$status = if ($connector -and $copilotRule) { 'Clean' }
elseif ($connector) { 'Anomaly' }
else { 'Pending' }
[pscustomobject]@{
ControlId = '1.7'
Helper = 'Test-FsiAuditToSentinel'
Status = $status
Office365ConnectorOn = [bool]$connector
CopilotAnalyticsRule = ($copilotRule | Select-Object -First 1).DisplayName
RuleCount = ($copilotRule | Measure-Object).Count
CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
Note = if ($status -eq 'Anomaly') { 'Office 365 connector is enabled and CopilotInteraction events are flowing into the workspace, but no analytics rule references CopilotInteraction — events are ingested but not alerted on. Author a rule per Control 3.9.' }
elseif ($status -eq 'Pending') { 'Office 365 connector not detected — CopilotInteraction events are not reaching Sentinel. Enable the connector per Control 3.9.' }
else { 'Connector enabled and at least one analytics rule references CopilotInteraction.' }
}
} catch {
[pscustomobject]@{ ControlId='1.7'; Helper='Test-FsiAuditToSentinel'; Status='Error'; ErrorMessage=$_.Exception.Message; CapturedAtUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
}
§12 — Evidence emission and quarterly attestation pack
Why this section exists. Audit-defensible evidence requires content-integrity proofs (BL-§5). Screenshots alone are not sufficient under SEC 17a-4(f) WORM requirements or FINRA 4511 record-keeping rules.
function Save-FsiAuditEvidence {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory, ValueFromPipeline)] $InputObject,
[Parameter(Mandatory)] [string]$Name,
[string]$RootPath = ".\evidence\1.7"
)
process {
if (-not (Test-Path $RootPath)) { New-Item -ItemType Directory -Path $RootPath -Force | Out-Null }
$ts = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$tenant = (Get-MgContext).TenantId
$base = Join-Path $RootPath "Control-1.7_${tenant}_${Name}_${ts}"
$InputObject | ConvertTo-Json -Depth 30 | Set-Content -Path "$base.json" -Encoding UTF8
$hash = (Get-FileHash -Path "$base.json" -Algorithm SHA256).Hash
$manifestPath = Join-Path $RootPath 'manifest.json'
$manifest = if (Test-Path $manifestPath) { @(Get-Content $manifestPath | ConvertFrom-Json) } else { @() }
$manifest += [pscustomobject]@{
file = (Split-Path "$base.json" -Leaf)
sha256 = $hash
bytes = (Get-Item "$base.json").Length
generated_utc = $ts
control_id = '1.7'
helper_name = $Name
tenant_id = $tenant
script_version = '1.4'
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
[pscustomobject]@{ EvidenceFile = "$base.json"; Sha256 = $hash; Manifest = $manifestPath }
}
}
# Quarterly attestation pack
Start-Transcript -Path ".\evidence\1.7\transcript_$(Get-Date -Format 'yyyyMMddTHHmmssZ').log" -IncludeInvocationHeader
Get-FsiAuditCopilotEnablement | Save-FsiAuditEvidence -Name 'AuditCopilotEnablement'
Get-FsiAuditPremiumStatus | Save-FsiAuditEvidence -Name 'AuditPremiumStatus'
Get-FsiAuditPaygEnablement -Cloud Commercial | Save-FsiAuditEvidence -Name 'AuditPaygEnablement'
Get-FsiMailboxAuditBypass | Save-FsiAuditEvidence -Name 'MailboxAuditBypass'
Get-FsiCopilotStudioDataverseAudit | Save-FsiAuditEvidence -Name 'CopilotStudioDataverseAudit'
Test-FsiAuditTo17a4Preservation `
-StorageAccountName 'fsiarchivewormprod' `
-ContainerName 'audit-1-7' | Save-FsiAuditEvidence -Name '17a4PreservationPipeline'
Test-FsiAuditToSentinel `
-ResourceGroupName 'rg-sec-prod' `
-WorkspaceName 'la-sec-prod' | Save-FsiAuditEvidence -Name 'SentinelForwarding'
Stop-Transcript
After the run, copy the evidence\1.7\ folder into the immutable container verified by Test-FsiAuditTo17a4Preservation. Reference each artifact's SHA-256 in the attestation statement.
§13 — Cross-control links
| Control | Why it cross-cuts 1.7 |
|---|---|
| 1.5 — DLP and Sensitivity Labels | DLP policy match events surface in the same UAL; correlate RecordType=DLP.SharePoint etc. with CopilotInteraction. |
| 1.6 — DSPM for AI | Content-tier viewer for CopilotInteraction transcripts (§8). |
| 1.10 — Communication Compliance Monitoring | FINRA Rule 3110 supervisory review of AI-generated communications (§8). |
| 1.19 — eDiscovery for Agent Interactions | Legal hold and collection of Copilot transcripts via Premium eDiscovery (§8). |
| 2.6 — Model Risk Management Alignment | Model identity / version cross-reference for Audit ModelTransparencyDetails. |
| 2.12 — Supervision and Oversight (FINRA Rule 3110) | Consumes UAL CopilotInteraction events as the supervisory queue source. |
| 3.4 — Incident Reporting and Root-Cause Analysis | UAL is the primary timeline source for Copilot/agent incidents. |
| 3.9 — Microsoft Sentinel Integration | Verified end-to-end by §11 Test-FsiAuditToSentinel. |
§14 — Operating cadence and anti-patterns
14.1 Cadence
| Task | Frequency | Helper |
|---|---|---|
| Tenant unified audit ingestion + record-type flow | Daily | Get-FsiAuditCopilotEnablement |
| License entitlement reconciliation (Audit Premium + 10y add-on) | Weekly | Get-FsiAuditPremiumStatus |
PAYG AIAppInteraction opt-in confirmation |
Monthly + after every Microsoft billing change | Get-FsiAuditPaygEnablement |
| Mailbox audit bypass review | Monthly + on every regulated-user provisioning event | Get-FsiMailboxAuditBypass |
| Dataverse per-table audit on six Copilot Studio entities | Weekly + after every solution import into a new environment | Get-FsiCopilotStudioDataverseAudit |
| 17a-4(f) preservation pipeline health | Daily | Test-FsiAuditTo17a4Preservation |
| Sentinel forwarding + analytics rule presence | Daily | Test-FsiAuditToSentinel |
| Quarterly attestation pack (full evidence set, manifest signed) | Quarterly | All of the above |
14.2 Anti-patterns (do not ship)
- ❌ Calling
Get-AdminAuditLogConfigwithout first callingAssert-FsiExoSession— false-clean trap #1. - ❌
Search-UnifiedAuditLog -ResultSize 5000without-SessionCommand ReturnLargeSetand a session-ceiling splitter — silent truncation. - ❌ Hard-coding
-RecordType PowerPlatformAdminActivity(or any non-enum value) — silent zero-row return. - ❌ Claiming retention coverage on the basis of a custom retention policy alone —
Get-FsiAuditPremiumStatusfirst. - ❌ Treating an empty
AIAppInteractionresult as "no shadow AI" without confirming PAYG opt-in viaGet-FsiAuditPaygEnablement. - ❌ Enabling tenant-level Dataverse audit and assuming the six Copilot Studio entities inherit per-table audit — they do not, per environment.
- ❌ Treating the 10-Year Audit Log Retention add-on as a 17a-4(f) preservation layer — it is record-CAPTURE telemetry; preservation requires
Test-FsiAuditTo17a4Preservationto pass. - ❌ Enabling the Sentinel Office 365 connector and not authoring an analytics rule on
CopilotInteraction— events ingested, never alerted. - ❌ Using the same service principal for both
Set-AdminAuditLogConfigand the audit-evidence pack — SOX 404 separation-of-duties violation. - ❌ Connecting to Microsoft Graph or Power Apps without the sovereign
-Environment/-Endpointon a GCC / GCC High / DoD tenant — false-clean exit 0.
Back to Control 1.7 · Portal Walkthrough · Troubleshooting
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current