Eight production-ready PowerShell scripts that collect everything you need from a Microsoft 365 tenant before any AI engagement starts. Licences, Copilot config, security posture, data governance, automation readiness. One structured output your team can act on.
Why this matters:Competitors charge $8,000–$15,000 AUD for "AI audits" that are essentially structured data collection exercises. These scripts give your engineers the same data in a day — and your assessment fee goes on your expertise in interpreting and acting on it, not the data collection itself.
Install these PowerShell modules on the engineer machine running the discovery. You need a work account with Global Reader + Security Reader + Exchange View-Only in the client tenant — temporary assignment, remove after engagement.
Install-Module Microsoft.Graph -Scope CurrentUserInstall-Module ExchangeOnlineManagement -Scope CurrentUserInstall-Module MicrosoftTeams -Scope CurrentUserInstall-Module Az.Accounts -Scope CurrentUserInstall-Module PnP.PowerShell -Scope CurrentUser| Role | Required? | Notes |
|---|---|---|
| Global Reader | Required | Minimum for read-only discovery. Can be temporary — remove after engagement. |
| Exchange View-Only Organization Management | Required | Required for Exchange Online discovery. |
| Teams Administrator | Optional | Needed if scoping Teams governance. Can use Global Reader for basic Teams config. |
| SharePoint Administrator | Optional | Needed for full SharePoint site-level data. PnP requires explicit site access. |
| Security Reader | Required | Needed for Defender, Secure Score, and Conditional Access policy reads. |
Run these in order. Each script writes output to a discovery-output\ folder in your working directory. Script 08 compiles everything into a client-ready report.
Install all required PowerShell modules and connect to the tenant. Run this first — everything else depends on it.
# PAI MSP Toolkit — Prerequisite Setup Script
# Run as: .\01-prereq.ps1 -TenantDomain "contoso.onmicrosoft.com"
# Purpose: Install modules and authenticate to Microsoft 365 tenant
param(
[Parameter(Mandatory=$true)]
[string]$TenantDomain,
[string]$OutputPath = ".\discovery-output"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Write-Host "PAI MSP Toolkit — Tenant Discovery Prereq Setup" -ForegroundColor Cyan
Write-Host "Tenant: $TenantDomain" -ForegroundColor Yellow
Write-Host ""
# Create output directory
if (-not (Test-Path $OutputPath)) {
New-Item -ItemType Directory -Path $OutputPath | Out-Null
Write-Host "Created output directory: $OutputPath" -ForegroundColor Green
}
# Module installation
$modules = @(
"Microsoft.Graph",
"ExchangeOnlineManagement",
"MicrosoftTeams",
"Az.Accounts",
"PnP.PowerShell"
)
foreach ($mod in $modules) {
if (-not (Get-Module -ListAvailable -Name $mod)) {
Write-Host "Installing $mod..." -ForegroundColor Yellow
Install-Module -Name $mod -Scope CurrentUser -Force -AllowClobber
Write-Host " $mod installed." -ForegroundColor Green
} else {
Write-Host " $mod already installed." -ForegroundColor Gray
}
}
# Connect to Microsoft Graph
Write-Host ""
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
$scopes = @(
"Directory.Read.All",
"Policy.Read.All",
"AuditLog.Read.All",
"SecurityEvents.Read.All",
"Reports.Read.All",
"RoleManagement.Read.Directory",
"Organization.Read.All",
"User.Read.All",
"Group.Read.All",
"Application.Read.All",
"DeviceManagementConfiguration.Read.All"
)
Connect-MgGraph -Scopes $scopes -TenantId $TenantDomain
# Connect to Exchange Online
Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan
Connect-ExchangeOnline -Organization $TenantDomain -ShowBanner:$false
# Connect to Teams
Write-Host "Connecting to Microsoft Teams..." -ForegroundColor Cyan
Connect-MicrosoftTeams -TenantId $TenantDomain | Out-Null
Write-Host ""
Write-Host "All connections established. Ready to run discovery scripts." -ForegroundColor Green
Write-Host "Output will be written to: $OutputPath" -ForegroundColor YellowCollect the tenant's licence inventory, SKUs, assigned vs available seats, and identify which Microsoft AI products are licensed.
# PAI MSP Toolkit — Tenant Profile and Licensing
# Run as: .\02-tenant-profile.ps1 -OutputPath ".\discovery-output"
# Requires: Microsoft.Graph connected (run 01-prereq.ps1 first)
param(
[string]$OutputPath = ".\discovery-output"
)
Set-StrictMode -Version Latest
Write-Host "Script 02: Tenant Profile and Licensing" -ForegroundColor Cyan
# AI-relevant SKU patterns to flag
$aiSkuPatterns = @(
"COPILOT",
"M365_COPILOT",
"MICROSOFT_365_COPILOT",
"VIVA",
"POWER_AUTOMATE",
"POWER_BI",
"AZURE_AI",
"COGNITIVE_SERVICES",
"OPENAI",
"SYNTEX"
)
# Org profile
$org = Get-MgOrganization
$orgProfile = [PSCustomObject]@{
TenantId = $org.Id
DisplayName = $org.DisplayName
Domain = ($org.VerifiedDomains | Where-Object { $_.IsDefault }).Name
Country = $org.CountryLetterCode
CreatedDateTime = $org.CreatedDateTime
TenantType = $org.TenantType
}
$orgProfile | ConvertTo-Json | Out-File "$OutputPath enant-profile.json" -Encoding UTF8
Write-Host " Tenant: $($org.DisplayName) | $($orgProfile.Domain)" -ForegroundColor Green
# Total user count
$userCount = (Get-MgUser -Count -ConsistencyLevel eventual -Filter "accountEnabled eq true").Length
Write-Host " Active users: $userCount" -ForegroundColor Green
# Licence inventory
Write-Host " Collecting licence data..." -ForegroundColor Yellow
$subscriptions = Get-MgSubscribedSku
$licenceRows = @()
foreach ($sub in $subscriptions) {
$skuName = $sub.SkuPartNumber
$isAiRelevant = $false
foreach ($pattern in $aiSkuPatterns) {
if ($skuName -like "*$pattern*") { $isAiRelevant = $true; break }
}
$licenceRows += [PSCustomObject]@{
SKU = $skuName
FriendlyName = $sub.SkuId # Graph doesn't return friendly name; map manually
TotalLicences = $sub.PrepaidUnits.Enabled
AssignedLicences = $sub.ConsumedUnits
AvailableLicences = ($sub.PrepaidUnits.Enabled - $sub.ConsumedUnits)
CapabilityStatus = $sub.CapabilityStatus
AIRelevant = $isAiRelevant
}
}
$licenceRows | Export-Csv "$OutputPathlicence-inventory.csv" -NoTypeInformation
Write-Host " Licence inventory saved. AI-relevant SKUs found:" -ForegroundColor Green
$licenceRows | Where-Object { $_.AIRelevant } | ForEach-Object {
Write-Host " * $($_.SKU): $($_.AssignedLicences) / $($_.TotalLicences) assigned" -ForegroundColor Magenta
}
Write-Host "Script 02 complete." -ForegroundColor GreenDiscover Copilot for M365 enablement status, which users have it assigned, Copilot Studio agents in use, and any third-party AI apps installed via the Teams/M365 app store.
# PAI MSP Toolkit — Copilot Configuration and AI Tool Footprint
# Run as: .\03-copilot-config.ps1 -OutputPath ".\discovery-output"
# Requires: Microsoft.Graph connected
param(
[string]$OutputPath = ".\discovery-output"
)
Set-StrictMode -Version Latest
Write-Host "Script 03: Copilot Config and AI Tool Footprint" -ForegroundColor Cyan
# Known AI app IDs in Teams/M365 ecosystem
$knownAiApps = @{
"1542629d-7a94-4f03-9dab-da58c0c0acfd" = "Microsoft Copilot (Teams)"
"com.microsoft.teamspace.tab.copilot" = "Copilot in Teams"
"1f1b1bef-a052-4df4-b655-fb9f1843d1cc" = "Copilot Studio"
"aa3f4303-9640-45cb-9fd4-26e7e3adc8ef" = "Power Automate"
"2e24e22c-1ad3-4f30-96b4-8d0ef77a2b3c" = "Power BI"
"0d820ecd-def2-4297-adad-78056cde7c78" = "Azure AI Foundry"
}
# Copilot SKUs
$copilotSkus = @(
"Microsoft_365_Copilot",
"M365_COPILOT",
"COPILOT_FOR_MICROSOFT_365"
)
Write-Host " Finding Copilot-licensed users..." -ForegroundColor Yellow
# Get all users with Copilot licence
$copilotUsers = @()
$allUsers = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AssignedLicenses,Department,JobTitle,AccountEnabled |
Where-Object { $_.AccountEnabled }
$skus = Get-MgSubscribedSku
$copilotSkuIds = $skus |
Where-Object { $copilotSkus -contains $_.SkuPartNumber -or $_.SkuPartNumber -like "*COPILOT*" } |
Select-Object -ExpandProperty SkuId
foreach ($user in $allUsers) {
$hasCopilot = $user.AssignedLicenses | Where-Object { $copilotSkuIds -contains $_.SkuId }
if ($hasCopilot) {
$copilotUsers += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
Department = $user.Department
JobTitle = $user.JobTitle
CopilotAssigned = $true
}
}
}
$copilotUsers | Export-Csv "$OutputPathcopilot-licensed-users.csv" -NoTypeInformation
Write-Host " Copilot licensed users: $($copilotUsers.Count)" -ForegroundColor Green
# Discover Teams apps (AI footprint)
Write-Host " Scanning Teams app catalogue for AI tools..." -ForegroundColor Yellow
try {
$teamsApps = Get-MgTeamApp -Filter "distributionMethod eq 'organization'" -All 2>$null
$aiAppsFound = @()
foreach ($app in $teamsApps) {
$aiAppsFound += [PSCustomObject]@{
AppId = $app.Id
DisplayName = $app.DisplayName
ExternalId = $app.ExternalId
IsKnownAI = ($knownAiApps.ContainsKey($app.Id) -or $knownAiApps.ContainsKey($app.ExternalId))
Category = if ($knownAiApps.ContainsKey($app.Id)) { $knownAiApps[$app.Id] } else { "Unknown" }
}
}
$aiAppsFound | Export-Csv "$OutputPath eams-ai-apps.csv" -NoTypeInformation
Write-Host " Teams apps scanned: $($teamsApps.Count) total" -ForegroundColor Green
Write-Host " Known AI apps found: $($aiAppsFound | Where-Object { $_.IsKnownAI } | Measure-Object | Select-Object -Exp Count)" -ForegroundColor Magenta
} catch {
Write-Host " Teams app scan requires Teams Admin access — skipping." -ForegroundColor Yellow
}
# Power Platform — check if any flows exist (indicator of automation maturity)
Write-Host " Checking Power Platform signal..." -ForegroundColor Yellow
# Note: Full Power Platform inventory requires Power Platform Admin Centre API
# This checks for licensed users as a proxy
$ppSkus = $skus | Where-Object { $_.SkuPartNumber -like "*POWER*" }
if ($ppSkus) {
Write-Host " Power Platform licences detected:" -ForegroundColor Green
$ppSkus | ForEach-Object { Write-Host " * $($_.SkuPartNumber): $($_.ConsumedUnits) assigned" -ForegroundColor Magenta }
} else {
Write-Host " No Power Platform licences found." -ForegroundColor Gray
}
Write-Host "Script 03 complete." -ForegroundColor GreenAssess MFA adoption, Conditional Access policies, Secure Score, privileged role assignments, and guest access configuration — all factors that gate safe AI deployment.
# PAI MSP Toolkit — Security Posture Assessment
# Run as: .\04-security-posture.ps1 -OutputPath ".\discovery-output"
# Requires: Microsoft.Graph connected with SecurityEvents.Read.All
param(
[string]$OutputPath = ".\discovery-output"
)
Set-StrictMode -Version Latest
Write-Host "Script 04: Security Posture and Conditional Access" -ForegroundColor Cyan
# Microsoft Secure Score
Write-Host " Collecting Secure Score..." -ForegroundColor Yellow
try {
$secureScores = Get-MgSecuritySecureScore -Top 1
if ($secureScores) {
$score = $secureScores[0]
$secureScoreSummary = [PSCustomObject]@{
CurrentScore = $score.CurrentScore
MaxScore = $score.MaxScore
ScorePercentage = [math]::Round(($score.CurrentScore / $score.MaxScore) * 100, 1)
AverageComparativeScore = ($score.AverageComparativeScores | Where-Object { $_.Basis -eq "AllTenants" }).AverageScore
CreatedDateTime = $score.CreatedDateTime
}
$secureScoreSummary | ConvertTo-Json | Out-File "$OutputPathsecure-score.json" -Encoding UTF8
Write-Host " Secure Score: $($secureScoreSummary.CurrentScore) / $($secureScoreSummary.MaxScore) ($($secureScoreSummary.ScorePercentage)%)" -ForegroundColor Green
}
} catch {
Write-Host " Secure Score requires Security Reader role — skipping." -ForegroundColor Yellow
}
# Conditional Access Policies
Write-Host " Collecting Conditional Access policies..." -ForegroundColor Yellow
$caPolicies = Get-MgIdentityConditionalAccessPolicy -All
$caRows = $caPolicies | ForEach-Object {
[PSCustomObject]@{
DisplayName = $_.DisplayName
State = $_.State
CreatedAt = $_.CreatedDateTime
ModifiedAt = $_.ModifiedDateTime
UsersIncluded = ($_.Conditions.Users.IncludeUsers -join ", ")
UsersExcluded = ($_.Conditions.Users.ExcludeUsers -join ", ")
AppsIncluded = ($_.Conditions.Applications.IncludeApplications -join ", ")
MFARequired = ($_.GrantControls.BuiltInControls -contains "mfa")
BlockAccess = ($_.GrantControls.Operator -eq "Block" -or $_.GrantControls.BuiltInControls -contains "block")
}
}
$caRows | Export-Csv "$OutputPathconditional-access-policies.csv" -NoTypeInformation
Write-Host " CA Policies found: $($caPolicies.Count) ($($caRows | Where-Object { $_.State -eq 'enabled' } | Measure-Object | Select-Object -Exp Count) enabled)" -ForegroundColor Green
Write-Host " Policies requiring MFA: $($caRows | Where-Object { $_.MFARequired } | Measure-Object | Select-Object -Exp Count)" -ForegroundColor Green
# Privileged Role Assignments
Write-Host " Collecting privileged role members..." -ForegroundColor Yellow
$highPrivRoles = @(
"62e90394-69f5-4237-9190-012177145e10", # Global Administrator
"194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator
"c4e39bd9-1100-46d3-8c65-fb160da0071f", # Authentication Administrator
"729827e3-9c14-49f7-bb1b-9608f156bbb8" # Helpdesk Administrator
)
$roleRows = @()
foreach ($roleId in $highPrivRoles) {
try {
$roleAssignments = Get-MgDirectoryRoleMember -DirectoryRoleId $roleId -ErrorAction SilentlyContinue
if ($roleAssignments) {
$roleDef = Get-MgDirectoryRole -DirectoryRoleId $roleId -ErrorAction SilentlyContinue
foreach ($member in $roleAssignments) {
$roleRows += [PSCustomObject]@{
RoleName = $roleDef.DisplayName
MemberId = $member.Id
MemberType = $member.AdditionalProperties.'@odata.type'
}
}
}
} catch { }
}
$roleRows | Export-Csv "$OutputPathprivileged-roles.csv" -NoTypeInformation
Write-Host " Privileged role members found: $($roleRows.Count)" -ForegroundColor Green
$globalAdmins = $roleRows | Where-Object { $_.RoleName -eq "Global Administrator" }
if ($globalAdmins.Count -gt 5) {
Write-Host " WARNING: $($globalAdmins.Count) Global Administrators found — recommend reducing." -ForegroundColor Red
}
# Guest access configuration
Write-Host " Checking guest access settings..." -ForegroundColor Yellow
$authPolicy = Get-MgPolicyAuthorizationPolicy
$guestSettings = [PSCustomObject]@{
AllowInvites = $authPolicy.AllowInvitesFrom
GuestUserRole = $authPolicy.GuestUserRoleId
BlockMsolPowerShell = $authPolicy.BlockMsolPowerShell
}
$guestSettings | ConvertTo-Json | Out-File "$OutputPathguest-access-settings.json" -Encoding UTF8
Write-Host " Guest invite policy: $($authPolicy.AllowInvitesFrom)" -ForegroundColor Green
Write-Host "Script 04 complete." -ForegroundColor GreenInventory Microsoft Purview sensitivity labels, DLP policies, SharePoint external sharing settings, and whether data classification is in place before AI can safely access content.
# PAI MSP Toolkit — Data Governance and Sensitivity Labels
# Run as: .\05-data-governance.ps1 -OutputPath ".\discovery-output"
# Requires: Microsoft.Graph connected
param(
[string]$OutputPath = ".\discovery-output"
)
Set-StrictMode -Version Latest
Write-Host "Script 05: Data Governance and Sensitivity Labels" -ForegroundColor Cyan
# Sensitivity Labels via Microsoft Graph
Write-Host " Collecting sensitivity labels from Microsoft Purview..." -ForegroundColor Yellow
try {
$labels = Get-MgSecurityInformationProtectionSensitivityLabel -All
$labelRows = $labels | ForEach-Object {
[PSCustomObject]@{
Id = $_.Id
Name = $_.Name
Description = $_.Description
Color = $_.Color
Sensitivity = $_.Sensitivity
IsActive = $_.IsActive
IsEndpointProtectionEnabled = $_.IsEndpointProtectionEnabled
}
}
$labelRows | Export-Csv "$OutputPathsensitivity-labels.csv" -NoTypeInformation
Write-Host " Sensitivity labels defined: $($labels.Count)" -ForegroundColor Green
if ($labels.Count -eq 0) {
Write-Host " WARNING: No sensitivity labels found. Data classification not configured — HIGH RISK for AI deployment." -ForegroundColor Red
}
} catch {
Write-Host " Label enumeration requires Compliance Reader — attempting Exchange method..." -ForegroundColor Yellow
}
# DLP Policies via Exchange Online (compliance centre)
Write-Host " Collecting DLP policies..." -ForegroundColor Yellow
try {
$dlpPolicies = Get-DlpCompliancePolicy -All
$dlpRows = $dlpPolicies | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
Mode = $_.Mode
Workload = $_.Workload
IsEnabled = $_.Enabled
CreatedAt = $_.WhenCreated
ModifiedAt = $_.WhenChanged
}
}
$dlpRows | Export-Csv "$OutputPathdlp-policies.csv" -NoTypeInformation
Write-Host " DLP policies found: $($dlpPolicies.Count)" -ForegroundColor Green
$activeDlp = $dlpRows | Where-Object { $_.IsEnabled }
Write-Host " Active DLP policies: $($activeDlp.Count)" -ForegroundColor Green
if ($activeDlp.Count -eq 0) {
Write-Host " WARNING: No active DLP policies. Data loss prevention not configured." -ForegroundColor Red
}
} catch {
Write-Host " DLP policy read requires Compliance Admin role — skipping." -ForegroundColor Yellow
}
# SharePoint External Sharing
Write-Host " Checking SharePoint external sharing settings..." -ForegroundColor Yellow
try {
$spTenant = Get-SPOTenant 2>$null
if ($spTenant) {
$spSettings = [PSCustomObject]@{
SharingCapability = $spTenant.SharingCapability
DefaultSharingLinkType = $spTenant.DefaultSharingLinkType
ExternalServicesEnabled = $spTenant.ExternalServicesEnabled
AllowEditing = $spTenant.AllowEditing
RequireAcceptingAccount = $spTenant.RequireAcceptingAccountMatchInvitedAccount
}
$spSettings | ConvertTo-Json | Out-File "$OutputPathsharepoint-sharing.json" -Encoding UTF8
if ($spTenant.SharingCapability -eq "ExternalUserAndGuestSharing") {
Write-Host " WARNING: SharePoint sharing set to Anyone (highest risk). Review before AI deployment." -ForegroundColor Red
} else {
Write-Host " SharePoint sharing: $($spTenant.SharingCapability)" -ForegroundColor Green
}
}
} catch {
Write-Host " SharePoint tenant config requires SharePoint Admin — checking via Graph instead..." -ForegroundColor Yellow
$settings = Get-MgAdminSharepoint | Select-Object * -ErrorAction SilentlyContinue
if ($settings) { $settings | ConvertTo-Json | Out-File "$OutputPathsharepoint-sharing.json" -Encoding UTF8 }
}
# Retention Policies
Write-Host " Collecting retention policies..." -ForegroundColor Yellow
try {
$retPolicies = Get-RetentionCompliancePolicy -All
$retRows = $retPolicies | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
Enabled = $_.Enabled
Workload = $_.Workload
Created = $_.WhenCreated
}
}
$retRows | Export-Csv "$OutputPath
etention-policies.csv" -NoTypeInformation
Write-Host " Retention policies: $($retPolicies.Count)" -ForegroundColor Green
} catch {
Write-Host " Retention policy read skipped (requires Compliance Admin)." -ForegroundColor Yellow
}
Write-Host "Script 05 complete." -ForegroundColor GreenCollect M365 usage reports to understand collaboration patterns, application adoption, and identify which teams would benefit most from AI tooling.
# PAI MSP Toolkit — User Activity and Productivity Signals
# Run as: .\06-user-activity.ps1 -OutputPath ".\discovery-output"
# Requires: Reports.Read.All permission
param(
[string]$OutputPath = ".\discovery-output",
[int]$PeriodDays = 30
)
Set-StrictMode -Version Latest
Write-Host "Script 06: User Activity and Productivity Signals" -ForegroundColor Cyan
Write-Host " Period: last $PeriodDays days" -ForegroundColor Yellow
$period = "D$PeriodDays"
# M365 Apps Usage Detail
Write-Host " Collecting M365 app usage..." -ForegroundColor Yellow
try {
$m365Usage = Get-MgReportM365AppUserDetail -Period $period -OutFile "$OutputPathm365-app-usage-raw.csv"
Write-Host " M365 app usage report saved." -ForegroundColor Green
} catch {
Write-Host " M365 usage report: $($_.Exception.Message)" -ForegroundColor Yellow
}
# Teams User Activity
Write-Host " Collecting Teams activity..." -ForegroundColor Yellow
try {
Get-MgReportTeamUserActivityUserDetail -Period $period -OutFile "$OutputPath eams-user-activity-raw.csv"
Write-Host " Teams activity report saved." -ForegroundColor Green
} catch {
Write-Host " Teams activity: $($_.Exception.Message)" -ForegroundColor Yellow
}
# Email activity
Write-Host " Collecting email activity..." -ForegroundColor Yellow
try {
Get-MgReportEmailActivityUserDetail -Period $period -OutFile "$OutputPathemail-activity-raw.csv"
Write-Host " Email activity report saved." -ForegroundColor Green
} catch {
Write-Host " Email activity: $($_.Exception.Message)" -ForegroundColor Yellow
}
# SharePoint activity
Write-Host " Collecting SharePoint activity..." -ForegroundColor Yellow
try {
Get-MgReportSharePointActivityUserDetail -Period $period -OutFile "$OutputPathsharepoint-activity-raw.csv"
Write-Host " SharePoint activity report saved." -ForegroundColor Green
} catch {
Write-Host " SharePoint activity: $($_.Exception.Message)" -ForegroundColor Yellow
}
# OneDrive usage
Write-Host " Collecting OneDrive usage..." -ForegroundColor Yellow
try {
Get-MgReportOneDriveUsageAccountDetail -Period $period -OutFile "$OutputPathonedrive-usage-raw.csv"
Write-Host " OneDrive usage report saved." -ForegroundColor Green
} catch {
Write-Host " OneDrive usage: $($_.Exception.Message)" -ForegroundColor Yellow
}
# Summarise by department — parse the M365 usage CSV
Write-Host " Building department activity summary..." -ForegroundColor Yellow
if (Test-Path "$OutputPathm365-app-usage-raw.csv") {
$usageData = Import-Csv "$OutputPathm365-app-usage-raw.csv"
# Enrich with department from Azure AD
$userDepts = @{}
Get-MgUser -All -Property UserPrincipalName,Department | ForEach-Object {
$userDepts[$_.UserPrincipalName] = $_.Department
}
$summary = $usageData | Group-Object { $userDepts[$_.'User Principal Name'] } | ForEach-Object {
$dept = if ($_.Name) { $_.Name } else { "Unknown" }
$users = $_.Group
[PSCustomObject]@{
Department = $dept
UserCount = $users.Count
TeamsActive = ($users | Where-Object { $_.'Microsoft Teams' -eq 'Yes' } | Measure-Object).Count
OutlookActive = ($users | Where-Object { $_.'Outlook' -eq 'Yes' } | Measure-Object).Count
SPActive = ($users | Where-Object { $_.'SharePoint' -eq 'Yes' } | Measure-Object).Count
OneDriveActive = ($users | Where-Object { $_.'OneDrive' -eq 'Yes' } | Measure-Object).Count
WordActive = ($users | Where-Object { $_.'Word' -eq 'Yes' } | Measure-Object).Count
ExcelActive = ($users | Where-Object { $_.'Excel' -eq 'Yes' } | Measure-Object).Count
}
}
$summary | Export-Csv "$OutputPathdepartment-activity-summary.csv" -NoTypeInformation
Write-Host " Department summary saved. Top 3 departments by Teams adoption:" -ForegroundColor Green
$summary | Sort-Object TeamsActive -Descending | Select-Object -First 3 | ForEach-Object {
Write-Host " $($_.Department): $($_.TeamsActive)/$($_.UserCount) Teams-active" -ForegroundColor Magenta
}
}
Write-Host "Script 06 complete." -ForegroundColor GreenCheck Power Automate environment config, existing flows (where permitted), connector policies, and signal the highest-ROI automation opportunities based on what's already in use.
# PAI MSP Toolkit — Automation Readiness
# Run as: .\07-automation-readiness.ps1 -OutputPath ".\discovery-output"
# Requires: Power Platform Admin rights (or Global Reader for policy-only view)
param(
[string]$OutputPath = ".\discovery-output",
[string]$TenantDomain = ""
)
Set-StrictMode -Version Latest
Write-Host "Script 07: Automation Readiness — Power Platform" -ForegroundColor Cyan
# Check if Power Platform Admin module available
$ppModule = Get-Module -ListAvailable -Name "Microsoft.PowerApps.Administration.PowerShell"
if (-not $ppModule) {
Write-Host " Installing Power Platform Admin module..." -ForegroundColor Yellow
Install-Module -Name Microsoft.PowerApps.Administration.PowerShell -Scope CurrentUser -Force -AllowClobber
Install-Module -Name Microsoft.PowerApps.PowerShell -Scope CurrentUser -Force -AllowClobber
}
try {
Add-PowerAppsAccount -Endpoint "prod"
# Environment inventory
Write-Host " Collecting Power Platform environments..." -ForegroundColor Yellow
$envs = Get-AdminPowerAppEnvironment
$envRows = $envs | ForEach-Object {
[PSCustomObject]@{
DisplayName = $_.DisplayName
EnvironmentName = $_.EnvironmentName
Type = $_.EnvironmentType
Region = $_.Location
IsDefault = $_.IsDefault
CreatedBy = $_.CreatedBy.displayName
CreatedTime = $_.CreatedTime
DatabaseExists = $_.CommonDataServiceDatabaseProvisioningState -eq "Succeeded"
}
}
$envRows | Export-Csv "$OutputPathpower-platform-environments.csv" -NoTypeInformation
Write-Host " Environments found: $($envs.Count)" -ForegroundColor Green
# Data Loss Prevention (Connector) Policies
Write-Host " Collecting connector policies (DLP)..." -ForegroundColor Yellow
$dlpPolicies = Get-AdminDlpPolicy
$dlpRows = $dlpPolicies | ForEach-Object {
[PSCustomObject]@{
PolicyName = $_.DisplayName
EnvironScope = $_.EnvironmentName
CreatedTime = $_.CreatedTime
Type = $_.PolicyType
}
}
$dlpRows | Export-Csv "$OutputPathpower-platform-dlp-policies.csv" -NoTypeInformation
Write-Host " Power Platform DLP policies: $($dlpPolicies.Count)" -ForegroundColor Green
if ($dlpPolicies.Count -eq 0) {
Write-Host " WARNING: No Power Platform DLP policies configured — connectors are unrestricted." -ForegroundColor Red
}
# Flow count per environment (proxy for automation maturity)
Write-Host " Counting flows per environment..." -ForegroundColor Yellow
$flowSummary = @()
foreach ($env in $envs | Select-Object -First 5) {
try {
$flows = Get-AdminFlow -EnvironmentName $env.EnvironmentName
$flowSummary += [PSCustomObject]@{
Environment = $env.DisplayName
FlowCount = $flows.Count
EnabledFlows = ($flows | Where-Object { $_.Properties.state -eq "Started" } | Measure-Object).Count
}
} catch { }
}
$flowSummary | Export-Csv "$OutputPathpower-automate-flow-summary.csv" -NoTypeInformation
} catch {
Write-Host " Power Platform Admin access not available — generating signal from licences only." -ForegroundColor Yellow
# Fallback: infer from Graph licence data
if (Test-Path "$OutputPathlicence-inventory.csv") {
$licences = Import-Csv "$OutputPathlicence-inventory.csv"
$ppLicences = $licences | Where-Object { $_.SKU -like "*POWER*" -or $_.SKU -like "*FLOW*" }
$signals = @()
if ($ppLicences | Where-Object { $_.SKU -like "*POWER_AUTOMATE*" }) {
$signals += "Power Automate licences detected — automation capability exists"
}
if ($ppLicences | Where-Object { $_.SKU -like "*POWER_BI*" }) {
$signals += "Power BI licences detected — data reporting capability exists"
}
$signals | Out-File "$OutputPathautomation-signals.txt" -Encoding UTF8
Write-Host " Automation signals written from licence data." -ForegroundColor Green
}
}
# Generate automation opportunity map based on what we know
Write-Host " Generating automation opportunity map..." -ForegroundColor Yellow
$opportunities = @"
AUTOMATION OPPORTUNITY MAP — Generated $(Get-Date -Format 'yyyy-MM-dd')
Based on tenant discovery data. Review each signal with the client.
HIGH CONFIDENCE OPPORTUNITIES (based on tool presence):
- Email triage and routing (requires: Exchange Online + Power Automate)
- Document approval workflows (requires: SharePoint + Power Automate)
- Teams notifications for business events (requires: Teams + Power Automate)
- Copilot-assisted meeting summaries (requires: Copilot for M365 licence)
- SharePoint document classification (requires: Purview sensitivity labels)
SIGNALS TO INVESTIGATE IN STAKEHOLDER INTERVIEWS:
- What manual data entry tasks happen repeatedly each week?
- Which approval processes still run via email chains?
- Are there reports generated manually from spreadsheets?
- What does onboarding/offboarding look like step-by-step?
- Which teams spend the most time finding information across systems?
RECOMMENDED COPILOT PILOT TARGETS:
- Departments with highest Teams and Outlook activity (see department-activity-summary.csv)
- Roles with document-heavy workflows (legal, finance, HR, operations)
- Teams that do frequent meeting-heavy scheduling
"@
$opportunities | Out-File "$OutputPathautomation-opportunity-map.txt" -Encoding UTF8
Write-Host " Automation opportunity map saved." -ForegroundColor Green
Write-Host "Script 07 complete." -ForegroundColor GreenAggregate all discovery output into a structured JSON report and generate a human-readable HTML summary ready to review with the client.
# PAI MSP Toolkit — Compile Discovery Report
# Run as: .\08-compile-report.ps1 -TenantName "Contoso" -OutputPath ".\discovery-output"
# Requires: All previous scripts to have run successfully
param(
[Parameter(Mandatory=$true)]
[string]$TenantName,
[string]$OutputPath = ".\discovery-output",
[string]$AssessorName = "MSP Engineer",
[string]$AssessorFirm = "Your MSP Firm"
)
Set-StrictMode -Version Latest
Write-Host "Script 08: Compiling Discovery Report" -ForegroundColor Cyan
$reportDate = Get-Date -Format "yyyy-MM-dd"
$report = @{ TenantName = $TenantName; AssessmentDate = $reportDate; AssessorName = $AssessorName; AssessorFirm = $AssessorFirm; Sections = @{} }
# Load each output file
if (Test-Path "$OutputPath enant-profile.json") {
$report.Sections.TenantProfile = Get-Content "$OutputPath enant-profile.json" | ConvertFrom-Json
}
if (Test-Path "$OutputPathlicence-inventory.csv") {
$licences = Import-Csv "$OutputPathlicence-inventory.csv"
$report.Sections.Licencing = @{
TotalSKUs = $licences.Count
AIRelevantSKUs = @($licences | Where-Object { $_.AIRelevant -eq "True" })
TotalSeats = ($licences | Measure-Object -Property TotalLicences -Sum).Sum
AssignedSeats = ($licences | Measure-Object -Property AssignedLicences -Sum).Sum
}
}
if (Test-Path "$OutputPathcopilot-licensed-users.csv") {
$copilot = Import-Csv "$OutputPathcopilot-licensed-users.csv"
$report.Sections.CopilotFootprint = @{ LicensedUserCount = $copilot.Count; Users = @($copilot) }
}
if (Test-Path "$OutputPathsecure-score.json") {
$report.Sections.SecurityPosture = Get-Content "$OutputPathsecure-score.json" | ConvertFrom-Json
}
if (Test-Path "$OutputPathconditional-access-policies.csv") {
$ca = Import-Csv "$OutputPathconditional-access-policies.csv"
$report.Sections.ConditionalAccess = @{
TotalPolicies = $ca.Count
EnabledPolicies = @($ca | Where-Object { $_.State -eq "enabled" }).Count
MFAPolicies = @($ca | Where-Object { $_.MFARequired -eq "True" }).Count
}
}
if (Test-Path "$OutputPathsensitivity-labels.csv") {
$labels = Import-Csv "$OutputPathsensitivity-labels.csv"
$report.Sections.DataGovernance = @{ SensitivityLabelCount = $labels.Count; Labels = @($labels) }
}
if (Test-Path "$OutputPathautomation-opportunity-map.txt") {
$report.Sections.AutomationSignals = Get-Content "$OutputPathautomation-opportunity-map.txt" -Raw
}
# RAG risk scores
$risks = @()
if ($report.Sections.DataGovernance.SensitivityLabelCount -eq 0) {
$risks += @{ Area = "Data Classification"; Status = "RED"; Detail = "No sensitivity labels configured — AI deployment blocked until resolved" }
} elseif ($report.Sections.DataGovernance.SensitivityLabelCount -lt 3) {
$risks += @{ Area = "Data Classification"; Status = "AMBER"; Detail = "Only $($report.Sections.DataGovernance.SensitivityLabelCount) labels — expand before Copilot rollout" }
} else {
$risks += @{ Area = "Data Classification"; Status = "GREEN"; Detail = "$($report.Sections.DataGovernance.SensitivityLabelCount) labels configured" }
}
if ($report.Sections.ConditionalAccess.MFAPolicies -eq 0) {
$risks += @{ Area = "MFA Coverage"; Status = "RED"; Detail = "No CA policies enforce MFA — critical security gap" }
} elseif ($report.Sections.ConditionalAccess.MFAPolicies -lt 2) {
$risks += @{ Area = "MFA Coverage"; Status = "AMBER"; Detail = "MFA not universally enforced" }
} else {
$risks += @{ Area = "MFA Coverage"; Status = "GREEN"; Detail = "$($report.Sections.ConditionalAccess.MFAPolicies) MFA-enforcing policies active" }
}
$report.RiskRegister = $risks
# Save JSON report
$report | ConvertTo-Json -Depth 10 | Out-File "$OutputPathdiscovery-report.json" -Encoding UTF8
Write-Host " Discovery report saved: $OutputPathdiscovery-report.json" -ForegroundColor Green
# Generate HTML summary
$redCount = ($risks | Where-Object { $_.Status -eq "RED" }).Count
$amberCount = ($risks | Where-Object { $_.Status -eq "AMBER" }).Count
$greenCount = ($risks | Where-Object { $_.Status -eq "GREEN" }).Count
$html = @"
<!DOCTYPE html><html><head><meta charset='UTF-8'>
<title>AI Readiness Discovery — $TenantName</title>
<style>
body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; max-width: 960px; margin: 40px auto; padding: 0 24px; color: #1e293b; }
h1 { font-size: 28px; font-weight: 800; } h2 { font-size: 18px; font-weight: 700; margin-top: 32px; }
.meta { color: #64748b; font-size: 14px; margin-bottom: 32px; }
.grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; margin: 16px 0; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px 20px; }
.num { font-size: 28px; font-weight: 800; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: #64748b; padding: 8px 12px; border-bottom: 2px solid #e2e8f0; }
td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
.red { color: #dc2626; font-weight: 700; } .amber { color: #d97706; font-weight: 700; } .green { color: #16a34a; font-weight: 700; }
.badge { display:inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
.badge.red { background: #fef2f2; color: #dc2626; } .badge.amber { background: #fffbeb; color: #d97706; } .badge.green { background: #f0fdf4; color: #16a34a; }
footer { margin-top: 48px; font-size: 12px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 16px; }
</style></head><body>
<h1>AI Readiness Discovery Report</h1>
<div class='meta'>$TenantName · $reportDate · Prepared by $AssessorName, $AssessorFirm</div>
<h2>Risk Summary</h2>
<div class='grid'>
<div class='card'><div class='num red'>$redCount</div><div>Critical findings</div></div>
<div class='card'><div class='num amber'>$amberCount</div><div>Moderate findings</div></div>
<div class='card'><div class='num green'>$greenCount</div><div>Items passing</div></div>
</div>
<h2>Risk Register</h2>
<table><tr><th>Area</th><th>Status</th><th>Detail</th></tr>
$($risks | ForEach-Object { "<tr><td>$($_.Area)</td><td><span class='badge $($_.Status.ToLower())'>$($_.Status)</span></td><td>$($_.Detail)</td></tr>" } | Out-String)
</table>
<h2>Licence Summary</h2>
<p>Total licensed seats: <strong>$($report.Sections.Licencing.TotalSeats)</strong> · Assigned: <strong>$($report.Sections.Licencing.AssignedSeats)</strong></p>
<h2>Copilot Footprint</h2>
<p>Users with Copilot for Microsoft 365: <strong>$($report.Sections.CopilotFootprint.LicensedUserCount)</strong></p>
<footer>Generated by PAI MSP Toolkit · productionai.institute/msp/discovery · PSF-aligned assessment methodology</footer>
</body></html>
"@
$html | Out-File "$OutputPathdiscovery-summary.html" -Encoding UTF8
Write-Host " HTML summary saved: $OutputPathdiscovery-summary.html" -ForegroundColor Green
Write-Host ""
Write-Host "Discovery complete. Files in: $OutputPath" -ForegroundColor Cyan
Write-Host "Next step: Open discovery-report.json and discovery-summary.html and review with your team before stakeholder interviews." -ForegroundColor YellowYou now have structured data. Here is what to do with it before the client presentation.
Open the HTML report first. RED findings need to be resolved before any AI deployment. AMBER findings go into the roadmap. Share this internally with your delivery lead before the readout.
Use the risk register output to map each finding to the eight PSF domains. This gives your readout structure and connects every finding to a recognised governance framework.
The scripts get the technical picture. The interviews get the human one. Use the Assessment Facilitation Guide to run structured conversations with business owners before presenting.
Sort findings by RAG status and business impact. Red findings = immediate blockers. Amber = 30-day targets. Green = baseline to maintain. Each finding becomes a scoped deliverable.
The discovery report, risk register, roadmap, and ROI model together form the readout deck. Use the assessment report template from the toolkit and the ROI calculator to complete the package.
Every finding is a potential engagement. The assessment SOW is complete. The next SOW covers remediation, policy work, or deployment — depending on what the client prioritises.
Available to Certified AI Integrators via the partner toolkit.
The scripts are one piece. The full assessment methodology — stakeholder interviews, scoring, the readout — is in the Assessment Facilitation Guide. Both are included for Certified AI Integrators.