Get-AADLogs.ps1


function Get-AADLogs {

    <#
    .SYNOPSIS
    The Get-AADLogs function dumps in JSON files Entra ID devices related events for a specific time range. Please note that a Microsoft Entra ID P1 tenant is required to get sign in logs and more than a week of audit logs.
 
    .EXAMPLE
 
    PS C:\>$appId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    PS C:\>$tenant = "example.onmicrosoft.com"
    PS C:\>$certificatePath = "./example.pfx"
    PS C:\>$endDate = Get-Date
    PS C:\>$startDate = $endDate.AddDays(-30)
 
    PS C:\>Get-AADLogs -startDate $startDate -endDate $endDate -appId $appId -tenant $tenant -certificatePath $certificatePath
 
    Dump all Entra ID logs for the last 30 days.
 
    PS C:\>Get-AADLogs -startDate $startDate -endDate $endDate -appId $appId -tenant $tenant -certificatePath $certificatePath -dumpLogs "all"
 
    Dump all Entra ID logs for the last 30 days.
 
    PS C:\>Get-AADLogs -startDate $startDate -endDate $endDate -appId $appId -tenant $tenant -certificatePath $certificatePath -dumpLogs "auditOnly"
 
    Dump all Entra ID audit logs for the last 30 days.
 
    PS C:\>Get-AADLogs -startDate $startDate -endDate $endDate -appId $appId -tenant $tenant -certificatePath $certificatePath -dumpLogs "signInsOnly"
 
    Dump all Entra ID sign ins for the last 30 days.
    #>


    param (
        [Parameter(Mandatory = $true)]
        [DateTime]$endDate,
        [Parameter(Mandatory = $true)]
        [DateTime]$startDate,
        [Parameter(Mandatory = $true)]
        [String]$certificatePath,
        [Parameter(Mandatory = $true)]
        [String]$appId,
        [Parameter(Mandatory = $true)]
        [String]$tenant,
        [Parameter(Mandatory = $false)]
        [ValidateSet("all","auditOnly","signInsOnly")]
        [String]$dumpLogs = "all",
        [Parameter(Mandatory = $false)]
        [String]$logFile = "Get-AADLogs.log"
    )

    $currentPath = (Get-Location).path
    $logFile = $currentPath + "\" + $logFile

    if ($dumpLogs -eq "all"){
        "Processing sign in and audit logs" | Write-Log -LogPath $logFile
    }
    elseif ($dumpLogs -eq "auditOnly"){
        "Processing audit logs only" | Write-Log -LogPath $logFile
    }
    else {
        "Processing sign in logs only" | Write-Log -LogPath $logFile
    }

    $maxStartDate = (Get-Date).AddDays(-30)
    if ($startDate -lt $maxStartDate){
        Write-Warning "You can only get 30 days with Audit Log. Setting startDate to $maxStartDate"
        "You can only get 30 days with Audit Log. Setting startDate to $maxStartDate" | Write-Log -LogPath $logFile -LogLevel "Warning"
        $startDate = $maxStartDate
        if ($endDate -lt $startDate){
            Write-Host "Incompatible endDate: $endDate. Exiting"
            "Incompatible endDate: $endDate. Exiting" | Write-Log -LogPath $logFile
            exit
        }
    }

    $launchSearch =
    {
        param($newStartDate, $newEndDate, $currentPath, $tenantSize, $dumpLogs, $P1Enabled, $cert, $appId, $tenant)

        $dateToProcess = ($newStartDate.ToString("yyyy-MM-dd"))
        $logFile = $currentPath + "\AAD" + $dateToProcess + ".log"

        Connect-MicrosoftGraphApplication -certificate $cert -appId $appId -tenant $tenant -logFile $logFile

        # Get Entra ID audit logs
        if (($dumpLogs -eq "all") -or ($dumpLogs -eq "auditOnly")){
            $AzureADAuditFolder = $currentPath + "\azure_ad_audit"
            if ((Test-Path $AzureADAuditFolder) -eq $false){
                New-Item $AzureADAuditFolder -Type Directory
            }

            "Processing Entra ID audit logs for day $($dateToProcess)" | Write-Log -LogPath $logFile
            $outputdate = "{0:yyyy-MM-dd}" -f ($newStartDate)
            $outputFile = $AzureADAuditFolder + "\AADAuditLog_" + $tenant + "_" + $outputdate + ".json"
            $auditStart = "{0:s}" -f $newStartDate + "Z"
            $auditEnd = "{0:s}" -f $newEndDate + "Z"
            $AzureADAuditEvents = Get-MicrosoftGraphLogs -type "AuditLogs" -dateStart $auditStart -dateEnd $auditEnd -certificate $cert -appId $appId -tenant $tenant -logFile $logFile
            if ($AzureADAuditEvents){
                $nbAzureADAuditEvents = ($AzureADAuditEvents | Measure-Object).Count
                "Dumping $($nbAzureADAuditEvents) Entra ID audit events to $($outputFile)" | Write-Log -LogPath $logFile
                $AzureADAuditEvents | ConvertTo-Json -Depth 99 | Out-File $outputFile -Encoding UTF8
            }
            else {
                "No Entra ID audit event to dump to $($outputFile)" | Write-Log -LogPath $logFile -LogLevel "Warning" 
            }
        }

        # Get Entra ID sign in logs
        if (($dumpLogs -eq "all") -or ($dumpLogs -eq "signInsOnly"))
        {
            if ($P1Enabled -eq $true){
                $totalHours = [Math]::Floor((New-TimeSpan -Start $newStartDate -End $newEndDate).TotalHours)
                if ($totalHours -eq 24){
                    $totalHours--
                }
                for ($h=0; $h -le $totalHours; $h++){
                    if ($h -eq 0){
                        $newStartHour = $newStartDate
                        $newEndHour = $newStartDate.AddMinutes(59 - $newStartDate.Minute).AddSeconds(60 - $newStartDate.Second)
                    }
                    elseif ($h -eq $totalHours){
                        $newStartHour = $newEndHour
                        $newEndHour = $newEndDate
                    }
                    else {
                        $newStartHour = $newEndHour
                        $newEndHour = $newStartHour.addHours(1)
                    }
                    "Processing sign in logs between {0:yyyy-MM-dd} {0:HH:mm:ss} and {1:yyyy-MM-dd} {1:HH:mm:ss}" -f ($newStartHour, $newEndHour) | Write-Log -LogPath $logFile
                    $outputDate = "{0:yyyy-MM-dd}_{0:HH-00-00}" -f ($newStartHour)

                    $AzureADSignInsFolder = $currentPath + "\azure_ad_signin"
                    if ((Test-Path $AzureADSignInsFolder) -eq $false){
                        New-Item $AzureADSignInsFolder -Type Directory
                    }

                    $signInsStart = "{0:s}" -f $newStartHour + "Z"
                    $signInsEnd = "{0:s}" -f $newEndHour + "Z"
                    $AzureADSignInEvents = Get-MicrosoftGraphLogs -type "SignIns" -tenantSize $tenantSize -dateStart $signInsStart -dateEnd $signInsEnd -certificate $cert -appId $appId -tenant $tenant -logFile $logFile
                    $folderToProcess = $AzureADSignInsFolder + "\" + $dateToProcess
                    if ((Test-Path $folderToProcess) -eq $false){
                        New-Item $folderToProcess -Type Directory
                    }
                    $outputFile = $folderToProcess + "\AADSigninLog_" + $tenant + "_" + $outputdate + ".json"
                    if ($AzureADSignInEvents){
                        $nbADSigninEvents = ($AzureADSignInEvents | Measure-Object).Count
                        "Dumping $($nbADSigninEvents) Entra ID sign in events to $($outputFile)" | Write-Log -LogPath $logFile
                        $AzureADSignInEvents | ConvertTo-Json -Depth 99 | Out-File $outputFile -Encoding UTF8 
                    }
                    else {
                        "No Entra ID sign in events to dump to $($outputFile)" | Write-Log -LogPath $logFile -LogLevel "Warning"
                    }
                }
            }
            else {
                "No Entra ID P1 licence: can't dump sign in logs using API between {0:yyyy-MM-dd} {0:HH:mm:ss} and {1:yyyy-MM-dd} {1:HH:mm:ss}" -f ($newStartDate, $newEndDate) | Write-Log -LogPath $logFile -LogLevel "Warning"
            }
        }
    }

    $cert, $null, $null = Import-Certificate -certificatePath $certificatePath -logFile $logFile

    Get-RSJob | Remove-RSJob -Force

    Connect-MicrosoftGraphApplication -certificate $cert -appId $appId -tenant $tenant -logFile $logFile
 
    $AzureADTenantFolder = $currentPath + "\azure_ad_tenant"
    if ((Test-Path $AzureADTenantFolder) -eq $false){
        New-Item $AzureADTenantFolder -Type Directory | Out-Null
    }
    $outputFile = $AzureADTenantFolder + "\AADTenant_" + $tenant + ".json"

    # Test the directory size
    $tenantSize = "normal"
    $tenantInformation = Get-MgOrganization -ErrorAction Stop
    if ($tenantInformation.AdditionalProperties.directorySizeQuota.used -ge 100000){
        $tenantSize = "huge"
        if ($dumpLogs -eq "all" -or $dumpLogs -eq "signInsOnly"){
            Write-Warning "Directory size is huge, processing might be long. As a consequence, sign in logs will be filtered on some specific applications"
            "Directory size is huge, processing might be long. As a consequence, sign in logs will be filtered on some specific applications" | Write-Log -LogPath $logFile -LogLevel "Warning"
        }
        else {
            Write-Warning "Directory size is huge, processing of audit logs might be long"
            "Directory size is huge, processing of audit logs might be long" | Write-Log -LogPath $logFile -LogLevel "Warning"
        }
        Write-Host "You might also want to dump sign in logs and audit logs separately by using the dumpLogs switch"
        "You might also want to dump sign in logs and audit logs separately by using the dumpLogs switch" | Write-Log -LogPath $logFile
    }
    else {
        "Tenant of a normal size, dumping all logs" | Write-Log -LogPath $logFile
    }

    "Dumping tenant information in azure_ad_tenant folder" | Write-Log -LogPath $logFile
    $tenantInformation | ConvertTo-Json -Depth 99 | Out-File $outputFile -Encoding UTF8 

    # Check if Microsoft Entra ID P1 is enabled
    "Checking Microsoft Entra ID P1"| Write-Log -LogPath $logFile
    $P1Enabled = $true
    try {
        $null = Get-MgBetaAuditLogSignIn -Top 1 -All -ErrorAction Stop
    }
    catch {
        if ($_.ErrorDetails.Message -like "*RequestFromNonPremiumTenant*"){
            $P1Enabled = $false
            Write-Warning "Entra ID P1 is not enabled tenant-wide. You should buy at least one Entra ID P1 licence to be able to retrieve the full audit log"
            "Entra ID P1 is not enabled tenant-wide. You should buy at least one Entra ID P1 licence to be able to retrieve the full audit log" | Write-Log -LogPath $logFile -LogLevel "Warning"
            $maxStartDate = (Get-Date).AddDays(-7)
            if ($startDate -lt $maxStartDate){
                Write-Warning "Entra ID P1 not enabled tenant-wide, you can only get 7 days of Audit Log. Setting startDate to $maxStartDate"
                "Entra ID P1 not enabled tenant-wide, you can only get 7 days of Audit Log. Setting startDate to $maxStartDate" | Write-Log -LogPath $logFile -LogLevel "Warning"
                $startDate = [DateTime]$maxStartDate.ToString("yyyy-MM-dd")
                if ($endDate -lt $startDate){
                    Write-Error "Incompatible endDate: $endDate. Exiting"
                    "Incompatible endDate: $endDate. Exiting" | Write-Log -LogPath $logFile -LogLevel "Error"
                    exit
                }
            }
        }
    }    

    $totalTimeSpan = (New-TimeSpan -Start $startDate -End $endDate)
    if (($totalTimeSpan.Hours -eq 0) -and ($totalTimeSpan.Minutes -eq 0) -and ($totalTimeSpan.Seconds -eq 0)){
        $totalDays = $totalTimeSpan.days
        $totalLoops = $totalDays
    }
    else {
        $totalDays = $totalTimeSpan.days + 1
        $totalLoops = $totalTimeSpan.days
    }

    for ($d=0; $d -le $totalLoops; $d++){
        if ($d -eq 0){
            $newStartDate = $startDate
            $newEndDate = Get-Date("{0:yyyy-MM-dd} 00:00:00.000" -f ($newStartDate.AddDays(1)))
        }
        elseif ($d -eq $totalDays){
            $newEndDate = $endDate
            $newStartDate = Get-Date("{0:yyyy-MM-dd} 00:00:00.000" -f ($newEndDate))
        }
        else {
            $newStartDate = $newEndDate
            $newEndDate = $newEndDate.AddDays(1)
        }

        "Lauching job number $($d) with startDate {0:yyyy-MM-dd} {0:HH:mm:ss} and endDate {1:yyyy-MM-dd} {1:HH:mm:ss}" -f ($newStartDate, $newEndDate) | Write-Log -LogPath $logFile
        $dateToProcess = ($newStartDate.ToString("yyyy-MM-dd"))
        $jobName = "AAD" + $dateToProcess

        Start-RSJob -Name $jobName -ScriptBlock $launchSearch -FunctionsToImport Write-Log, Connect-MicrosoftGraphApplication, Get-MicrosoftGraphLogs -ArgumentList $newStartDate, $newEndDate, $currentPath, $tenantSize, $dumpLogs, $P1Enabled, $cert, $appId, $tenant

        $maxJobRunning = 3
        if ($tenantSize -eq "huge"){
            $maxJobRunning = 1
        }

        $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
        while ($jobRunningCount -ge $maxJobRunning){
            Start-Sleep -Seconds 1
            $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
        }
        $jobsDone = Get-RSJob | Where-Object {$_.State -eq "Completed"}
        if ($jobsDone){
            foreach ($jobDone in $jobsDone){
                "Runspace Job $($jobDone.Name) has finished - dumping log" | Write-Log -LogPath $logFile
                $logFileName = $jobDone.Name + ".log"
                Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                Remove-Item $logFileName -Confirm:$false -Force
                $jobDone | Remove-RSJob
                "Runspace Job $($jobDone.Name) finished - job removed" | Write-Log -LogPath $logFile
            }
        }
        $jobsFailed = Get-RSJob | Where-Object {$_.State -eq "Failed"}
        if ($jobsFailed){
            foreach ($jobFailed in $jobsFailed){
                "Runspace Job $($jobFailed.Name) failed with error $($jobFailed.Error)" | Write-Log -LogPath $logFile -LogLevel "Error"
                "Runspace Job $($jobFailed.Name) failed - dumping log" | Write-Log -LogPath $logFile -LogLevel "Error"
                $logFileName = $jobFailed.Name + ".log"
                Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
                Remove-Item $logFileName -Confirm:$false -Force
                $jobFailed | Remove-RSJob
                "Runspace Job $($jobFailed.Name) failed - job removed" | Write-Log -LogPath $logFile -LogLevel "Error"
            }
        }
    }

    # Waiting for final jobs to complete
    $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
    while ($jobRunningCount -ge 1){
        Start-Sleep -Seconds 1
        $jobRunningCount = (Get-RSJob | Where-Object {$_.State -eq "Running"} | Measure-Object).Count
    }
    $jobsDone = Get-RSJob | Where-Object {$_.State -eq "Completed"}
    if ($jobsDone){
        foreach ($jobDone in $jobsDone){
            "Runspace Job $($jobDone.Name) has finished - dumping log" | Write-Log -LogPath $logFile
            $logFileName = $jobDone.Name + ".log"
            Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
            Remove-Item $logFileName -Confirm:$false -Force
            $jobDone | Remove-RSJob
            "Runspace Job $($jobDone.Name) finished - job removed" | Write-Log -LogPath $logFile
        }
    }
    $jobsFailed = Get-RSJob | Where-Object {$_.State -eq "Failed"}
    if ($jobsFailed){
        foreach ($jobFailed in $jobsFailed){
            "Runspace Job $($jobFailed.Name) failed with error $($jobFailed.Error)" | Write-Log -LogPath $logFile -LogLevel "Error"
            "Runspace Job $($jobFailed.Name) failed - dumping log" | Write-Log -LogPath $logFile -LogLevel "Error"
            $logFileName = $jobFailed.Name + ".log"
            Get-Content $logFileName | Out-File $logFile -Encoding UTF8 -Append
            Remove-Item $logFileName -Confirm:$false -Force
            $jobFailed | Remove-RSJob
            "Runspace Job $($jobFailed.Name) failed - job removed" | Write-Log -LogPath $logFile -LogLevel "Error"
        }
    }
}