Public/Get-TntM365AuditEvent.ps1
|
function Get-TntM365AuditEvent { <# .SYNOPSIS Audits and reports on specific events in Microsoft 365 and Azure AD. .DESCRIPTION This function retrieves and processes audit logs for user lifecycle (creation, deletion) and group membership activities. Use the -AuditMode parameter to switch between the event streams while keeping authentication options consistent. .PARAMETER TenantId The Azure AD Tenant ID to connect to. .PARAMETER ClientId The Application (Client) ID of the app registration with necessary permissions. .PARAMETER ClientSecret The client secret for the app registration. .PARAMETER CertificateThumbprint The thumbprint of a certificate for authentication. .PARAMETER DaysBack Number of days to look back for audit logs. Defaults to 30 days. Maximum is 30. .PARAMETER ExportToFile If specified, exports the report to a file in the specified format. .PARAMETER OutputPath The directory path where the report file will be saved. Defaults to the current directory. .PARAMETER OutputFormat The output format for the exported file. Valid values are 'CSV' or 'JSON'. Required if -ExportToFile is used. .PARAMETER UserFilter Filter results by the user principal name of the affected user. Supports wildcards. .PARAMETER InitiatedByFilter Filter results by the user principal name of the person who initiated the action. Supports wildcards. .PARAMETER GroupFilter (Group mode only) Filter results by the display name of the affected group. Supports wildcards. .PARAMETER AuditMode Selects the audit stream to query. Use 'User' for account lifecycle events (creation, deletion) or 'Group' for membership changes. Legacy modes 'UserCreation' and 'GroupMembership' are also supported. .EXAMPLE Get-TntM365AuditEvent -AuditMode User -TenantId "..." -ClientId "..." -ClientSecret $secret -DaysBack 14 Retrieves all user creation and deletion events from the last 14 days. .EXAMPLE Get-TntM365AuditEvent -AuditMode Group -TenantId "..." -ClientId "..." -ClientSecret $secret -GroupFilter "*Admins*" | Export-Csv -Path ".\AdminGroupChanges.csv" -NoTypeInformation Retrieves group membership changes for any group with "Admins" in the name and exports the results to a CSV file. .EXAMPLE Get-TntM365AuditEvent -AuditMode User -TenantId "..." -ClientId "..." -ClientSecret $secret -ExportToFile -OutputFormat JSON -OutputPath "C:\AuditReports" Retrieves user lifecycle events and saves them to a JSON file in the C:\AuditReports directory. .OUTPUTS System.Management.Automation.PSCustomObject Returns a report object containing: - Summary: Statistics including TenantId, AuditMode, DaysBack, TotalEvents, SuccessfulEvents, FailedEvents - Details: Array of audit event objects with Timestamp, Activity, Result, InitiatedBy, and target information .NOTES Author: Tom de Leeuw Website: https://systom.dev Module: TenantReports Required Permissions: - AuditLog.Read.All (Application) - Directory.Read.All (Application) .LINK https://systom.dev #> [CmdletBinding(DefaultParameterSetName = 'ClientSecret')] [OutputType([System.Management.Automation.PSObject])] param( # Tenant ID of the Microsoft 365 tenant. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string]$TenantId, # Application (client) ID of the registered app. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [Alias('ApplicationId')] [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')] [string]$ClientId, # Client secret credential when using secret-based authentication. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Alias('ApplicationSecret')] [ValidateNotNullOrEmpty()] [SecureString]$ClientSecret, # Certificate thumbprint for certificate-based authentication. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, # Use interactive authentication (no app registration required). [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, # Number of days to include in the report window. [Parameter()] [ValidateRange(1, 30)] [int]$DaysBack = 30, # Filter results by the user principal name of the affected user. Supports wildcards. [Parameter()] [string]$UserFilter, # Filter results by the user principal name of the initiator. Supports wildcards. [Parameter()] [string]$InitiatedByFilter, # Filter results by the display name of the affected group. Supports wildcards. [string]$GroupFilter, # Select which audit stream to query. [Parameter()] [Alias('Mode')] [ValidateSet('User', 'Group', 'UserCreation', 'GroupMembership')] [string]$AuditMode = 'User' ) begin { Write-Information 'Starting audit event retrieval...' -InformationAction Continue } process { try { # Establish connection $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters $ConnectionInfo = Connect-TntGraphSession @ConnectionParams # Define audit activities and properties based on the requested audit mode $AuditActivities = @() $IsGroupMode = $false switch ($AuditMode) { { $_ -in 'User', 'UserCreation' } { $AuditActivities = @( 'Add user', 'Create user', 'Invite external user', 'Delete user' ) } { $_ -in 'Group', 'GroupMembership' } { $AuditActivities = @( 'Add member to group', 'Remove member from group' ) $IsGroupMode = $true } default { $AuditActivities = @( 'Add user', 'Create user', 'Invite external user', 'Delete user' ) } } # Build a single, efficient filter for the API call $ActivityFilters = $AuditActivities | ForEach-Object { "activityDisplayName eq '$($_)'" } $FilterString = $ActivityFilters -join ' or ' $startDate = (Get-Date).AddDays(-$DaysBack).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $FullFilter = "activityDateTime ge $startDate and ($FilterString)" Write-Verbose "Retrieving audit logs with filter: $FullFilter" $auditLogs = Get-MgAuditLogDirectoryAudit -Filter $FullFilter -All Write-Verbose "Retrieved $($auditLogs.Count) total log entries." $GroupCache = @{} if ($IsGroupMode) { Write-Verbose 'Pre-caching group names for audit events...' # Extract unique group IDs from all audit logs $UniqueGroupIds = $auditLogs.TargetResources | Where-Object { $_.Type -eq 'Group' } | Select-Object -ExpandProperty Id -Unique # Build cache with single API call per unique group foreach ($GroupId in $UniqueGroupIds) { if (-not $GroupCache.ContainsKey($GroupId)) { try { $group = Get-MgGroup -GroupId $GroupId -Property DisplayName -ErrorAction Stop $GroupCache[$GroupId] = $group.DisplayName } catch { Write-Warning "Could not retrieve group name for ID '$GroupId' - using ID as fallback" $GroupCache[$GroupId] = $GroupId } } } Write-Verbose "Group name cache built with $($GroupCache.Count) entries" } # Process logs with cached lookups (no API calls in loop) Write-Verbose 'Processing audit events...' $Results = foreach ($log in $auditLogs) { # Determine initiator - can be either a User or an App (service principal) $InitiatedBy = if ($log.InitiatedBy.User.UserPrincipalName) { $log.InitiatedBy.User.UserPrincipalName } elseif ($log.InitiatedBy.App.DisplayName) { $log.InitiatedBy.App.DisplayName } else { 'Unknown' } # Common properties for all events $Output = [PSCustomObject]@{ Timestamp = $log.ActivityDateTime Activity = $log.ActivityDisplayName Result = $log.Result InitiatedBy = $InitiatedBy InitiatedByIP = $log.InitiatedBy.User.IPAddress TargetUserUPN = '' TargetDeviceName = '' TargetGroupName = '' TargetObjectID = '' } # Extract target resources. An event can have multiple targets. $targetUser = $log.TargetResources | Where-Object { $_.Type -eq 'User' } | Select-Object -First 1 $targetDevice = $log.TargetResources | Where-Object { $_.Type -eq 'Device' } | Select-Object -First 1 $targetGroup = $log.TargetResources | Where-Object { $_.Type -eq 'Group' } | Select-Object -First 1 if ($targetUser) { $Output.TargetUserUPN = $targetUser.UserPrincipalName $Output.TargetObjectID = $targetUser.Id } if ($targetDevice) { $Output.TargetDeviceName = $targetDevice.DisplayName $Output.TargetObjectID = $targetDevice.Id } if ($targetGroup) { $GroupId = $targetGroup.Id # Overwrite TargetObjectID if group is the primary target $Output.TargetObjectID = $targetGroup.Id if ($GroupCache.ContainsKey($GroupId)) { $Output.TargetGroupName = $GroupCache[$GroupId] } else { # Fallback if group not in cache (shouldn't happen) Write-Verbose "Cache miss for group ID: $GroupId (using DisplayName from audit log)" $Output.TargetGroupName = $targetGroup.DisplayName ?? $GroupId } } # Apply client-side filters $isMatch = $true if ($InitiatedByFilter -and $Output.InitiatedBy -notlike $InitiatedByFilter) { $isMatch = $false } if ($UserFilter -and $Output.TargetUserUPN -notlike $UserFilter) { $isMatch = $false } if ($IsGroupMode -and $GroupFilter -and $Output.TargetGroupName -notlike $GroupFilter) { $isMatch = $false } if ($isMatch) { # Output the object to the pipeline $Output } } Write-Verbose "Processing complete. Found $($Results.Count) matching events." Write-Information "Audit event retrieval completed - $($Results.Count) matching events found" -InformationAction Continue # Return standardized Summary + Details structure [PSCustomObject]@{ Summary = [PSCustomObject]@{ TenantId = $TenantId ReportGeneratedDate = Get-Date AuditMode = $AuditMode DaysBack = $DaysBack TotalEvents = $Results.Count SuccessfulEvents = ($Results | Where-Object { $_.Result -eq 'success' }).Count FailedEvents = ($Results | Where-Object { $_.Result -eq 'failure' }).Count } Details = $Results } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-TntM365AuditEvent failed: $($_.Exception.Message)", $_.Exception), 'GetTntM365AuditEventError', [System.Management.Automation.ErrorCategory]::OperationStopped, $TenantId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } finally { if ($ConnectionInfo.ShouldDisconnect) { Disconnect-TntGraphSession -ConnectionState $ConnectionInfo | Out-Null } } } } |