Auditing Azure and Office365 administrator activity using PowerShell

Tracking changes made by administrators in Azure and/or Office365 can be difficult and I’m sure that there are a lot of alternatives to query this information – but I wanted to share this basic script, even though several parts of it may need improvement.

TL;DR:
1. Make sure you have ExchangeOnlineManagement PS module installed
2. Check the values of the variables, make changes if needed
3. Run the script, use an AAD admin account when connecting to ExchangeOnline
4. Results will be exported to $outputDirectory in HTML format (C:\Office365_AuditExport by default)

The script itself:

$startDate = (get-date).AddDays(-30)
$endDate = (Get-date)
$outputDirectory = "C:\Office365_AuditExport\"
$str_date = Get-Date -Format yyyyMMdd_HHmm

<## Gather information ##>
<# --Create PowerShell Session-- #>
Connect-ExchangeOnline

<#-- Get administrative users -- #>
$administrators = Get-RoleGroup | % {Get-RoleGroupMember $_.Name | ? {$_.RecipientType -ne "group"} } | % {$_.windowsLiveId} | select -Unique
$userIds = $administrators -join ","

<#-- Set RecordTypes
https://docs.microsoft.com/en-us/microsoft-365/compliance/detailed-properties-in-the-office-365-audit-log?view=o365-worldwide
#>

$recordtypes = "ExchangeAdmin","MicrosoftTeams","MicrosoftTeamsAdmin","SecurityComplianceCenterEOPCmdlet","SharePoint","SharePointSharingOperation","SkypeForBusinessCmdlets"

<#-- Load audit events --#>
$auditevents = foreach ($recordtype in $recordtypes){
    Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType $recordtype
    Clear-Variable recordtype
    }

<# Load AzureAD Admin events (recordtype = 8) --#>
$azureAD_adminAuditEvents = Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType 8

<#-- Create report object --#>
$obj_report =  $auditevents.auditdata | ConvertFrom-Json | ? {($_.usertype -eq 2) -and ($_.operation -notmatch "Get-")} | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,@{Label='parameters';Expression={($_.parameters | % {" -" + $_.Name + " " + $_.value}) -join ""} }

<#-- Add AzureAD admin records --#>
$obj_AzureADreport = $azureAD_adminAuditEvents.auditdata | ConvertFrom-Json | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,id

<#-- Create HTML report --#>
$body = $null

$body += "<p><b>Admin Activity report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Paramaters</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Parameters + "</td></tr>"
}
  
$body += "</table>"
$body += "<p><b>Admin Activity report (AzureAD)</b></p>"


$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Id</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_AzureADreport){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Id + "</td></tr>"
}
  
$body += "</table>"

#Export
if (!(Test-Path $outputDirectory)){mkdir $outputDirectory}
$body > $outputDirectory\O365Audit_$str_date.html

Explained

The first four lines contains the values of the variables used later. The time range queried is the last 30 days ($startDate, $endDate) and the report will be exported to C:\Office365_AuditExport folder ($outputDirectory). The last variable will be used as a suffix for the filename:
$startDate = (get-date).AddDays(-30)
$endDate = (Get-date)
$outputDirectory = "C:\Office365_AuditExport\"
$str_date = Get-Date -Format yyyyMMdd_HHmm

Then we connect to Exchange Online:
Connect-ExchangeOnline

The next step is to query the roles in Exchange and store the members of these roles in a variable – if the member is not a group:
$administrators = Get-RoleGroup | % {Get-RoleGroupMember $_.Name | ? {$_.RecipientType -ne "group"} } | % {$_.windowsLiveId} | select -Unique

These userIDs can be joined to a string, separated with a comma, because the Search-UnifiedAuditLog cmdlet accepts it in this form:
$userIds = $administrators -join ","

Next, we set the record types we want to query. Possible values can be found here (link):
$recordtypes = "ExchangeAdmin","MicrosoftTeams","MicrosoftTeamsAdmin","SecurityComplianceCenterEOPCmdlet","SharePoint","SharePointSharingOperation","SkypeForBusinessCmdlets"

Now we are ready to load the audit logs. The maximum result size is limited to 5000 records for a search session by default, you can overcome this issue with several methods (example):
$auditevents = foreach ($recordtype in $recordtypes){
Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType $recordtype
Clear-Variable recordtype
}

Azure Active Directory events use a different schema so they are loaded in a separate variable:
$azureAD_adminAuditEvents = Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType 8

Now that the informations are loaded, it’s time to convert it and filter out unnecessary events (like commands with Get verb):
$obj_report = $auditevents.auditdata | ConvertFrom-Json | ? {($_.usertype -eq 2) -and ($_.operation -notmatch "Get-")} | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,@{Label='parameters';Expression={($_.parameters | % {" -" + $_.Name + " " + $_.value}) -join ""} }

The same applies to the AzureAD events:
$obj_AzureADreport = $azureAD_adminAuditEvents.auditdata | ConvertFrom-Json | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,id

The event data loaded in $obj_report and $obj_AzureADreport is then inserted to HTML code:

$body = $null

$body += "<p><b>Admin Activity report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Paramaters</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Parameters + "</td></tr>"
}
  
$body += "</table>"
$body += "<p><b>Admin Activity report (AzureAD)</b></p>"


$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Id</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_AzureADreport){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Id + "</td></tr>"
}
  
$body += "</table>"

The last step is to export the HTML report to the previously defined directory:
if (!(Test-Path $outputDirectory)){mkdir $outputDirectory}
$body > $outputDirectory\O365Audit_$str_date.html

The result should look like this:

The script can be improved in several ways, but it can be a good start or at least serve as inspiration. Probably I will share an improved version later…

Leave a Reply