Skip to content

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 as Search-UnifiedAuditLog enters 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 ingesting CopilotInteraction events 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:

  1. Wrong-shell trap on Get-AdminAuditLogConfig. Always returns $false for UnifiedAuditLogIngestionEnabled from the Security & Compliance session. Helpers in §3 hard-fail unless the active session URI matches outlook.office365.(com|us).
  2. Invalid -RecordType returns zero rows silently. Earlier playbooks shipped -RecordType PowerPlatformAdminActivity; that name is not in the AuditLogRecordType enumeration. Some module versions silently return zero rows for invalid RecordType values. The canonical names are MicrosoftCopilotStudio, PowerPlatformAdminEnvironment, PowerPlatformAdministratorActivity, PowerPlatformServiceActivity, MicrosoftFlow, PowerAppsApp, MicrosoftPowerBIAudits, CopilotInteraction, ConnectedAIAppInteraction, AIAppInteraction. Validate against the live enum at runtime.
  3. Search-UnifiedAuditLog -ResultSize truncation. Default -ResultSize 100; per-call cap 5,000; per-session ceiling 50,000. Single-shot calls silently drop records past those limits. Use -SessionCommand ReturnLargeSet with a fresh -SessionId per date window, and split windows when the session ceiling is approached.
  4. Search-UnifiedAuditLog is in maintenance. Microsoft has signalled the Audit Search Graph API (/security/auditLog/queries) as the strategic forward path. Search-AdminAuditLog was already deprecated 15 September 2024. New investment should target the Graph API; this playbook ships both paths and labels the legacy one.
  5. Audit Premium silently downgrades to Audit Standard. If a user does not have an Audit (Premium) entitlement, their CopilotInteraction records fall back to 180-day retention regardless of any custom retention policy. License reconciliation (§4) must be tenant-wide before retention claims.
  6. PAYG record types require explicit opt-in. AIAppInteraction and some ConnectedAIAppInteraction scenarios (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.
  7. 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.
  8. 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-reader audit-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
    }
}

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).

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.


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-AdminAuditLogConfig without first calling Assert-FsiExoSession — false-clean trap #1.
  • Search-UnifiedAuditLog -ResultSize 5000 without -SessionCommand ReturnLargeSet and 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-FsiAuditPremiumStatus first.
  • ❌ Treating an empty AIAppInteraction result as "no shadow AI" without confirming PAYG opt-in via Get-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-FsiAuditTo17a4Preservation to 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-AdminAuditLogConfig and the audit-evidence pack — SOX 404 separation-of-duties violation.
  • ❌ Connecting to Microsoft Graph or Power Apps without the sovereign -Environment / -Endpoint on 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