Skip to content

PowerShell Setup: Control 2.14 — Training and Awareness Program

Read the FSI PowerShell baseline first

Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show the patterns; the baseline is authoritative.

Last Updated: April 2026 Modules Required: Microsoft.Graph (User.Read.All, RoleManagement.Read.All, optional Mail.Send), ExchangeOnlineManagement (only if mail-flow analysis is needed) Mutation Profile: Read-only against Entra and Graph; mail send (Step 3) is gated by -WhatIf and ShouldProcess


Scope and Limits

This playbook automates the measurable parts of Control 2.14:

  1. Identify users in scope for training based on their Entra role assignments.
  2. Reconcile a roster of expected trainees against an LMS-exported completion list.
  3. Generate signed (SHA-256) evidence artifacts suitable for FINRA / SEC examination.
  4. Optionally send reminders via Microsoft Graph mail (gated).

This playbook does not read directly from Microsoft Viva Learning. Programmatic access to Viva Learning learner records is currently delivered through the Employee Experience Graph APIs (/employeeExperience/learningProviders, /learningContents, /learningCourseActivities) which are in beta and subject to change. For audit-defensible reporting in regulated tenants, treat the LMS / Viva-exported CSV as the system of record and have the LMS owner countersign the export.


Prerequisites

# Pin module versions per your firm's CAB-approved baseline.
# Replace <version> with the version validated for your tenant.
Install-Module Microsoft.Graph `
    -RequiredVersion '<version>' `
    -Repository PSGallery `
    -Scope CurrentUser `
    -AcceptLicense

# Sovereign-cloud aware connection (commercial shown; see baseline for GCC / GCC High / DoD)
Connect-MgGraph -Scopes 'User.Read.All','RoleManagement.Read.All' -Environment Global

Script 1 — Identify Users In Scope for Training

<#
.SYNOPSIS
    Build the in-scope trainee roster from Entra role assignments.
.DESCRIPTION
    Maps a curated list of Entra directory roles to a "training required" flag,
    enriches users with department/job title, and emits a SHA-256 signed JSON
    artifact for evidence.
.PARAMETER OutputPath
    Directory to write the evidence artifact (default .\evidence-2.14).
.EXAMPLE
    .\Get-TrainingRoster.ps1 -OutputPath .\evidence-2.14
.NOTES
    Read-only. Requires User.Read.All and RoleManagement.Read.All.
#>
[CmdletBinding()]
param(
    [string]$OutputPath = ".\evidence-2.14"
)

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path (Join-Path $OutputPath "transcript-roster-$ts.log") -IncludeInvocationHeader

# Canonical roles whose holders require AI governance training.
# Names below are Entra built-in role display names (NOT this framework's
# canonical short names) because that is what Get-MgDirectoryRole returns.
$inScopeRoles = @(
    'Power Platform Administrator',
    'Compliance Administrator',
    'Compliance Data Administrator',
    'SharePoint Administrator',
    'Exchange Administrator',
    'Teams Administrator',
    'AI Administrator',
    'Knowledge Administrator',
    'Knowledge Manager',
    'Global Administrator'
)

$roster = @()
foreach ($role in (Get-MgDirectoryRole)) {
    if ($inScopeRoles -notcontains $role.DisplayName) { continue }
    $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All -ErrorAction SilentlyContinue
    foreach ($m in $members) {
        # Members may be users, groups, or service principals; filter to users.
        if ($m.AdditionalProperties['@odata.type'] -ne '#microsoft.graph.user') { continue }
        $u = Get-MgUser -UserId $m.Id -Property 'id,displayName,mail,userPrincipalName,department,jobTitle,accountEnabled' -ErrorAction SilentlyContinue
        if (-not $u) { continue }
        $roster += [PSCustomObject]@{
            UserId        = $u.Id
            DisplayName   = $u.DisplayName
            Mail          = $u.Mail
            UPN           = $u.UserPrincipalName
            Department    = $u.Department
            JobTitle      = $u.JobTitle
            AccountEnabled = $u.AccountEnabled
            EntraRole     = $role.DisplayName
            CollectedUtc  = $ts
        }
    }
}

# Deduplicate (users may hold multiple in-scope roles)
$roster = $roster | Sort-Object UserId, EntraRole -Unique

# Emit JSON + CSV + manifest (SHA-256)
$jsonPath = Join-Path $OutputPath "training-roster-$ts.json"
$csvPath  = Join-Path $OutputPath "training-roster-$ts.csv"
$roster | ConvertTo-Json -Depth 5 | Set-Content -Path $jsonPath -Encoding UTF8
$roster | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

$manifest = @($jsonPath, $csvPath) | ForEach-Object {
    [PSCustomObject]@{
        file          = Split-Path $_ -Leaf
        sha256        = (Get-FileHash $_ -Algorithm SHA256).Hash
        bytes         = (Get-Item $_).Length
        generated_utc = $ts
        script        = 'Get-TrainingRoster'
    }
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Path (Join-Path $OutputPath "manifest-roster-$ts.json") -Encoding UTF8

Write-Host "[OK] Roster: $($roster.Count) users in scope" -ForegroundColor Green
Stop-Transcript

Script 2 — Reconcile Roster vs LMS Completions

<#
.SYNOPSIS
    Reconcile the in-scope roster against an LMS / Viva Learning completion export.
.DESCRIPTION
    Joins the roster (Script 1 output) with a CSV exported from the firm's
    learning system. Produces compliant / non-compliant / expired buckets
    plus SHA-256 signed evidence.
.PARAMETER RosterCsv
    CSV produced by Script 1.
.PARAMETER CompletionsCsv
    LMS-exported CSV with at minimum: Mail, CourseId, CompletionDateUtc.
.PARAMETER RequiredCourseId
    The course identifier used by your LMS to denote AI Governance training.
.PARAMETER ValidityDays
    Days a completion remains valid before refresher is required (default 365).
.PARAMETER OutputPath
    Evidence directory (default .\evidence-2.14).
.EXAMPLE
    .\Get-TrainingCompliance.ps1 -RosterCsv .\evidence-2.14\training-roster-*.csv `
        -CompletionsCsv .\lms-export.csv -RequiredCourseId 'AI-GOV-101'
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string]$RosterCsv,
    [Parameter(Mandatory)] [string]$CompletionsCsv,
    [Parameter(Mandatory)] [string]$RequiredCourseId,
    [int]$ValidityDays = 365,
    [string]$OutputPath = ".\evidence-2.14"
)

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'

$roster      = Import-Csv -Path $RosterCsv
$completions = Import-Csv -Path $CompletionsCsv |
    Where-Object { $_.CourseId -eq $RequiredCourseId }

# Index latest completion per user mail (case-insensitive)
$latest = @{}
foreach ($c in $completions) {
    $key = $c.Mail.ToLowerInvariant()
    $dt  = [datetime]::Parse($c.CompletionDateUtc, [System.Globalization.CultureInfo]::InvariantCulture)
    if (-not $latest.ContainsKey($key) -or $latest[$key] -lt $dt) {
        $latest[$key] = $dt
    }
}

$now    = (Get-Date).ToUniversalTime()
$cutoff = $now.AddDays(-1 * $ValidityDays)

$report = foreach ($u in $roster) {
    $key = if ($u.Mail) { $u.Mail.ToLowerInvariant() } else { $u.UPN.ToLowerInvariant() }
    if ($latest.ContainsKey($key)) {
        $dt = $latest[$key]
        $status = if ($dt -ge $cutoff) { 'Compliant' } else { 'Expired' }
    } else {
        $dt = $null
        $status = 'NotCompleted'
    }
    [PSCustomObject]@{
        DisplayName        = $u.DisplayName
        Mail               = $u.Mail
        UPN                = $u.UPN
        EntraRole          = $u.EntraRole
        Department         = $u.Department
        LastCompletionUtc  = if ($dt) { $dt.ToString('o') } else { '' }
        Status             = $status
    }
}

$total       = $report.Count
$compliant   = ($report | Where-Object Status -eq 'Compliant').Count
$expired     = ($report | Where-Object Status -eq 'Expired').Count
$notDone     = ($report | Where-Object Status -eq 'NotCompleted').Count
$rate        = if ($total -gt 0) { [math]::Round(($compliant / $total) * 100, 1) } else { 0 }

Write-Host "Total in-scope: $total" -ForegroundColor Cyan
Write-Host "Compliant:      $compliant ($rate%)" -ForegroundColor Green
Write-Host "Expired:        $expired" -ForegroundColor Yellow
Write-Host "Not completed:  $notDone" -ForegroundColor Red

$jsonPath = Join-Path $OutputPath "training-compliance-$ts.json"
$csvPath  = Join-Path $OutputPath "training-compliance-$ts.csv"
$report | ConvertTo-Json -Depth 4 | Set-Content -Path $jsonPath -Encoding UTF8
$report | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

$summary = [PSCustomObject]@{
    GeneratedUtc      = $ts
    RequiredCourseId  = $RequiredCourseId
    ValidityDays      = $ValidityDays
    Total             = $total
    Compliant         = $compliant
    Expired           = $expired
    NotCompleted      = $notDone
    CompliancePct     = $rate
}
$summary | ConvertTo-Json -Depth 3 | Set-Content -Path (Join-Path $OutputPath "training-summary-$ts.json")

# Manifest
$manifest = @($jsonPath, $csvPath) | ForEach-Object {
    [PSCustomObject]@{
        file          = Split-Path $_ -Leaf
        sha256        = (Get-FileHash $_ -Algorithm SHA256).Hash
        bytes         = (Get-Item $_).Length
        generated_utc = $ts
        script        = 'Get-TrainingCompliance'
    }
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Path (Join-Path $OutputPath "manifest-compliance-$ts.json")

Script 3 — Send Reminders (Mutation; -WhatIf Required)

<#
.SYNOPSIS
    Send training reminder emails via Microsoft Graph.
.DESCRIPTION
    Sends from a designated mailbox using Send-MgUserMail. Supports -WhatIf
    and ShouldProcess so dry-runs are safe. Records every send (or would-send)
    in an evidence artifact.
.PARAMETER NonCompliantCsv
    Output of Script 2 filtered to Status -ne 'Compliant'.
.PARAMETER SenderUpn
    Mailbox to send from (must be licensed; service mailbox preferred).
.PARAMETER TrainingUrl
    Deep link to the training page in Viva Learning or your LMS.
.EXAMPLE
    .\Send-TrainingReminders.ps1 -NonCompliantCsv .\non-compliant.csv `
        -SenderUpn aigov-no-reply@contoso.com `
        -TrainingUrl 'https://contoso.sharepoint.com/sites/AIGovernanceLearning' -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)] [string]$NonCompliantCsv,
    [Parameter(Mandatory)] [string]$SenderUpn,
    [Parameter(Mandatory)] [string]$TrainingUrl,
    [string]$OutputPath = ".\evidence-2.14"
)

# Requires the Mail.Send delegated or application scope with consent.
# Connect-MgGraph -Scopes 'Mail.Send' -Environment Global

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$log = @()

$users = Import-Csv -Path $NonCompliantCsv
foreach ($u in $users) {
    $body = @"
Hello $($u.DisplayName),

Our records indicate you have not completed (or your completion has expired) the
required AI Governance training associated with your role: $($u.EntraRole).

Please complete the training within 14 days:
$TrainingUrl

This requirement supports our firm's obligations under FINRA Rule 3110 and
related supervisory expectations. If you believe this notice is in error,
contact the AI Governance team.

— AI Governance Office
"@

    $message = @{
        message = @{
            subject      = 'Action required: AI Governance training'
            body         = @{ contentType = 'Text'; content = $body }
            toRecipients = @(@{ emailAddress = @{ address = $u.Mail } })
        }
        saveToSentItems = $true
    }

    if ($PSCmdlet.ShouldProcess($u.Mail, "Send AI Governance training reminder")) {
        try {
            Send-MgUserMail -UserId $SenderUpn -BodyParameter $message
            $log += [PSCustomObject]@{ Mail = $u.Mail; Status = 'Sent'; Utc = (Get-Date).ToUniversalTime().ToString('o') }
        } catch {
            $log += [PSCustomObject]@{ Mail = $u.Mail; Status = "Error: $($_.Exception.Message)"; Utc = (Get-Date).ToUniversalTime().ToString('o') }
        }
    } else {
        $log += [PSCustomObject]@{ Mail = $u.Mail; Status = 'WhatIf'; Utc = (Get-Date).ToUniversalTime().ToString('o') }
    }
}

$logPath = Join-Path $OutputPath "reminder-log-$ts.csv"
$log | Export-Csv -Path $logPath -NoTypeInformation -Encoding UTF8

[PSCustomObject]@{
    file          = Split-Path $logPath -Leaf
    sha256        = (Get-FileHash $logPath -Algorithm SHA256).Hash
    bytes         = (Get-Item $logPath).Length
    generated_utc = $ts
    script        = 'Send-TrainingReminders'
} | ConvertTo-Json | Set-Content -Path (Join-Path $OutputPath "manifest-reminders-$ts.json")

Write-Host "Reminders processed: $($log.Count). See $logPath" -ForegroundColor Cyan

Notes on Viva Learning Programmatic Access

If your firm wants direct (non-LMS) reads of Viva Learning data, the relevant Microsoft Graph surface is /employeeExperience/learningProviders and related entities. As of April 2026 these endpoints are beta. Treat them as "monitor-only" for now; do not use them as the system of record for FINRA / SEC examinations until they reach v1.0 GA. See Microsoft Learn — Employee experience overview.


Back to Control 2.14 | Portal Walkthrough | Verification & Testing | Troubleshooting