Querying the Microsoft 365 Unified Audit Log datamart via the Graph API

Over the past couple of months, several announcements have been made around the Microsoft 365 Unified audit log and the methods used to access it. Some changes were good, such as the improvements made on the UI side, where we finally got some more meaningful filters. Others fall under the expected (but still concerning category), such as the recently announced deprecation of the Exchange Online audit log cmdlets and their replacement with the UAL. Some were simply puzzling, such as the reduced number of results returned when using the Search-UnifiedAuditLog cmdlet and the now mandatory –SessionCommand parameter to fetch additional results.

In this article, we will cover another recent improvement, namely the introduction of Graph API endpoints for exploring the Unified Audit Log. This functionality, now available in public preview, allows you to create asynchronous queries against the UAL, and fetch their results once the backend task is complete. This is the equivalent of the UI search experience, as seen in the Microsoft 365 Compliance/Purview portal. Let’s take a look, shall we.

Documentation issues

The first thing to note here, and I cannot stress this enough, is that the official documentation is currently WRONG. Well, not all of it, luckily, but the endpoint mentioned across the corresponding articles certainly are. Take for example the Create audit log query article, which tells us that in order to create a new Unified audit log search we need to issue a POST request against the /security/auditCore/auditLogQueries endpoint:

#DO NOT USE THIS ONE!
POST https://graph.microsoft.com/beta/security/auditCore/auditLogQueries

If you try using said endpoint, you will end up getting an error. Instead, the correct endpoint is /security/auditLog/queries:

POST https://graph.microsoft.com/beta/security/auditLog/queries

Having been part of the private preview, I have been playing with the API approach for a while now, so imagine my surprise when the public preview was announced and the corresponding documentation featured totally different endpoints. Judging from the naming schema used across said documentation, it looks like Microsoft planned (or is planning?) to introduce a “premium” tier on the API front here as well, akin to what we have on the eDiscovery front. We shall see, safe to say that nothing surprises me anymore.

In addition, there are some documentation issues around the available filter parameters, as we will see later on.

Permission requirements

Anyway, I digress. Back to the task at hand – exploring the /security/auditLog/queries endpoint. Before we dive into some examples, we need to take care of authentication. The documentation on that part is correct, and tells us that we can leverage either delegate or application permissions. Microsoft aimed to provide some granularity here, thus the list of supported permissions and scopes features not only the “directory wide” AuditLogsQuery.Read.All scope (not to be confused with the AuditLog.Read.All one!), but also workload-specific variations. The idea behind the latter is to allow you to scope the permissions granted on a given user or application so that they can only run Audit log queries against events generated from specific workload, say Exchange Online. In which case, you can use the “narrow” AuditLogsQuery-Exchange.Read.All scope instead of the all encompassing AuditLogsQuery.Read.All.

A simple example

Once permissions have been granted, and a token obtained, you can submit a new query against the Unified audit log by issuing a POST request against the /beta/security/auditLog/queries endpoint, with a JSON body representing the actual search query. Let’s start simple, with only a time-based filter. In the example below, we give our new search a name via the displayName property and we configure the time period to cover via and filterStartDateTime and filterEndDateTime, respectively:

$uri = "https://graph.microsoft.com/beta/security/auditLog/queries"
$body = @{
"displayName" = "Graph API test"
"filterStartDateTime" = "2023-08-14T00:00:00.000Z"
"filterEndDateTime" = "2023-08-18T00:00:00.000Z"
}

$gr = Invoke-WebRequest -Headers $authHeader1 -Uri $uri -Method POST -Body ($body | ConvertTo-Json) -Verbose -Debug -ContentType "application/json"
$searchID = ($gr.Content | ConvertFrom-Json).Id

At this point, a request to create a new Unified audit log search query is sent to the backend, and we have to wait for it to be scheduled and executed before any matching results become available. We can periodically examine the status by executing a GET query against the /beta/security/auditLog/queries/{id} endpoint, and providing the GUID of our request, which we conveniently stored in the $searchID variable. Unfortunately, we don’t get any indication of the progress, unlike the UI. Here’s a sample request you can use to check the status of the query:

$uri = "https://graph.microsoft.com/beta/security/auditLog/queries/$searchID"
$gr = Invoke-WebRequest -Headers $authHeader1 -Uri $uri -Verbose -Debug
$gr.Content | ConvertFrom-Json

UAL GraphAPI

Once the status of our newly created query updates to succeeded, we can issue another GET request in order to fetch the set of matching UAL events. This request should be made against the /security/auditLog/queries/$searchID/records endpoint, and the response will give you a maximum of 150 results, similar to the UI. To check whether more matches have been found, make sure to check the value of @odata.count, and follow any @odata.nextLink page links! On the screenshot below, only the first result is shown:

$uri = "https://graph.microsoft.com/beta/security/auditLog/queries/$searchID/records"
$gr = Invoke-WebRequest -Headers $authHeader1 -Uri $uri -Verbose -Debug
($gr.Content | ConvertFrom-Json).value

UAL GraphAPI1

Additional examples and details

As a more advanced example, let us create a search that will look for specific operations, such as the File deleted event from SPO/ODFB. In addition, for this example we will be leveraging application permissions, which works practically the same on the API side of things, but does show minor differences in the UI. And, this time we will be leveraging the “limited” scope for performing the query (AuditLogsQuery-SharePoint.Read.All), whereas the example above was created via the “wide” AuditLogsQuery.Read.All permissions. Here’s the corresponding request:

UAL GraphAPI3

$uri = "https://graph.microsoft.com/beta/security/auditLog/queries"
$body = @{
"displayName" = "Graph API test with application permissions"
"filterStartDateTime" = "2024-02-14T00:00:00.000Z"
"filterEndDateTime" = "2024-02-18T00:00:00.000Z"
"operationFilters" = @("fileaccessed","filedeleted")
}

$gr = Invoke-WebRequest -Headers $authHeader1 -Uri $uri -Method POST -Body ($body | ConvertTo-Json) -Verbose -Debug
$searchID = ($gr.Content | ConvertFrom-Json).Id

Interestingly, queries created via application permissions do not get exposed under the Audit page in the UI, or when running a LIST query via delegate permissions. Vice versa, queries performed via application permissions against the /beta/security/auditLog/queries endpoint show no trace of any previously created searches in the delegate permissions context, regardless of whether they were created via the UI or the API. This is certainly something you have to keep in mind if you want to audit usage of the UAL functionality within your organization.

We can of course still monitor the progress and fetch the results via the API:

$uri = "https://graph.microsoft.com/beta/security/auditLog/queries/$searchID/records"
$gr = Invoke-WebRequest -Headers $authHeader1 -Uri $uri -Verbose -Debug
($gr.Content | ConvertFrom-Json).value

UAL GraphAPI2

As above, only the first result is shown on the screenshot, but that should be sufficient to illustrate the successful execution of our UAL search via application permissions. Of course, the examples provided here are rather simplistic, as for any real life scenarios you probably want to use a bit more complicated combination of filters in order to narrow down the number of results found. You can of course filter by the RecordType (not currently working?), Workload, Operation, UserID/ObjectID, keyword and so on, as detailed in the official documentation.

Some additional remarks and summary

Before closing the article, few additional bits need to be mentioned. First, the API allows you to query events past the 90/180 day limit for tenants with “premium” SKU. In fact, our first example demonstrated this, by surfacing events from Aug 2023. There is a nuance though. If you try to create a search query that exceeds the maximum allowed period, say by trying our first example on a “non-premium” tenant, the query will still be scheduled and executed… and will even return results. This in turn prompted me to try something similar with the UI… which also generated results. This behavior is likely not what Microsoft indented, and until we see some official document confirming it, I’d put it in the bug category.

On a similar note, it looks like the current implementation has issues with some of the filters, at least those based on the type of record (i.e. recordTypeFilter). Whichever values I tried seem to be ignored, which is more than just an annoyance and limits the feature’s usefulness. After checking with some folks at Microsoft, the issue was revealed to be a documentation snafu, again. Instead of recordTypeFilter, the correct property name is recordTypeFilterS, and the input it accepts should be in array format. Here’s an example of a working filter based on recordType:

$body = @{
"displayName" = "Graph API test #2"
"filterStartDateTime" = "2023-08-14T00:00:00.000Z"
"filterEndDateTime" = "2023-08-18T00:00:00.000Z"
"recordTypeFilters" = @("sharePointFileOperation","threatIntelligence")
}

Lastly, the API also had issues with the workload-scoped permissions, such as AuditLogsQuery-SharePoint.Read.All. In my tests, I was able to fetch Exchange admin events even though the token I was using only featured the aforementioned permission, which goes against what the documentation is telling us, and against Microsoft’ design, too. Again I was able to reach out to the team and the issue was quickly addressed, with all my later tests only returning a “trimmed” set of result, as per the permissions included in the access token. The perils of using beta software, I suppose.

Lastly, compared to the UI, you can spot few differences, such as the set of filters. This goes both ways, for example the UI does not currently support an IP-based filter, whereas the API does. The UI shows you how long it took to perform the query, and provides an estimate for its progress, whereas when using the API, you have to time it yourself and have no way of telling how far it has progressed. In addition, the API does not currently expose a Delete method, whereas you can most certainly delete existing queries via the UI. Similarly, there is no way to cancel a search in progress via the API.

In summary, we now have access to Graph API endpoints for performing searches against the Unified Audit Log in Microsoft 365. The API supports both delegate and application permissions, including permissions scoped to specific workloads only.  While there is no full parity with the UI just yet, and there are some mishaps with the documentation, overall this feature is a much welcomed addition. Personally, I would have loved to also get a Graph API implementation of the good old Search-UnifiedAuditLog cmdlet, which remains the only way to run queries against the Unified audit log in synchronous mode.

1 thought on “Querying the Microsoft 365 Unified Audit Log datamart via the Graph API

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.