Last active
March 25, 2026 11:49
-
-
Save puttyq/764ba84cd37a689a7ca612df7ef3d502 to your computer and use it in GitHub Desktop.
PSSchema.ps1 - M365 Management Agent Schema and Import Logic (mim-m365-activity)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Token acquisition in PSImport.ps1 (client credentials flow) | |
| $tokenBody = @{ | |
| grant_type = "client_credentials" | |
| client_id = $settings.ClientId | |
| client_secret = $settings.ClientSecret | |
| scope = "https://graph.microsoft.com/.default" | |
| } | |
| $tokenResponse = Invoke-RestMethod \` | |
| -Uri "https://login.microsoftonline.com/$($settings.TenantId)/oauth2/v2.0/token" \` | |
| -Method Post \` | |
| -Body $tokenBody | |
| $accessToken = $tokenResponse.access_token |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Get email activity for the last 30 days | |
| $uri = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D30')" | |
| $response = Invoke-RestMethod -Uri $uri \` | |
| -Headers @{ Authorization = "Bearer $accessToken" } \` | |
| -Method Get | |
| # Response is a CSV — pipe straight into ConvertFrom-Csv | |
| $activityData = $response | ConvertFrom-Csv |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 1. Pull email activity report (returns CSV, convert to objects) | |
| $emailActivity = Invoke-RestMethod \` | |
| -Uri "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D30')" \` | |
| -Headers @{Authorization="Bearer $accessToken"} | ConvertFrom-Csv | |
| # Build a lookup hashtable keyed on UPN for fast joins | |
| $emailIndex = @{} | |
| foreach ($row in $emailActivity) { | |
| $emailIndex[$row.'User Principal Name'] = $row | |
| } | |
| # 2. Enumerate users with signInActivity (paged) | |
| $usersUri = "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName,displayName,signInActivity&`$top=999" | |
| do { | |
| $page = Invoke-RestMethod -Uri $usersUri -Headers @{Authorization="Bearer $accessToken"} | |
| foreach ($user in $page.value) { | |
| $mail = $emailIndex[$user.userPrincipalName] | |
| # Emit combined object to MIM connector space | |
| $obj = New-Object -Type PSCustomObject | |
| $obj | Add-Member -NotePropertyName "Anchor-UPN|String" -NotePropertyValue $user.userPrincipalName | |
| $obj | Add-Member -NotePropertyName "displayName|String" -NotePropertyValue $user.displayName | |
| $obj | Add-Member -NotePropertyName "lastSignInDateTime|String" -NotePropertyValue $user.signInActivity.lastSignInDateTime | |
| $obj | Add-Member -NotePropertyName "mailLastActivityDate|String" -NotePropertyValue $mail?.'Last Activity Date' | |
| $obj | Add-Member -NotePropertyName "mailSendCount|String" -NotePropertyValue $mail?.'Send Count' | |
| $obj | Add-Member -NotePropertyName "mailReceiveCount|String" -NotePropertyValue $mail?.'Receive Count' | |
| $obj # yield to MIM | |
| } | |
| $usersUri = $page.'@odata.nextLink' | |
| } while ($usersUri) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| PSImport.ps1 - M365 Management Agent Import Script | |
| .DESCRIPTION | |
| A PSMA to import M365 activity (directory and Exchange Online) from M365. | |
| This import script reads Entra user, audit logs, and Exchange Online activity from | |
| Microsoft 365 and exposes it to Microsoft Identity Manager / ECMA Host through the Granfeldt PowerShell Management Agent. | |
| Required Entra ID (Azure AD) permissions (Application Registration): | |
| - AuditLog.Read.All (sign-in activity) | |
| - Directory.Read.All (user enumeration) | |
| - Report.Read.All (getEmailActivityUserDetail report) | |
| - User.Read.All (user enumeration) | |
| OPTIONAL - Required Exchange Online permissions (Application Registration): | |
| - Mail.Read (mailbox activity) | |
| - Exchange Admin or delegated mailbox access (for Get-Mailbox and Get-MailboxStatistics) | |
| .NOTES | |
| Version History | |
| v1.0 - Initial M365 PSImport script with sign-in activity and optional mailbox activity | |
| v1.1 - Added feature flag to enable/disable Exchange mailbox check, and error handling for non-mailbox users | |
| v1.2 - Added getEmailActivityUserDetail report import (with configurable period) | |
| v1.3 - Added structured logging via PowerShell Logging module | |
| .LINK | |
| Author - Almero Steyn - https://www.puttyq.com | |
| Integralis Website - http://www.integralis.co.za | |
| Integralis Support - help@integralis.co.za | |
| Granfeldt PowerShell Management Agent - https://github.com/sorengranfeldt/psma | |
| #> | |
| PARAM | |
| ( | |
| $Username, | |
| $Password, | |
| $Credentials, | |
| $AuxUsername, | |
| $AuxPassword, | |
| $AuxCredentials, | |
| $OperationType, | |
| $UsePagedImport, | |
| $PageSize, | |
| $ImportPageNumber, | |
| $ConfigurationParameter, | |
| $Schema | |
| ) | |
| BEGIN | |
| { | |
| # Setup structured logging using PowerShell Logging module | |
| Import-Module Logging | |
| # Logging - Configure targets | |
| $LogPath = if ($ConfigurationParameter.LogPath) { | |
| $ConfigurationParameter.LogPath | |
| } else { | |
| ".\Debug" | |
| } | |
| Add-LoggingTarget -Name File -Configuration @{ | |
| Path = "$LogPath\Import-%{+%Y%m%d}.log" | |
| Append = $true | |
| Encoding = 'ascii' | |
| RotateAfterAmount = 10 | |
| RotateAmount = 1 | |
| RotateAfterSize = 1048576 | |
| } | |
| Add-LoggingTarget -Name Console -Configuration @{ | |
| PrintException = $true | |
| ColorMapping = @{ | |
| 'DEBUG' = 'Blue' | |
| 'INFO' = 'Green' | |
| 'WARNING' = 'Yellow' | |
| 'ERROR' = 'Red' | |
| } | |
| } | |
| Set-LoggingDefaultLevel -Level 'DEBUG' | |
| Set-LoggingDefaultFormat -Format '[%{timestamp:+yyyy/MM/dd HH:mm:ss.fff}] [%{level:-7}] - %{message}' | |
| Write-Log -Level 'INFO' -Message 'M365 Activity Import starting' | |
| # Function to get last sent mail date for a user, with error handling for non-mailbox users and feature flag to enable/disable Exchange check | |
| function Get-LastSentMailDate { | |
| param( | |
| [string]$UserPrincipalName, | |
| [hashtable]$Headers, | |
| [bool]$EnableExchangeCheck | |
| ) | |
| $lastSentMailDate = $null | |
| if ($EnableExchangeCheck) { | |
| $MailUri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/mailFolders('sentitems')/messages?`$top=1&`$orderby=sentDateTime desc" | |
| try { | |
| $MailResponse = Invoke-RestMethod -Uri $MailUri -Headers $Headers | |
| if ($MailResponse.value -and $MailResponse.value.Count -gt 0) { | |
| $lastSentMailDate = $MailResponse.value[0].sentDateTime | |
| Write-Log -Level 'DEBUG' -Message " Last sent mail date: $lastSentMailDate" | |
| } | |
| else { | |
| $lastSentMailDate = $null | |
| } | |
| } | |
| catch { | |
| $lastSentMailDate = $null | |
| Write-Log -Level 'WARNING' -Message " Mailbox not enabled or error: $_" | |
| } | |
| } | |
| else { | |
| $lastSentMailDate = $null | |
| } | |
| return $lastSentMailDate | |
| } | |
| function isValuePresent($passedValue) | |
| { | |
| if($null -ne $passedValue -and $passedValue -ne '') | |
| { | |
| return $true | |
| } | |
| return $false | |
| } | |
| # Variables for Entra App Registration | |
| $ClientId = $Username | |
| $TenantId = $Password | |
| $ClientSecret = $AuxPassword | |
| # Check Exchange mailbox check feature flag | |
| $EnableExchangeCheck = ($ConfigurationParameter.EnableExchangeCheck -eq "true") | |
| # Check getEmailActivityUserDetail via Graph API | |
| if ($ConfigurationParameter.EmailActivityUserDetail -in @('D180','D90','D30','D7')) { | |
| $EmailActivityUserDetail = $true | |
| $EmailActivityUserDetailDays = $ConfigurationParameter.EmailActivityUserDetail | |
| } else { | |
| $EmailActivityUserDetail = $false | |
| $EmailActivityUserDetailDays = 'D0' | |
| } | |
| # Uncomment to override with local test config (see TestConfig.local.ps1.example) | |
| # . "$PSScriptRoot\TestConfig.local.ps1" | |
| # Authenticate to Microsoft Graph (non-interactive) | |
| $TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" | |
| $Body = @{ | |
| client_id = $ClientId | |
| scope = "https://graph.microsoft.com/.default" | |
| client_secret = $ClientSecret | |
| grant_type = "client_credentials" | |
| } | |
| try { | |
| $TokenResponse = Invoke-RestMethod -Method Post -Uri $TokenEndpoint -Body $Body | |
| $AccessToken = $TokenResponse.access_token | |
| Write-Log -Level 'INFO' -Message "Successfully authenticated to Microsoft Graph" | |
| } | |
| catch { | |
| Write-Log -Level 'ERROR' -Message "Failed to authenticate to Microsoft Graph: $_" | |
| throw | |
| } | |
| Write-Log -Level 'INFO' -Message "EnableExchangeCheck: $EnableExchangeCheck" | |
| Write-Log -Level 'INFO' -Message "EmailActivityUserDetail: $EmailActivityUserDetail (period: $EmailActivityUserDetailDays)" | |
| } | |
| PROCESS | |
| { | |
| # Get all users from Entra ID in batches, including signInActivity | |
| $Headers = @{ Authorization = "Bearer $AccessToken" } | |
| $users = [System.Collections.Generic.List[object]]::new() | |
| $batchSize = 500 | |
| $filter = "userType eq 'Member'" | |
| $select = "id,userPrincipalName,mail,mailNickname,signInActivity" | |
| $nextLink = "https://graph.microsoft.com/v1.0/users?`$filter=$filter&`$select=$select&`$top=$batchSize" | |
| while ($nextLink) { | |
| Write-Log -Level 'DEBUG' -Message "Fetching batch: $nextLink" | |
| try { | |
| $UsersResponse = Invoke-RestMethod -Uri $nextLink -Headers $Headers | |
| } | |
| catch { | |
| Write-Log -Level 'ERROR' -Message "Failed to fetch users from Microsoft Graph: $nextLink - $_" | |
| throw | |
| } | |
| $UsersResponse.value | ForEach-Object { $users.Add($_) } | |
| if ($UsersResponse.'@odata.nextLink') { | |
| $nextLink = $UsersResponse.'@odata.nextLink' | |
| } | |
| else { | |
| $nextLink = $null | |
| } | |
| } | |
| Write-Log -Level 'INFO' -Message "Fetched $($users.Count) users from Entra ID" | |
| # Get EmailActivityUserDetail report details (if enabled) | |
| if ($EmailActivityUserDetail) { | |
| Write-Log -Level 'INFO' -Message "Fetching EmailActivityUserDetail report (period: $EmailActivityUserDetailDays)" | |
| try { | |
| $EmailActivityUserDetailReportResults = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='$EmailActivityUserDetailDays')" -Headers $Headers | |
| $EmailActivityUserDetailData = $EmailActivityUserDetailReportResults | ConvertFrom-csv | |
| $EmailActivityLookup = @{} | |
| foreach ($record in $EmailActivityUserDetailData) { | |
| $EmailActivityLookup[$record."User Principal Name"] = $record | |
| } | |
| Write-Log -Level 'INFO' -Message "EmailActivityUserDetail report loaded - $($EmailActivityUserDetailData.Count) records" | |
| } | |
| catch { | |
| Write-Log -Level 'ERROR' -Message "Failed to fetch EmailActivityUserDetail report: $_" | |
| $EmailActivityUserDetail = $false | |
| } | |
| } | |
| # Loop through users and print sign-in dates, with optional Exchange mailbox check | |
| for ($i = 0; $i -lt $users.Count; $i++) { | |
| $user = $users[$i] | |
| $userPrincipalName = $user.userPrincipalName | |
| Write-Log -Level 'INFO' -Message "Processing user $($i + 1)/$($users.Count): $userPrincipalName" | |
| # signInActivity is now included in $user | |
| $lastInteractiveDate = $null | |
| $lastNonInteractiveDate = $null | |
| if ($user.signInActivity) { | |
| $lastInteractiveDate = $user.signInActivity.lastSignInDateTime | |
| $lastNonInteractiveDate = $user.signInActivity.lastNonInteractiveSignInDateTime | |
| } | |
| if ($user.mail -and $user.mailNickname) { | |
| $lastSentMailDate = Get-LastSentMailDate -UserPrincipalName $userPrincipalName -Headers $Headers -EnableExchangeCheck $EnableExchangeCheck | |
| } | |
| else { | |
| $lastSentMailDate = $null | |
| Write-Log -Level 'DEBUG' -Message " Skipping Exchange mailbox check: no mail or mailNickname." | |
| } | |
| # Calculate age in days for each activity type | |
| $now = Get-Date | |
| $interactiveAgeDays = if ($lastInteractiveDate) { [math]::Ceiling(($now - [datetime]$lastInteractiveDate).TotalDays) } else { $null } | |
| $nonInteractiveAgeDays = if ($lastNonInteractiveDate) { [math]::Ceiling(($now - [datetime]$lastNonInteractiveDate).TotalDays) } else { $null } | |
| $sentMailAgeDays = if ($lastSentMailDate) { [math]::Ceiling(($now - [datetime]$lastSentMailDate).TotalDays) } else { $null } | |
| # Emit import object to PSMA pipeline | |
| $obj = @{} | |
| $obj.Add("objectClass", "user") | |
| if (isValuePresent $userPrincipalName) { $obj.Add("UserPrincipalName", $userPrincipalName.ToString()) } | |
| if (isValuePresent $lastInteractiveDate) { $obj.Add("LastInteractiveSignIn", $lastInteractiveDate.ToString()) } | |
| if (isValuePresent $interactiveAgeDays) { $obj.Add("LastInteractiveAgeDays", $interactiveAgeDays.ToString()) } | |
| if (isValuePresent $lastNonInteractiveDate) { $obj.Add("LastNonInteractiveSignIn", $lastNonInteractiveDate.ToString()) } | |
| if (isValuePresent $nonInteractiveAgeDays) { $obj.Add("LastNonInteractiveAgeDays", $nonInteractiveAgeDays.ToString()) } | |
| if (isValuePresent $lastSentMailDate) { $obj.Add("LastSentMailDate", $lastSentMailDate.ToString()) } | |
| if (isValuePresent $sentMailAgeDays) { $obj.Add("LastSentMailAgeDays", $sentMailAgeDays.ToString()) } | |
| if ($EmailActivityUserDetail) { | |
| $ActivityDetails = $EmailActivityLookup[$userPrincipalName] | |
| if ($ActivityDetails) { | |
| Write-Log -Level 'DEBUG' -Message " Email activity data found for $userPrincipalName" | |
| if (isValuePresent $ActivityDetails."Read Count") { $obj.Add("ReportReadCount", ($ActivityDetails."Read Count").ToString()) } | |
| if (isValuePresent $ActivityDetails."Receive Count") { $obj.Add("ReportReceiveCount", ($ActivityDetails."Receive Count").ToString()) } | |
| if (isValuePresent $ActivityDetails."Send Count") { $obj.Add("ReportSendCount", ($ActivityDetails."Send Count").ToString()) } | |
| if (isValuePresent $ActivityDetails."Last Activity Date") { $obj.Add("ReportLastActivityDate", ($ActivityDetails."Last Activity Date").ToString()) } | |
| if (isValuePresent $ActivityDetails."Report Period") { $obj.Add("ReportPeriod", ($ActivityDetails."Report Period").ToString()) } | |
| } | |
| else { | |
| Write-Log -Level 'WARNING' -Message " No email activity report data found for $userPrincipalName" | |
| } | |
| } | |
| $obj | |
| } | |
| } | |
| END | |
| { | |
| Write-Log -Level 'INFO' -Message "M365 Activity Import complete - processed $($users.Count) users" | |
| Wait-Logging | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| PSSchema.ps1 - M365 Management Agent Schema Definition | |
| .DESCRIPTION | |
| Schema for Entra and Exchange Online last sign-in activity | |
| Source System: | |
| - Entra ID (User and Audit Logs) | |
| - Exchange Online (Optional) | |
| - Exchange Usage Reports (Optional) | |
| .NOTES | |
| Version History | |
| - v1.0 - Initial schema definition for M365 activity data | |
| .LINK | |
| Author - Almero Steyn - https://www.puttyq.com | |
| Integralis Website - http://www.integralis.co.za | |
| Integralis Support - help@integralis.co.za | |
| Granfeldt PowerShell Management Agent - https://github.com/sorengranfeldt/psma | |
| #> | |
| param | |
| ( | |
| $Username, | |
| $Password, | |
| $Credentials, | |
| $AuxUsername, | |
| $AuxPassword, | |
| $AuxCredentials, | |
| $ConfigurationParameter | |
| ) | |
| $obj = New-Object -Type PSCustomObject | |
| $obj | Add-Member -Type NoteProperty -Name "Anchor-UserPrincipalName|String" -Value "user@domain.com" | |
| $obj | Add-Member -Type NoteProperty -Name "objectClass|String" -Value "user" | |
| $obj | Add-Member -Type NoteProperty -Name "LastInteractiveSignIn|String" -Value "2023-01-01T12:00:00Z" | |
| $obj | Add-Member -Type NoteProperty -Name "LastInteractiveAgeDays|String" -Value "345" | |
| $obj | Add-Member -Type NoteProperty -Name "LastNonInteractiveSignIn|String" -Value "2023-01-15T12:00:00Z" | |
| $obj | Add-Member -Type NoteProperty -Name "LastNonInteractiveAgeDays|String" -Value "345" | |
| $obj | Add-Member -Type NoteProperty -Name "LastSentMailDate|String" -Value "2023-01-20T12:00:00Z" | |
| $obj | Add-Member -Type NoteProperty -Name "LastSentMailAgeDays|String" -Value "345" | |
| $obj | Add-Member -Type NoteProperty -Name "ReportReadCount|String" -Value "100" | |
| $obj | Add-Member -Type NoteProperty -Name "ReportReceiveCount|String" -Value "100" | |
| $obj | Add-Member -Type NoteProperty -Name "ReportSendCount|String" -Value "100" | |
| $obj | Add-Member -Type NoteProperty -Name "ReportLastActivityDate|String" -Value "2023-01-20T12:00:00Z" | |
| $obj | Add-Member -Type NoteProperty -Name "ReportPeriod|String" -Value "D180" | |
| $obj |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment