a comical and silly image showing the process of adding intune assignments with the azure logo, a powershell script, and exaggerated icons representin

Introduction

Managing app assignments in Microsoft Intune can be a time-consuming task, especially when dealing with multiple applications and groups. This PowerShell script aims to simplify and automate the process of adding new inclusion or exclusion assignments to Intune apps without removing existing assignments. Whether you need to include or exclude a group from app deployments, this script provides a streamlined solution.

Features

  • Automated Authentication: Authenticates with the Microsoft Graph API to manage Intune configurations.
  • Group and App Selection: Allows users to select groups and apps via interactive GridView dialogs.
  • Flexible Assignment Options: Supports both inclusion and exclusion assignments.
  • Optional Assignment Filters: Enables selection of assignment filters for more granular control.
  • Logging: Logs actions and outcomes to a specified log file.
  • Error Handling: Captures and logs errors, ensuring that the script can handle issues gracefully.

Prerequisites

Before running the script, ensure that you have the following:

  • Installed Microsoft.Graph and AzureAD PowerShell modules.
  • Appropriate permissions to access and manage Intune via the Graph API.
  • Credentials with the necessary Intune roles and permissions.

Script Overview

Here’s a breakdown of the script’s key components and how it works:

Setting Variables

The script begins by defining variables for client ID, tenant ID, and an optional prefix for filtering groups. It also sets up logging and initializes counters to track the number of assignments processed, succeeded, and failed.

PowerShell
param (
    [string]$Prefix = "XYZ-App",
    [string]$ClientId = "your-client-id",
    [string]$TenantId = "your-tenant-id"
)

# Define the log file path
$LogFilePath = "C:\Temp\IntuneAssignmentScript.log"

# Initialize counters
$processedCount = 0
$successCount = 0
$failureCount = 0

Logging Function

A Log-Message function is used to log messages to both the console and a log file. This function helps in tracking the script’s execution and handling errors.

PowerShell
function Log-Message {
    param (
        [string]$Message,
        [string]$Level = "INFO"
    )
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logEntry = "$timestamp [$Level] $Message"
    Add-Content -Path $LogFilePath -Value $logEntry
    if ($Level -eq "ERROR") {
        Write-Error $Message
    } else {
        Write-Host $Message
    }
}

Authentication

The Get-AuthToken function authenticates with the Graph API using the provided tenant and user credentials. It handles token expiration and re-authentication if necessary.

PowerShell
function Get-AuthToken {
    param (
        [Parameter(Mandatory = $true)]
        [string]$User
    )
    
    # Authentication logic...
    
    # Check if the token has expired
    $DateTime = (Get-Date).ToUniversalTime()
    $TokenExpires = [datetime]$authToken.ExpiresOn.UtcDateTime - $DateTime

    if ($TokenExpires.TotalMinutes -le 0) {
        Log-Message "Authentication Token expired $($TokenExpires.TotalMinutes) minutes ago" "ERROR"
        $global:MyCreds = Get-Credential -Message 'Enter specify UPN for Azure authentication:'
        $global:authToken = Get-AuthToken -User $global:MyCreds.UserName
    }
}

Group and App Selection

The script retrieves groups and apps from Intune, displaying them in interactive GridView dialogs for selection. This makes it easy to select the appropriate entities for assignment.

PowerShell
# Get the group
$selectedGroup = Select-Group -Prefix $Prefix
if ($selectedGroup) {
    $GroupId = $selectedGroup.Id
    $GroupName = $selectedGroup.DisplayName
    Log-Message "Selected Group ID: $GroupId"
    Log-Message "Selected Group Name: $GroupName"
} else {
    Log-Message "No group selected." "ERROR"
    Return
}

# Get the apps
Log-Message "Getting Apps from Intune. Be patient, this can take a while."
$apps = Get-MgBetaDeviceAppMgtMobileApp -ExpandProperty Assignments | select DisplayName, Id | Sort-Object 
$selectedApps = $apps | Out-GridView -PassThru -Title "Select App(s) You Want to Assign:" 

Assignment Creation

Depending on the selected deployment type (include or exclude), the script creates the appropriate assignment request body. It then sends the request to the Graph API and logs the result.

PowerShell
# Create the request body depending upon whether it will be an include or exclude assignment.
if ($selectedChoice -eq 'Exclude') {
    # Define the request body template for exclude assignment
    Log-Message "Defining body template for Graph calls to exclude group."
    $bodyTemplate = @{
        target        = @{
            groupId       = $GroupId
            "@odata.type" = "microsoft.graph.exclusionGroupAssignmentTarget"
        }
        intent        = "Required"
        "@odata.type" = "#microsoft.graph.mobileAppAssignment"
    }
}

if ($selectedChoice -eq 'Include') {
    # Define the request body template for include assignment
    Log-Message "Defining body template for Graph calls to include group."
    $bodyTemplate = @{
        target = @{
            groupId = $GroupId
            "@odata.type" = "#microsoft.graph.groupAssignmentTarget"
            deviceAndAppManagementAssignmentFilterId = $FilterId
            deviceAndAppManagementAssignmentFilterType = $FilterType
        }
        intent = "Required"
        settings = @{
            "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings"
            notifications = "showReboot"
            installTimeSettings = $null
            restartSettings = @{
                "@odata.type" = "#microsoft.graph.win32LobAppRestartSettings"
                gracePeriodInMinutes = "720"
                countdownDisplayBeforeRestartInMinutes = "90"
                restartNotificationSnoozeDurationInMinutes = "15"
            }
            deliveryOptimizationPriority = "notConfigured"
        }
    }
}

# Send the POST request
foreach ($app in $selectedApps) {
    $Id = $app.Id
    $AppName = $app.DisplayName

    # Define the API URL with the variable for the assignment ID
    $apiUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$Id/assignments"
    
    Log-Message "API URL: $apiUrl"

    try {
        $response = Invoke-WebRequest -Uri $apiUrl -Headers $headers -Method Post -Body $jsonBody -ContentType "application/json"
        $responseContent = $response.Content | ConvertFrom-Json
        Log-Message "Response Content: $($responseContent | ConvertTo-Json -Depth 3)"
        
        # Success message
        if ($selectedChoice -eq 'Include') {
            if ($FilterId) {
            # Line break added for readability on the blog. Feel free to remove it. 
                Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an included group using the  ` 
                filter $FilterName at $DateTime."
            } else {
                Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an included group at $DateTime."
            }
        } else {
            Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an excluded group at $DateTime."
        }

        $processedCount++
        $successCount++
    }
    catch {
        Log-Message "Error: $_" "ERROR"
        if ($_.Exception.Response) {
            $respStream = $_.Exception.Response.GetResponseStream()
            $reader = New-Object System.IO.StreamReader($respStream)
            $responseBody = $reader.ReadToEnd() | ConvertFrom-Json
            Log-Message "Response Body: $($responseBody | ConvertTo-Json -Depth 3)" "ERROR"

            if ($responseBody.error.message -match "The MobileApp Assignment already exists") {
                Log-Message "The MobileApp Assignment already exists for AppId: $Id and AssignmentId: $GroupId" "ERROR"
            } else {
                Log-Message "An error occurred for AppId: $($Id): $($responseBody.error.message)" "ERROR"
            }
        }

        $failureCount++
    }
}

# Write a summary message after the loop completes
$summaryMessage = "$processedCount assignments have been processed: $successCount succeeded, $failureCount failed."
Write-Host $summaryMessage
Log-Message $summaryMessage

Conclusion

This PowerShell script provides a robust and automated way to manage Intune app assignments. By leveraging the Microsoft Graph API and interactive GridView dialogs, it simplifies the process of including or excluding groups from app deployments. The built-in logging and error handling ensure that administrators can easily track the script’s actions and address any issues that arise.

Full Script

For your reference, here’s the complete script:

PowerShell
<#
.SYNOPSIS
Adds a new inclusion or exclusion assignment to a list of Intune Apps without removing the existing assignments.

.DESCRIPTION
This script adds a new inclusion or exclusion assignment to a list of Intune Apps in Microsoft Intune without removing existing assignments. 
It allows you to authenticate with Azure AD, select apps from Intune, and select a group to assign or exclude from the app deployment.
Some of the include assignment properties are hardcoded because I was tired of working on this. Feel free to improve upon my work. 

.PARAMETER GroupPrefix
Optional parameter to filter groups by their display name prefix.

.PARAMETER AppPrefix
Optional parameter to filter Apps by their display name prefix.

.PARAMETER ClientId
The client ID used for Graph authentication.

.PARAMETER TenantId
The Azure AD tenant ID.

.EXAMPLE
.\Add-IntuneAssignment.ps1 -Prefix "642-App" -ClientId "your-client-id" -TenantId "your-tenant-id"

.EXAMPLE
.\Add-IntuneAssignment.ps1
Without specifying parameters (uses default values).

.NOTES
The script requires the Microsoft.Graph and AzureAD modules.

Script Name: Add-IntuneAssignment.ps1
Author: John Marcum (PJM)
Date: 6/19/2024
Version: 2.0

6/26/2024: Removed the reliance on Azure AD module. - PJM

# LEGAL DISCLAIMER
# This script is provided "as is" without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, or non-infringement. The entire risk as to the quality and performance of the script is with you.
# In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the script or the use or other dealings in the script.
# You should never run any script from the Internet without understanding its contents and effects. It is highly recommended that you thoroughly test the script in a safe environment before running it in production.
#>

param (
    [string]$GroupPrefix = "123-",
    [string]$AppPrefix = "TEST-",
    [string]$ClientId = "",
    [string]$TenantId = ""
)

#### Begin Setting Variables ####

# Define the ID of the app used for Graph authentication (If required)
$clientId = $ClientId

# Define the Azure AD tenant ID (If required)
$TenantId = $TenantId

# Optionally define a prefix to filter the group names from the selection dialog box.
$GroupPrefix = $GroupPrefix

# Define the log file path
$LogFile = "c:\temp\IntuneAssignmentScript-$(Get-Date -UFormat "%m-%d-%Y_%H-%m").log"

# Initialize counters to track the number of assignments processed, succeeded, and failed
$processedCount = 0
$successCount = 0
$failureCount = 0

#### End Setting Variables ####

######## Begin Functions ########

####################################################
# Function to log messages to a log file and to the screen
function Log-Message {
    param (
        [string]$Message,
        [string]$Level = "INFO"
    )
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logEntry = "$timestamp [$Level] $Message"
    Add-Content -Path $LogFile -Value $logEntry

    switch ($Level) {
        "ERROR" { Write-Error $Message }
        default { Write-Host $Message }
    }
}

####################################################
# Function to get the group from Graph.
function Select-Group {
    <#
    .SYNOPSIS
    Retrieves groups from Graph and allows selection via GridView.

    .DESCRIPTION
    This function retrieves groups from Azure AD based on an optional prefix and displays them in a GridView for selection.

    .PARAMETER GroupPrefix
    The prefix to filter groups by their display name.
    #>
    
    param (
        [string]$GroupPrefix
    )
    Log-Message $GroupPrefix

    if ($GroupPrefix) {
        Log-Message "Getting groups starting with $($GroupPrefix)"
        $Groups = Get-MgGroup -Filter "startswith(DisplayName, '$GroupPrefix')" -Property DisplayName, Id
    }
    else {
        Log-Message "Getting all groups"
        $Groups = Get-MgGroup -Property DisplayName, Id
    }

    if ($Groups.Count -eq 0) {
        Log-Message "No groups found with the given prefix." "ERROR"
        return $null
    }

    # Customizing the output to display only DisplayName
    $TargetGroup = $Groups.DisplayName | Out-GridView -Title "Select a Single Group:" -OutputMode Single

    # Retrieve the Id based on the selected DisplayName
    $GroupId = $Groups | Where-Object { $_.DisplayName -eq $TargetGroup } | Select-Object -ExpandProperty Id

    if ($TargetGroup -and $GroupId) {
        Log-Message "Target Group Name: $($TargetGroup)"
        Log-Message "Target Group ID: $($GroupId)"
        return [PSCustomObject]@{
            DisplayName = $TargetGroup
            Id          = $GroupId
        }
    }
    else {
        Log-Message "No group selected. Please select a group to proceed." "ERROR"
        return $null
    }
}

####################################################
# Function to ensure modules are installed and updated
function Assert-ModuleExists {
    <#
    .SYNOPSIS
    Ensures the specified module is installed and up to date.

    .DESCRIPTION
    This function checks if a specified module is installed and up to date. If not, it installs or updates the module.

    .PARAMETER ModuleName
    The name of the module to check, install, or update.
    #>
    
    param (
        [string]$ModuleName
    )

    $installedModule = Get-Module -Name $ModuleName -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1
    $latestModule = Find-Module -Name $ModuleName | Sort-Object Version -Descending | Select-Object -First 1

    if ($installedModule) {
        if ($latestModule) {
            if ($installedModule.Version -lt $latestModule.Version) {
                Log-Message "Updating module $ModuleName ..."
                Update-Module -Name $ModuleName -Force
                Log-Message "Module updated to version $($latestModule.Version)"
            }
            else {
                Log-Message "Module $ModuleName is already up to date."
            }
        }
        else {
            Log-Message "Module $ModuleName is not found in the repository." "ERROR"
        }
    }
    else {
        Log-Message "Installing module $ModuleName ..."
        Install-Module -Name $ModuleName -Force
        Log-Message "Module installed"
    }
}

####################################################
# Function to select an option from a list
function Select-Option {
    <#
    .SYNOPSIS
    Displays a list of choices in a GridView for user selection.

    .DESCRIPTION
    This function displays a list of choices in a GridView for user selection and returns the selected choice.

    .PARAMETER Choices
    The list of choices to display.

    .PARAMETER Title
    The title of the GridView window.
    #>
    
    param (
        [string[]]$Choices,
        [string]$Title
    )

    $selectedChoice = $Choices | Out-GridView -Title $Title -OutputMode Single -ErrorAction SilentlyContinue
    if ($selectedChoice) {
        return $selectedChoice
    }
    else {
        Log-Message "No option selected. Please select an option." "ERROR"
        return $null
    }
}
####################################################
######## End Functions ########

######## Script Entry Point ########

# Install required modules
Log-Message "Checking whether Microsoft.Graph.Beta.Groups module is installed"
Assert-ModuleExists -ModuleName 'Microsoft.Graph.Beta.Groups'

Log-Message "Checking whether Microsoft.Graph.Authentication module is installed"
Assert-ModuleExists -ModuleName 'Microsoft.Graph.Authentication'

Log-Message "Checking whether Microsoft.Graph.Devices.CorporateManagement is installed"
Assert-ModuleExists -ModuleName 'Microsoft.Graph.Devices.CorporateManagement'


# Connect to Graph
$context = Get-MgContext
if (!($context)) {
    Log-Message "Connect to Graph."
    Connect-MgGraph -ClientId $clientId -TenantId $TenantId -NoWelcome
}
Log-Message "Connected to Graph"
Log-Message "Scopes: $($context.Scopes)"

# Get the app(s) from Intune
Log-Message "Getting Apps from Intune. Be patient, this can take a while."
if ([string]::IsNullOrEmpty($AppPrefix)) {
    $Apps = Get-MgBetaDeviceAppMgtMobileApp -ExpandProperty Assignments | select DisplayName, Id | Sort-Object
}
else {
    $Apps = Get-MgBetaDeviceAppMgtMobileApp -Filter "startswith(DisplayName, '$($AppPrefix)')" -ExpandProperty Assignments | select DisplayName, Id | Sort-Object
}

if ($Apps.Count -eq 0) {
    Log-Message "No apps found with the given prefix." "ERROR"
}
else {
    Log-Message "Apps retrieved successfully."
}

$selectedApps = $apps | Out-GridView -PassThru -Title "Select App(s) You Want to Assign:" 
$appIds = $selectedApps | select -ExpandProperty Id

if (!($appIds)) {
    Log-Message "No apps selected!" "ERROR"
    Return
}

# Get groups and display them in GridView for user selection
Log-Message "Getting groups from Graph"
# Get the group
$selectedGroup = Select-Group -GroupPrefix $GroupPrefix
if ($selectedGroup) {
    $GroupId = $selectedGroup.Id
    $GroupName = $selectedGroup.DisplayName
    Log-Message "Selected Group ID: $GroupId"
    Log-Message "Selected Group Name: $GroupName"
}
else {
    Log-Message "No group selected." "ERROR"
    Return
}

# Determine the assignment type
$AssignmentChoices = "Include", "Exclude"
$selectedChoice = Select-Option -Choices $AssignmentChoices -Title "Select Deployment Type:"
if (-not $selectedChoice) {
    Log-Message "No option selected. Please select either 'Include' or 'Exclude'." "ERROR"
    Return
}

# Set the variable based on the selected choice
if ($selectedChoice -eq "Include") {
    $DeployType = "Included"
}
elseif ($selectedChoice -eq "Exclude") {
    $DeployType = "Excluded"
}

# Output the selected group and deployment type
Log-Message "The selected group: $GroupId will be $DeployType"

# Only prompt for filter if the deployment type is Include
$FilterId = $null
$FilterType = $null
$FilterName = $null

if ($selectedChoice -eq 'Include') {
    # Make the API request to get assignment filters
    $filtersResponse = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters" -Method Get

    # Check the content of the response
    if ($filtersResponse -and $filtersResponse.value) {
        $filters = $filtersResponse.value

        # Extract DisplayName and Id from the hashtables
        $extractedFilters = $filters | ForEach-Object {
            [PSCustomObject]@{
                DisplayName = $_.DisplayName
                Id          = $_.Id
            }
        }

        # Debugging: Output the extracted filters data
        $extractedFilters | ForEach-Object { Write-Host "DisplayName: $($_.DisplayName), Id: $($_.Id)" }

        # Select DisplayName and Id properties and pipe to Out-GridView
        $selectedFilter = $extractedFilters | Out-GridView -Title "Select an Assignment Filter (Optional):" -OutputMode Single

        # Debugging: Check if a filter was selected
        if ($selectedFilter) {
            Log-Message "Selected Filter DisplayName: $($selectedFilter.DisplayName)"
            Log-Message "Selected Filter Id: $($selectedFilter.Id)"
        }
        else {
            Log-Message "No filter type selected. Please select a filter type." "ERROR"
        }
    }
    else {
        Log-Message "No filters found or API request failed." "ERROR"
    }

    if ($selectedFilter) {
        $FilterId = $selectedFilter.Id
        $FilterName = $selectedFilter.DisplayName
        Log-Message "Selected Filter ID: $FilterId"
        Log-Message "Selected Filter Name: $FilterName"

        # Determine the filter type
        $FilterTypeChoices = "Include", "Exclude"
        $FilterType = Select-Option -Choices $FilterTypeChoices -Title "Select Filter Type:"
        if (-not $FilterType) {
            
            Return
        }
    }
}

# Create the request body depending upon whether it will be an include or exclude assignment.
if ($selectedChoice -eq 'Exclude') {
    # Define the request body template for exclude assignment
    Log-Message "Defining body template for Graph calls to exclude group."
    $bodyTemplate = @{
        target        = @{
            groupId       = $GroupId
            "@odata.type" = "microsoft.graph.exclusionGroupAssignmentTarget"
        }
        intent        = "Required"
        "@odata.type" = "#microsoft.graph.mobileAppAssignment"
    }
}

if ($selectedChoice -eq 'Include') {
    # Define the request body template for include assignment
    Log-Message "Defining body template for Graph calls to include group."
    $bodyTemplate = @{
        target   = @{
            groupId                                    = $GroupId
            "@odata.type"                              = "#microsoft.graph.groupAssignmentTarget"
            deviceAndAppManagementAssignmentFilterId   = $FilterId
            deviceAndAppManagementAssignmentFilterType = $FilterType
        }
        intent   = "Required"
        settings = @{
            "@odata.type"                = "#microsoft.graph.win32LobAppAssignmentSettings"
            notifications                = "showReboot"
            installTimeSettings          = $null
            restartSettings              = @{
                "@odata.type"                              = "#microsoft.graph.win32LobAppRestartSettings"
                gracePeriodInMinutes                       = "720"
                countdownDisplayBeforeRestartInMinutes     = "90"
                restartNotificationSnoozeDurationInMinutes = "15"
            }
            deliveryOptimizationPriority = "notConfigured"
        }
    }
}

# Convert the body template to JSON
$jsonBody = $bodyTemplate | ConvertTo-Json -Depth 3

# Debug: Print Body, and JSON Body
Log-Message "Body: $bodyTemplate"
Log-Message "JSON Body: $jsonBody"


# Loop through each app ID
foreach ($app in $selectedApps) {
    $Id = $app.Id
    $AppName = $app.DisplayName

    # Define the API URL with the variable for the assignment ID
    $apiUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$Id/assignments"
    
    # Debug: Print API URL
    Log-Message "API URL: $apiUrl"

# Send the POST request
try {
    $response = Invoke-MgGraphRequest -Uri $apiUrl -Body $jsonBody -Method POST -ContentType "application/json"
    # Output the response
    Log-Message "Response Content: $($response | ConvertTo-Json -Depth 3)"

    # Success message
    if ($selectedChoice -eq 'Include') {
        if ($FilterId) {
            Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an included group using the filter $FilterName at $DateTime."
        }
        else {
            Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an included group at $DateTime."
        }
    }
    else {
        Log-Message "The $GroupName group has been successfully assigned to the app $AppName as an excluded group at $DateTime."
    }

    # Increment the counter
    $processedCount++
    $successCount++
}
catch {
    # Capture and output the error details
    Log-Message "Error: $($_.Exception.Message)" "ERROR"
    
    if ($_.Exception.Response) {
        $responseBody = $null
        try {
            $responseStream = $_.Exception.Response.RawContentStream
            $reader = New-Object System.IO.StreamReader($responseStream)
            $responseBody = $reader.ReadToEnd() | ConvertFrom-Json
        }
        catch {
            Log-Message "Failed to parse error response content." "ERROR"
        }

        if ($responseBody) {
            $errorMessage = $responseBody.error.message

            Log-Message "Response Body: $($responseBody | ConvertTo-Json -Depth 3)" "ERROR"

            # Check if the error indicates that the assignment already exists
            if ($errorMessage -match "The MobileApp Assignment already exists") {
                Log-Message "The MobileApp Assignment already exists for AppName: $($AppName) and AssignmentId: $GroupId" "ERROR"
            }
            else {
                Log-Message "An error occurred for AppName: $($AppName): $errorMessage" "ERROR"
            }
        }
        else {
            Log-Message "Error: Unable to parse error response content." "ERROR"
        }
    }
    else {
        Log-Message "Error: No response received." "ERROR"
    }

    # Increment the failure counter
    $failureCount++
 }
}

# Write a message after the loop completes
$summaryMessage = "$processedCount assignments have been processed: $successCount succeeded, $failureCount failed."
Log-Message $summaryMessage