Get a daily admin Audit Report for MEM / Intune

In an environment where you have multiple admin users it’s useful to audit admin activities so everyone can be aware of changes that others have made. I do this for Endpoint Configuration Manager with a daily email report built from admin status messages, so I decided to create something similar for Intune / MEM.

Admin actions are already audited for you in MEM (Tenant Administration > Audit logs) so it’s simply a case of getting that data into an email report. You can do this with Graph (which gives you more data actually) but I decided to use Log Analytics for this instead.

You need a Log Analytics workspace, and you need to configure Diagnostics settings in the MEM portal to send AuditLogs to the workspace.

Then, in order to automate sending a daily report create a service principal in Azure AD with just the permissions necessary to read data from the Log Analytics workspace. You can do this easily from the Azure portal using CloudShell. In the example below, I’m creating a new service principal with the role “Log Analytics Reader” scoped just to the Log Analytics workspace where the AuditLogs are sent to.

$DisplayName = "MEM-Reporting"
$Role = "Log Analytics Reader"
$Scope = "/subscriptions/<subscriptionId>/resourcegroups/<resourcegroupname>/providers/microsoft.operationalinsights/workspaces/<workspacename>"

$sp = New-AzADServicePrincipal -DisplayName $DisplayName -Role $Role -Scope $Scope

With the service principal created, you’ll need to make a note of the ApplicationId:

$sp.ApplicationId

And the secret:

$SP.Secret | ConvertFrom-SecureString -AsPlainText

Of course, if you prefer you can use certificate authentication instead of using the secret key.

Below is a PowerShell script that uses the Az PowerShell module to connect to the log analytics workspace as the service principal, query the IntuneAuditLogs for entries in the last 24 hours, then send them in an HTML email report. Run it with your favourite automation tool.

You’ll need the app Id and secret from the service principal, your tenant Id, your log analytics workspace Id, and don’t forget to update the email parameters.

Sample email report
# Script to send a daily audit report for admin activities in MEM/Intune
# Requirements:
# – Log Analytics Workspace
# – Intune Audit Logs saved to workspace
# – Service Principal with 'Log Analytics reader' role in workspace
# – Azure Az PowerShell modules
# Azure resource info
$ApplicationId = "abc73938-0000-0000-0000-9b01316a9123" # Service Principal Application Id
$Secret = "489j49r-0000-0000-0000-e2dc6451123" # Service Principal Secret
$TenantID = "abc894e7-00000-0000-0000-320d0334b123" # Tenant ID
$LAWorkspaceID = "abcc1e47-0000-0000-0000-b7ce2b2bb123" # Log Analytics Workspace ID
$Timespan = (New-TimeSpan Hours 24)
# Email params
$EmailParams = @{
To = 'trevor.jones@smsagent.blog'
From = 'MEMReporting@smsagent.blog'
Smtpserver = 'smsagent.mail.protection.outlook.com'
Port = 25
Subject = "MEM Audit Report | $(Get-Date Format ddMMMyyyy)"
}
# Html CSS style
$Style = @"
<style>
table {
border-collapse: collapse;
font-family: sans-serif
font-size: 12px
}
td, th {
border: 1px solid #ddd;
padding: 6px;
}
th {
padding-top: 8px;
padding-bottom: 8px;
text-align: left;
background-color: #3700B3;
color: #03DAC6
}
</style>
"@
# Connect to Azure with Service Principal
$Creds = [PSCredential]::new($ApplicationId,(ConvertTo-SecureString $Secret AsPlaintext Force))
Connect-AzAccount ServicePrincipal Credential $Creds Tenant $TenantID
# Run the Log Analytics Query
$Query = "IntuneAuditLogs | sort by TimeGenerated desc"
$Results = Invoke-AzOperationalInsightsQuery WorkspaceId $LAWorkspaceID Query $Query Timespan $Timespan
$ResultsArray = [System.Linq.Enumerable]::ToArray($Results.Results)
# Converts the results to a datatable
$DataTable = New-Object System.Data.DataTable
$Columns = @("Date","Initiated by (actor)","Application Name","Activity","Operation Status","Target Name","Target ObjectID")
foreach ($Column in $Columns)
{
[void]$DataTable.Columns.Add($Column)
}
foreach ($result in $ResultsArray)
{
$Properties = $Result.Properties | ConvertFrom-Json
[void]$DataTable.Rows.Add(
$Properties.ActivityDate,
$result.Identity,
$Properties.Actor.ApplicationName,
$result.OperationName,
$result.ResultType,
$Properties.TargetDisplayNames[0],
$Properties.TargetObjectIDs[0]
)
}
# Send an email
If ($DataTable.Rows.Count -ge 1)
{
$HTML = $Datatable |
ConvertTo-Html Property "Date","Initiated by (actor)","Application Name","Activity","Operation Status","Target Name","Target ObjectID" Head $Style Body "<h2>MEM Admin Activities in the last 24 hours</h2>" |
Out-String
Send-MailMessage @EmailParams Body $html BodyAsHtml
}

Collecting ConfigMgr Client Logs to Azure Storage

In the 2002 release of Endpoint Configuration Manager, Microsoft added a nice capability to collect log files from a client to the site server. Whilst this is a cool capability, you might not be on 2002 yet or you might prefer to send logs to a storage account in Azure rather than to the site server. You can do that quite easily using the Run Script feature. This works whether the client is connected on the corporate network or through a Cloud Management Gateway.

To do this you need a storage account in Azure, a container in the account, and a Shared access signature.

I’ll assume you have the first two in place, so let’s create a Shared access signature. In the Storage account in the Azure Portal, click on Shared access signature under Settings.

  • Under Allowed services, check Blob.
  • Under Allowed resource types, check Object.
  • Under Allowed permissions, check Create.

Set an expiry date then click Generate SAS and connection string. Copy the SAS token and keep it safe somewhere.

Below is a PowerShell script that will upload client log files to Azure storage.

## Uploads client logs files to Azure storage
$Logs = Get-ChildItem "$env:SystemRoot\CCM\Logs"
$Date = Get-date -Format "yyyy-MM-dd-HH-mm-ss"
$ContainerURL = "https://mystorageaccount.blob.core.windows.net/mycontainer&quot;
$FolderPath = "ClientLogFiles/$($env:COMPUTERNAME)/$Date"
$SASToken = "?sv=2019-10-10&ss=b&srt=o&sp=c&se=2030-05-01T06:31:59Z&st=2020-04-30T22:31:59Z&spr=https&sig=123456789abcdefg"
$Responses = New-Object System.Collections.ArrayList
$Stopwatch = New-object System.Diagnostics.Stopwatch
$Stopwatch.Start()
foreach ($Log in $Logs)
{
$Body = Get-Content $($Log.FullName) -Raw
$URI = "$ContainerURL/$FolderPath/$($Log.Name)$SASToken"
$Headers = @{
'x-ms-content-length' = $($Log.Length)
'x-ms-blob-type' = 'BlockBlob'
}
$Response = Invoke-WebRequest -Uri $URI -Method PUT -Headers $Headers -Body $Body
[void]$Responses.Add($Response)
}
$Stopwatch.Stop()
Write-host "$(($Responses | Where {$_.StatusCode -eq 201}).Count) log files uploaded in $([Math]::Round($Stopwatch.Elapsed.TotalSeconds,2)) seconds."

Update the following parameters in your script:

  • ContainerURL. This is the URL to the container in your storage account. You can find it by clicking on the container, then Properties > URL.
  • SASToken. This is the SAS token string you created earlier.

Create and approve a new Script in ConfigMgr with this code. You can then run it against any online machine, or collection. When it’s complete, it will output how many log files were uploaded and how long the upload took.

To view the log files, you can either browse them in storage account in the Azure portal looking at the container directly, or using the Storage explorer. My preferred method is to use the standalone Microsoft Azure Storage Explorer app, where you can simply double-click a log file to open it, or easily download the folder containing the log files to your local machine.

Delete Device Records in AD / AAD / Intune / Autopilot / ConfigMgr with PowerShell

I’ve done a lot of testing with Windows Autopilot in recent times. Most of my tests are done in virtual machines, which are ideal as I can simply dispose of them after. But you also need to cleanup the device records that were created in Azure Active Directory, Intune, the Autopilot registration service, Microsoft Endpoint Manager (if you’re using it) and Active Directory in the case of Hybrid-joined devices.

To make this a bit easier, I wrote the following PowerShell script. You simply enter the device name and it’ll go and search for that device in any of the above locations that you specify and delete the device records.

The script assumes you have the appropriate permissions, and requires the Microsoft.Graph.Intune and AzureAD PowerShell modules, as well as the Configuration Manager module if you want to delete from there.

You can delete from all of the above locations with the -All switch, or you can specify any combination, for example -AAD -Intune -ConfigMgr, or -AD -Intune etc.

In the case of the Autopilot device registration, the device must also exist in Intune before you attempt to delete it as the Intune record is used to determine the serial number of the device.

Please test thoroughly before using on any production device!

Examples

Delete-AutopilotedDeviceRecords -ComputerName PC01 -All
@(
    'PC01'
    'PC02'
    'PC03'
) | foreach {
    Delete-AutopilotedDeviceRecords -ComputerName $_ -AAD -Intune
}

Output

Script

[CmdletBinding(DefaultParameterSetName='All')]
Param
(
[Parameter(ParameterSetName='All',Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)]
[Parameter(ParameterSetName='Individual',Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)]
$ComputerName,
[Parameter(ParameterSetName='All')]
[switch]$All = $True,
[Parameter(ParameterSetName='Individual')]
[switch]$AD,
[Parameter(ParameterSetName='Individual')]
[switch]$AAD,
[Parameter(ParameterSetName='Individual')]
[switch]$Intune,
[Parameter(ParameterSetName='Individual')]
[switch]$Autopilot,
[Parameter(ParameterSetName='Individual')]
[switch]$ConfigMgr
)
Set-Location $env:SystemDrive
# Load required modules
If ($PSBoundParameters.ContainsKey("AAD") -or $PSBoundParameters.ContainsKey("Intune") -or $PSBoundParameters.ContainsKey("Autopilot") -or $PSBoundParameters.ContainsKey("ConfigMgr") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-host "Importing modules…" NoNewline
If ($PSBoundParameters.ContainsKey("AAD") -or $PSBoundParameters.ContainsKey("Intune") -or $PSBoundParameters.ContainsKey("Autopilot") -or $PSBoundParameters.ContainsKey("All"))
{
Import-Module Microsoft.Graph.Intune ErrorAction Stop
}
If ($PSBoundParameters.ContainsKey("AAD") -or $PSBoundParameters.ContainsKey("All"))
{
Import-Module AzureAD ErrorAction Stop
}
If ($PSBoundParameters.ContainsKey("ConfigMgr") -or $PSBoundParameters.ContainsKey("All"))
{
Import-Module $env:SMS_ADMIN_UI_PATH.Replace('i386','ConfigurationManager.psd1') ErrorAction Stop
}
Write-host "Success" ForegroundColor Green
}
Catch
{
Write-host "$($_.Exception.Message)" ForegroundColor Red
Return
}
}
# Authenticate with Azure
If ($PSBoundParameters.ContainsKey("AAD") -or $PSBoundParameters.ContainsKey("Intune") -or $PSBoundParameters.ContainsKey("Autopilot") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-Host "Authenticating with MS Graph and Azure AD…" NoNewline
$intuneId = Connect-MSGraph ErrorAction Stop
$aadId = Connect-AzureAD AccountId $intuneId.UPN ErrorAction Stop
Write-host "Success" ForegroundColor Green
}
Catch
{
Write-host "Error!" ForegroundColor Red
Write-host "$($_.Exception.Message)" ForegroundColor Red
Return
}
}
Write-host "$($ComputerName.ToUpper())" ForegroundColor Yellow
Write-Host "===============" ForegroundColor Yellow
# Delete from AD
If ($PSBoundParameters.ContainsKey("AD") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-host "Retrieving " NoNewline
Write-host "Active Directory " ForegroundColor Yellow NoNewline
Write-host "computer account…" NoNewline
$Searcher = [ADSISearcher]::new()
$Searcher.Filter = "(sAMAccountName=$ComputerName`$)"
[void]$Searcher.PropertiesToLoad.Add("distinguishedName")
$ComputerAccount = $Searcher.FindOne()
If ($ComputerAccount)
{
Write-host "Success" ForegroundColor Green
Write-Host " Deleting computer account…" NoNewline
$DirectoryEntry = $ComputerAccount.GetDirectoryEntry()
$Result = $DirectoryEntry.DeleteTree()
Write-Host "Success" ForegroundColor Green
}
Else
{
Write-host "Not found!" ForegroundColor Red
}
}
Catch
{
Write-host "Error!" ForegroundColor Red
$_
}
}
# Delete from Azure AD
If ($PSBoundParameters.ContainsKey("AAD") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-host "Retrieving " NoNewline
Write-host "Azure AD " ForegroundColor Yellow NoNewline
Write-host "device record/s…" NoNewline
[array]$AzureADDevices = Get-AzureADDevice SearchString $ComputerName All:$true ErrorAction Stop
If ($AzureADDevices.Count -ge 1)
{
Write-Host "Success" ForegroundColor Green
Foreach ($AzureADDevice in $AzureADDevices)
{
Write-host " Deleting DisplayName: $($AzureADDevice.DisplayName) | ObjectId: $($AzureADDevice.ObjectId) | DeviceId: $($AzureADDevice.DeviceId) …" NoNewline
Remove-AzureADDevice ObjectId $AzureADDevice.ObjectId ErrorAction Stop
Write-host "Success" ForegroundColor Green
}
}
Else
{
Write-host "Not found!" ForegroundColor Red
}
}
Catch
{
Write-host "Error!" ForegroundColor Red
$_
}
}
# Delete from Intune
If ($PSBoundParameters.ContainsKey("Intune") -or $PSBoundParameters.ContainsKey("Autopilot") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-host "Retrieving " NoNewline
Write-host "Intune " ForegroundColor Yellow NoNewline
Write-host "managed device record/s…" NoNewline
[array]$IntuneDevices = Get-IntuneManagedDevice Filter "deviceName eq '$ComputerName'" ErrorAction Stop
If ($IntuneDevices.Count -ge 1)
{
Write-Host "Success" ForegroundColor Green
If ($PSBoundParameters.ContainsKey("Intune") -or $PSBoundParameters.ContainsKey("All"))
{
foreach ($IntuneDevice in $IntuneDevices)
{
Write-host " Deleting DeviceName: $($IntuneDevice.deviceName) | Id: $($IntuneDevice.Id) | AzureADDeviceId: $($IntuneDevice.azureADDeviceId) | SerialNumber: $($IntuneDevice.serialNumber) …" NoNewline
Remove-IntuneManagedDevice managedDeviceId $IntuneDevice.Id Verbose ErrorAction Stop
Write-host "Success" ForegroundColor Green
}
}
}
Else
{
Write-host "Not found!" ForegroundColor Red
}
}
Catch
{
Write-host "Error!" ForegroundColor Red
$_
}
}
# Delete Autopilot device
If ($PSBoundParameters.ContainsKey("Autopilot") -or $PSBoundParameters.ContainsKey("All"))
{
If ($IntuneDevices.Count -ge 1)
{
Try
{
Write-host "Retrieving " NoNewline
Write-host "Autopilot " ForegroundColor Yellow NoNewline
Write-host "device registration…" NoNewline
$AutopilotDevices = New-Object System.Collections.ArrayList
foreach ($IntuneDevice in $IntuneDevices)
{
$URI = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($IntuneDevice.serialNumber)')"
$AutopilotDevice = Invoke-MSGraphRequest Url $uri HttpMethod GET ErrorAction Stop
[void]$AutopilotDevices.Add($AutopilotDevice)
}
Write-Host "Success" ForegroundColor Green
foreach ($device in $AutopilotDevices)
{
Write-host " Deleting SerialNumber: $($Device.value.serialNumber) | Model: $($Device.value.model) | Id: $($Device.value.id) | GroupTag: $($Device.value.groupTag) | ManagedDeviceId: $($device.value.managedDeviceId) …" NoNewline
$URI = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities/$($device.value.Id)"
$AutopilotDevice = Invoke-MSGraphRequest Url $uri HttpMethod DELETE ErrorAction Stop
Write-Host "Success" ForegroundColor Green
}
}
Catch
{
Write-host "Error!" ForegroundColor Red
$_
}
}
}
# Delete from ConfigMgr
If ($PSBoundParameters.ContainsKey("ConfigMgr") -or $PSBoundParameters.ContainsKey("All"))
{
Try
{
Write-host "Retrieving " NoNewline
Write-host "ConfigMgr " ForegroundColor Yellow NoNewline
Write-host "device record/s…" NoNewline
$SiteCode = (Get-PSDrive PSProvider CMSITE ErrorAction Stop).Name
Set-Location ("$SiteCode" + ":") ErrorAction Stop
[array]$ConfigMgrDevices = Get-CMDevice Name $ComputerName Fast ErrorAction Stop
Write-Host "Success" ForegroundColor Green
foreach ($ConfigMgrDevice in $ConfigMgrDevices)
{
Write-host " Deleting Name: $($ConfigMgrDevice.Name) | ResourceID: $($ConfigMgrDevice.ResourceID) | SMSID: $($ConfigMgrDevice.SMSID) | UserDomainName: $($ConfigMgrDevice.UserDomainName) …" NoNewline
Remove-CMDevice InputObject $ConfigMgrDevice Force ErrorAction Stop
Write-Host "Success" ForegroundColor Green
}
}
Catch
{
Write-host "Error!" ForegroundColor Red
$_
}
}
Set-Location $env:SystemDrive

The Cost of Running a Personal Windows 10 VM in Azure

As amazing as it may sound for an IT professional of many years, aside from my work laptops, I do not own a Windows computer! For my personal computing requirements, I use a £200 Chromebook to connect to the internet and do most everything I need in the cloud.

My dear wife, on the other hand, is not a fan of the Chromebook and likes a ‘good old-fashioned’ Windows computer. But since her computing requirements are minimal, I decided to investigate the cost of running a Windows 10 VM in Azure instead of buying a new Windows laptop that she won’t use very often. Turns out, it’s quite a cost effective way to run a Windows 10 OS 🙂 The only things you really pay for are the disk storage and VM compute time. To access the VM, we simply use a remote desktop app on the Chromebook.

The first thing you need is an Azure subscription. Currently you get some credits for the first month, then some services that are free for the first 12 months. Even after that, if you have no resources in the subscription you won’t pay a penny for it.

You can create a Windows 10 VM from the Azure Marketplace. Creating the VM will create a few resources in a resource group such as a NIC, an NSG (network security group), a disk and of course the VM itself. To save on cost, I didn’t add any data disk and just used the 127GiB disk that comes with the OS. I also used the basic sku for the NSG, and I didn’t assign a static public IP address – I simply added a DNS name. You’ll get charged for a static IP (something like £0.06 p/day) but if you use a dynamic IP with a DNS name you won’t get charged anything.

For the disk, I somehow assumed that I would need a Premium SSD to get good performance as that’s what I would typically use for corporate VMs, but as this is for home use and I’m not really concerned about SLAs, I experimented with the Standard SSD and the Standard HDD as well. I was surprised to find the the Standard HDD was perfectly adequate for every day use and I didn’t really notice much difference in performance with either of the SSD options. Of course you do get less IOPS with an HDD, but that hasn’t been any issue. Since an HDD is much cheaper than an SSD, it made sense to use one.

For the VM size, I used an F4s_V2 which is compute optimized, has 8GB RAM, 4vCPUs and runs great. You could certainly get away with a smaller size though and shave your compute costs, something like a DS2_V3 and it’ll still run just fine.

In the tables below I summarized the actual costs of running the VM and also compared the costs of using Premium SSD/Standard SSD/Standard HDD. These costs are in GBP (£) and are in the UK South Azure region and are true at the time of writing – prices will vary based on region and currency and VM compute hours. The costs are also from actual invoiced costs – not from the Azure price calculator. The price calculator can give a good ball-park figure but in my experience the actual cost will be different…

Note: there are also data egress costs, ie data coming out of Azure. Downloads etc within the VM are ingress and don’t generally get charged. But even for egress you get the first 5GB free anyway (see here).

Time periodCost (£ GBP)
Per hour0.16
Per day3.84
Per month116.8
Per year1401.6
Compute costs for F4s_V2 VM
Time periodCost (£ GBP) Premium SSDCost (£ GBP) Standard SSDCost (£ GBP) Standard HDD
Per day0.570.250.12
Per month17.347.63.65
Per year208.0591.2543.8
Disk storage costs

So the base cost for owning the VM is £3.65 p/month using a Standard HDD. On top of that is the compute time. For example, if I use the VM for 20 hours in a month, the compute cost is £3.20 for the month. Add that to the base cost, and it’s £6.85 for the month. That’s not bad 🙂

Some key things to remember are that you always pay for the disk storage whether you use the VM or not. You only pay for compute time when you actually turn on the VM and use it. Always remember to actually stop your VM when finished (not just shut down the OS) so that the resources are de-allocated and you are not charged for unnecessary compute time. Use the Auto-shutdown feature to ensure the VM gets stopped every night. Also, since you have a public IP address it’s a good idea to use some NSG rules to restrict who or from where and on which ports you can access your VM.

Using an Azure VM for personal computing needs is a great option – you benefit from the elasticity and scalability of the cloud, and you only pay for what you use. You can scale up or scale down at any time according to your needs and you can set a budget and keep your eye on how much you’re spending using Azure cost management.

Setting the Computer Description During Windows Autopilot

I’ve been getting to grips with Windows Autopilot recently and, having a long history working with SCCM, I’ve found it hard not to compare it with the power of traditional OSD using a task sequence. In fact, one of my goals was to basically try to reproduce what I’m doing in OSD with Autopilot in order to end up with the same result – and it’s been a challenge.

I like the general concept of Autopilot and don’t get me wrong – it’s getting better all the time – but it still has its shortcomings that require a bit of creativity to work around. One of the things I do during OSD is to set the computer description in AD. That’s fairly easy to do in a task sequence; you can just script it and run the step using credentials that have the permission to make that change.

In Autopilot however (hybrid AAD join scenario), although you can run Powershell scripts too, they will only run in SYSTEM context during the Autopilot process. That means you either need to give computer accounts the permission to change their own properties in AD, or you have to find a way to run that code using alternate credentials. You can run scripts in the context of the logged-on user, but I don’t want to do that – in fact I disable the user ESP – I want to use a specific account that has those permissions.

You could use SCCM to do it post-deployment if you are co-managing the device, but ideally I want everything to be native to Autopilot where possible, and move away from the hybrid mentality of do what you can with Intune, and use SCCM for the rest.

It is possible to execute code in another user context from SYSTEM context, but when making changes in AD the DirectoryEntry operation kept erroring with “An operations error occurred”. After researching, I realized it is due to AD not accepting the authentication token as it’s being passed a second time and not directly. I tried creating a separate powershell process, a background job, a runspace with specific credentials – nothing would play ball. Anyway, I found a way to get around that by using the AccountManagement .Net class, which allows you to create a context using specific credentials.

In this example, I’m setting the computer description based on the model and serial number of the device. You need to provide the username and password for the account you will perform the AD operation with. I’ve put the password in clear text in this example, but in the real world we store the credentials in an Azure Keyvault and load them in dynamically at runtime with some POSH code to avoid storing them in the script. I hope in the future we will be able to run Powershell scripts with Intune in a specific user context, as you can with steps in an SCCM task sequence.

# Set credentials
$ADAccount = "mydomain\myADaccount"
$ADPassword = "Pa$$w0rd"

# Set initial description
$Model = Get-WMIObject -Class Win32_ComputerSystem -Property Model -ErrorAction Stop| Select -ExpandProperty Model
$SerialNumber = Get-WMIObject -Class Win32_BIOS -Property SerialNumber -ErrorAction Stop | Select -ExpandProperty SerialNumber
$Description = "$Model - $SerialNumber"

# Set some type accelerators
Add-Type -AssemblyName System.DirectoryServices.AccountManagement -ErrorAction Stop
$Accelerators = [PowerShell].Assembly.GetType("System.Management.Automation.TypeAccelerators")
$Accelerators::Add("PrincipalContext",[System.DirectoryServices.AccountManagement.PrincipalContext])
$Accelerators::Add("ContextType",[System.DirectoryServices.AccountManagement.ContextType])
$Accelerators::Add("Principal",[System.DirectoryServices.AccountManagement.ComputerPrincipal])
$Accelerators::Add("IdentityType",[System.DirectoryServices.AccountManagement.IdentityType])

# Connect to AD and set the computer description
$Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$PrincipalContext = [PrincipalContext]::new([ContextType]::Domain,$Domain,$ADAccount,$ADPassword)
$Account = [Principal]::FindByIdentity($PrincipalContext,[IdentityType]::Name,$env:COMPUTERNAME)
$LDAPObject = $Account.GetUnderlyingObject()
If ($LDAPObject.Properties["description"][0])
{
    $LDAPObject.Properties["description"][0] = $Description
}
Else
{
    [void]$LDAPObject.Properties["description"].Add($Description)
}
$LDAPObject.CommitChanges()
$Account.Dispose()

Querying for Devices in Azure AD and Intune with PowerShell and Microsoft Graph

Recently I needed to get a list of devices in both Azure Active Directory and Intune and I found that using the online portals I could not filter devices by the parameters that I needed. So I turned to Microsoft Graph to get the data instead. You can use the Microsoft Graph Explorer to query via the Graph REST API, however, the query capabilities of the API are still somewhat limited. To find the data I needed, I had to query the Graph REST API using PowerShell, where I can take advantage of the greater filtering capabilities of PowerShell’s Where-Object.

To use the Graph API, you need to authenticate first. A cool guy named Dave Falkus has published a number of PowerShell scripts on GitHub that use the Graph API with Intune, and these contain some code to authenticate with the API. Rather than re-invent the wheel, we can use his functions to get the authentication token that we need.

First, we need the AzureRM or Azure AD module installed as we use the authentication libraries that are included with it.

Next, open one of the scripts that Dave has published on GitHub, for example here, and copy the function Get-AuthToken into your script.

The also copy the Authentication code region into your script, ie the section between the following:


#region Authentication
...
#endregion

If you run this code it’ll ask you for an account name to authenticate with from your Azure AD. Once authenticated, we have a token we can use with the Graph REST API saved as a globally-scoped variable $authToken.

Get Devices from Azure AD

To get devices from Azure AD, we can use the following function, which I take no credit for as I have simply modified a function written by Dave.


Function Get-AzureADDevices(){

[cmdletbinding()]

$graphApiVersion = "v1.0"
$Resource = "devices"
$QueryParams = ""

    try {

        $uri = "https://graph.microsoft.com/$graphApiVersion/$($Resource)$QueryParams"
        Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get
    }

    catch {

    $ex = $_.Exception
    $errorResponse = $ex.Response.GetResponseStream()
    $reader = New-Object System.IO.StreamReader($errorResponse)
    $reader.BaseStream.Position = 0
    $reader.DiscardBufferedData()
    $responseBody = $reader.ReadToEnd();
    Write-Host "Response content:`n$responseBody" -f Red
    Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
    write-host
    break

    }

}

In the $graphAPIVersion parameter, you can use the current version of the API.

Now we can run the following code, which will use the API to return all devices in your Azure AD and save them to them a hash table which organizes the results by operating system version.


# Return the data
$ADDeviceResponse = Get-AzureADDevices
$ADDevices = $ADDeviceResponse.Value
$NextLink = $ADDeviceResponse.'@odata.nextLink'
# Need to loop the requests because only 100 results are returned each time
While ($NextLink -ne $null)
{
    $ADDeviceResponse = Invoke-RestMethod -Uri $NextLink -Headers $authToken -Method Get
    $NextLink = $ADDeviceResponse.'@odata.nextLink'
    $ADDevices += $ADDeviceResponse.Value
}

Write-Host "Found $($ADDevices.Count) devices in Azure AD" -ForegroundColor Yellow
$ADDevices.operatingSystem | group -NoElement

$DeviceTypes = $ADDevices.operatingSystem | group -NoElement | Select -ExpandProperty Name
$AzureADDevices = @{}
Foreach ($DeviceType in $DeviceTypes)
{
    $AzureADDevices.$DeviceType = $ADDevices | where {$_.operatingSystem -eq "$DeviceType"} | Sort displayName
}

Write-host "Devices have been saved to a variable. Enter '`$AzureADDevices' to view."

It will tell you how many devices it found, and how many devices there are by operating system version / device type.

2018-10-22 16_06_14-Windows PowerShell ISE

We can now use the $AzureADDevices hash table to query the data as we wish.

For example, here I search for an iPhone that belongs to a particular user:


$AzureADDevices.Iphone | where {$_.displayName -match 'nik'}

Here I am looking for the count of Windows devices that are hybrid Azure AD joined, and display the detail in the GridView.


($AzureADDevices.Windows | where {$_.trustType -eq 'ServerAd'}).Count
$AzureADDevices.Windows | where {$_.trustType -eq 'ServerAd'} | Out-GridView

And here I’m looking for all MacOS devices that are not compliant with policy.


($AzureADDevices.MacOS | where {$_.isCompliant -ne "True"}) | Out-GridView

Get Devices from Intune

To get devices from Intune, we can take a similar approach. Again no credit for this function as its modified from Dave’s code.


Function Get-IntuneDevices(){

[cmdletbinding()]

# Defining Variables
$graphApiVersion = "v1.0"
$Resource = "deviceManagement/managedDevices"

try {

    $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource"
    (Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get).Value

}

    catch {

    $ex = $_.Exception
    $errorResponse = $ex.Response.GetResponseStream()
    $reader = New-Object System.IO.StreamReader($errorResponse)
    $reader.BaseStream.Position = 0
    $reader.DiscardBufferedData()
    $responseBody = $reader.ReadToEnd();
    Write-Host "Response content:`n$responseBody" -f Red
    Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)"
    write-host
    break

    }

}

Running the following code will return all devices in Intune and save them to a hash table again organised by operating system.


$MDMDevices = Get-IntuneDevices

Write-Host "Found $($MDMDevices.Count) devices in Intune" -ForegroundColor Yellow
$MDMDevices.operatingSystem | group -NoElement

$IntuneDeviceTypes = $MDMDevices.operatingSystem | group -NoElement | Select -ExpandProperty Name
$IntuneDevices = @{}
Foreach ($IntuneDeviceType in $IntuneDeviceTypes)
{
    $IntuneDevices.$IntuneDeviceType = $MDMDevices | where {$_.operatingSystem -eq "$IntuneDeviceType"} | Sort displayName
}

Write-host "Devices have been saved to a variable. Enter '`$IntuneDevices' to view."

Now we can query data using the $IntuneDevices variable.

Here I am querying for the count of compliant and non-compliant iOS devices.


$IntuneDevices.iOS | group complianceState -NoElement

Here I am querying for all non-compliant iOS devices, specifying the columns I want to see, sort the results and outputting into table format.


$IntuneDevices.iOS |
    where {$_.complianceState -eq "noncompliant"} |
    Select userDisplayName,deviceName,imei,managementState,complianceGracePeriodExpirationDateTime |
    Sort userDisplayName |
    ft

All Windows devices sorted by username:


$IntuneDevices.Windows | Select userDisplayName,deviceName | Sort userDisplayName

Windows devices managed by SCCM:


$IntuneDevices.Windows | where {$_.managementAgent -eq "ConfigurationManagerClientMdm"} | Out-GridView

Windows devices enrolled using Windows auto enrollment:


$IntuneDevices.Windows | where {$_.deviceEnrollmentType -eq "windowsAutoEnrollment"} | Out-GridView

Windows devices enrolled by SCCM co-management:


$IntuneDevices.Windows | where {$_.deviceEnrollmentType -eq "windowsCoManagement"} | Out-GridView

You can, of course, expand this into users and other resource types, not just devices. You just need the right URL construct for the data type you want to query.