Introduction

It is best practice to register applications in Entra ID using PowerShell (or another automation tool that utilizes the Microsoft Graph API), rather than adding them manually through the Microsoft Entra Admin Center. This approach offers several advantages:

  1. Repeatable Deployment Process: Automating the registration helps prevent human errors that could lead to misconfigurations or security issues.
  2. Fast Cross-Tenant Migration: Scripting allows for quick migration between development, testing, and production environments. (You do have at least one pre-production Entra ID tenant, right?)
  3. Access to Advanced Settings: Some advanced settings are only available through the Microsoft Graph API and not exposed in the Microsoft Entra Admin Center.
  4. Improved Customer Experience: Providing customers with reliable scripts can enhance their product experience and may also reduce support costs for software vendors.
  5. Documentation: PowerShell scripts can serve as definitive and up-to-date documentation for the correct configuration of applications.
  6. Infrastructure as Code: All the advantages associated with the broader Infrastructure as Code (IaC) practice apply as well.

In this article, you will learn how to automate the registration process for AzureHound, the data collector application for BloodHound Enterprise. With only minor modifications, this guide can be applied to automatically register almost any service or daemon application that is using the OAuth 2.0 client credentials grant flow in Entra ID.

Entra ID Enterprise Applications Screenshot

Required User Permissions

To register applications with Microsoft Graph permissions in Entra ID, non-trivial user permissions are required. As an alternative to the almighty Global Administrator role, the following role assignments should be sufficient:

The User Access Administrator role is additionally needed for delegating permissions in Azure.

App Registration

First, we need to install the required official Microsoft.Graph.* and Az.* PowerShell modules:

Install-Module -Scope AllUsers -Repository PSGallery -Force -Name @(
    Microsoft.Graph.Applications,
    Microsoft.Graph.Authentication,
    Microsoft.Graph.Identity.DirectoryManagement,
    Az.Resources,
    Az.Accounts
)

Next, we can connect to the Microsoft Graph API while specifying all necessary permissions for the app registration process:

Connect-MgGraph -NoWelcome -ContextScope Process -Scopes @(
   'User.Read',
   'Application.ReadWrite.All',
   'AppRoleAssignment.ReadWrite.All',
   'RoleManagement.ReadWrite.Directory'
)

We are now ready to register the BloodHound Enterprise Collector application in Entra ID:

[string] $appName = 'BloodHound Enterprise Collector'
[string] $appDescription = 'Azure Data Exporter for BloodHound Enterprise (AzureHound)'
[string] $homePage = 'https://specterops.io/bloodhound-enterprise'
[hashtable] $infoUrls = @{
    MarketingUrl      = 'https://specterops.io/bloodhound-enterprise'
    TermsOfServiceUrl = 'https://specterops.io/terms-of-service'
    PrivacyStatementUrl = 'https://specterops.io/privacy-policy'
    SupportUrl = 'https://support.bloodhoundenterprise.io/'
}
[hashtable] $webUrls = @{
    HomePageUrl = $homePage
}

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphApplication] $registeredApp =
   New-MgApplication -DisplayName $appName `
                     -Description $appDescription `
                     -Inf $infoUrls `
                     -Web $webUrls `
                     -SignInAudience 'AzureADMyOrg'

NOTE: In this article, we use strongly-typed PowerShell variables, which is a matter of personal preference.

It is time to configure the application logo:

BloodHound Enterprise Collector Branding and Properties Screenshot

The following script will first download the logo to the local computer before uploading it to Entra ID:

[string] $logoUrl = 'https://www.dsinternals.com/assets/images/bloodhound-enterprise-logo-square.png'
[string] $tempLogoPath = New-TemporaryFile
Invoke-WebRequest -Uri $logoUrl -OutFile $tempLogoPath -UseBasicParsing -ErrorAction Stop
try {
    Set-MgApplicationLogo -ApplicationId $registeredApp.Id -ContentType 'image/png' -InFile $tempLogoPath
}
finally {
    # Delete the local copy of the logo from temp
    Remove-Item -Path $tempLogoPath
}

NOTE: The image dimensions should be 215 x 215 pixels. Supported file types include .png, .jpg, and .bmp, and the file size must be less than 100 KB.

App Instance Property Lock

We should also lock sensitive application properties for modification:

BloodHound Enterprise Collector App Instance Property Lock Screenshot

This prevents the creation of secrets on the associated service principal objects, which is crucial for safeguarding multi-tenant applications; however, single-tenant applications, like AzureHound, can also benefit.

The corresponding PowerShell command is simple:

Update-MgApplication -ApplicationId $registeredApp.Id -ServicePrincipalLockConfiguration @{
    IsEnabled = $true
    AllProperties = $true
}

Service Principal

Once the application itself is registered, we can create the corresponding service principal object:

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphServicePrincipal] $servicePrincipal =
   New-MgServicePrincipal -DisplayName $appName `
                          -AppId $registeredApp.AppId `
                          -AccountEnabled `
                          -ServicePrincipalType Application `
                          -Notes $appDescription `
                          -Homepage $homePage `
                          -Tags 'WindowsAzureActiveDirectoryIntegratedApp','HideApp'

Note that we have applied the HideApp tag to ensure that the application does not clutter the My Apps dashboard unnecessarily. The outcome should appear in the Enterprise Applications section of the Microsoft Entra Admin Center:

BloodHound Enterprise Collector Service Principal Properties Screenshot

Application Permissions

The BloodHound Collector requires the following Entra ID read-only permissions:

Permission Type Identifier
Directory.Read.All Application 7ab1d382-f21e-4acd-a863-ba3e13f7da61
RoleManagement.Read.All Application c7fbd983-d9aa-4fa7-84b8-17382c103bc4

NOTE: When registering an application manually, the User.Read delegated permission is automatically assigned. However, the BloodHound Enterprise Collector app does not actually require this permission.

BloodHound Enterprise Collector Microsoft Graph API Permissions Screenshot

When configuring Microsoft Graph API permissions through the API itself, we need to use their identifiers instead of their human-readable names:

# Fetch the Microsoft Graph applicaton ID, which should be 00000003-0000-0000-c000-000000000000
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphServicePrincipal] $microsoftGraph =
    Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"

# Fetch the Directory.Read.All scope ID, which should be 7ab1d382-f21e-4acd-a863-ba3e13f7da61
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRole] $readDirectoryScope =
    $microsoftGraph.AppRoles | Where-Object Value -eq 'Directory.Read.All'

# Fetch the RoleManagement.Read.All scope ID, which should be c7fbd983-d9aa-4fa7-84b8-17382c103bc4
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRole] $readRolesScope =
    $microsoftGraph.AppRoles | Where-Object Value -eq 'RoleManagement.Read.All'

# Delegate the required API permissions
Update-MgApplication -ApplicationId $registeredApp.Id -RequiredResourceAccess @{
    ResourceAppId = $microsoftGraph.AppId # 00000003-0000-0000-c000-000000000000
    ResourceAccess = @(@{
        id = $readDirectoryScope.Id       # 7ab1d382-f21e-4acd-a863-ba3e13f7da61
        type = 'Role'
    },@{
        id = $readRolesScope.Id           # c7fbd983-d9aa-4fa7-84b8-17382c103bc4
        type = 'Role'
    })
}

The permissions then need to be approved through administrative consent:

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRoleAssignment] $readRolesAdminConsent =
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id `
                                            -PrincipalId $servicePrincipal.Id `
                                            -ResourceId $microsoftGraph.Id `
                                            -AppRoleId $readRolesScope.Id

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRoleAssignment] $readDirectoryAdminConsent =
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id `
                                            -PrincipalId $servicePrincipal.Id `
                                            -ResourceId $microsoftGraph.Id `
                                            -AppRoleId $readDirectoryScope.Id

Directory Role

In addition to the Graph API permissions listed above, the application must be assigned the Directory Readers role as well:

BloodHound Enterprise Collector Role Assignment Screenshot

When assigning directory roles using the Microsoft Graph API, security principal OData identifiers must be used rather than their names:

# Fetch the template ID of the Directory Readers role, which should be 88d8e3e3-8f55-4a1e-953a-9b9898b8876b
[Microsoft.Graph.PowerShell.Models.MicrosoftGraphDirectoryRole] $directoryReadersRole =
    Get-MgDirectoryRole -Filter "displayName eq 'Directory Readers'"

# Get the environment-specific Microsoft Graph API endpoint
# Azure Global: https://graph.microsoft.com
# Azure USGov:  https://graph.microsoft.us 
[string] $graphEndpoint =
    (Get-MgEnvironment -Name (Get-MgContext).Environment).GraphEndpoint

# OData IDs need to be used when assigning role membership,
# e.g., https://graph.microsoft.com/v1.0/serviceprincipals/{46615ae4-da39-4403-8de2-606e10774ae0}
[string] $servicePrincipalOdataId = "$graphEndpoint/v1.0/serviceprincipals/{$($servicePrincipal.Id)}"

# Assign the Directory Readers Entra ID Role to the service principal
New-MgDirectoryRoleMemberByRef -DirectoryRoleId $directoryReadersRole.Id -OdataId $servicePrincipalOdataId

App Ownership

We can optionally configure the current user as the application owner:

# Fetch the info about the current user
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphDirectoryObject] $currentUser =
    Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me'

# OData IDs need to be used when assigning application ownership,
# e.g., https://graph.microsoft.com/v1.0/users/{bca3617a-4c54-45eb-9a32-744c1938242e}
[string] $currentUserOdataId = "$graphEndpoint/v1.0/users/{$($currentUser.Id)}"

# Assign the current user as the application object owner
New-MgApplicationOwnerByRef -ApplicationId $registeredApp.Id -OdataId $currentUserOdataId

# Assign the current user as the service principal owner
New-MgServicePrincipalOwnerByRef -ServicePrincipalId $servicePrincipal.Id -OdataId $currentUserOdataId

Assigning owners is a straightforward way to provide the ability to manage all aspects of the application:

BloodHound Enterprise Collector Service Principal Owner Screenshot

However, it is important to note that application object ownership can be exploited by malicious actors in privilege escalation attacks. Therefore, this permission should be handled with caution. (And you can use BloodHound to discover possible attack paths.)

Azure Permissions

If an organization is using Microsoft Azure, BloodHound Enterprise should be assigned the Reader role on all Azure Subscriptions. Ideally, this assignment should be done at the Tenant Root Group level:

BloodHound Enterprise Collector Azure Role Assignment Screenshot

Since a different set of PowerShell modules is used for Azure management, the corresponding PowerShell script needs to re-authenticate the user before assigning the required role membership:

# Optionally enable browser-based login on Windows 10 and later
Update-AzConfig -EnableLoginByWam $false

# Authenticate against Azure Resource Manager
Connect-AzAccount -Environment AzureCloud -Scope Process

# Fetch the identifier of the Reader role
[string] $rootScope = '/'
[Microsoft.Azure.Commands.Resources.Models.Authorization.PSRoleDefinition] $azureReaderRole =
    Get-AzRoleDefinition -Scope $rootScope -Name 'Reader'

# Fetch the identifier of the BloodHound Enterprise Collector Service Principal
[Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphServicePrincipal] $servicePrincipal = 
        Get-AzADServicePrincipal -DisplayName 'BloodHound Enterprise Collector'

# Fetch the Tenant Root Group
[guid] $currentTenantId = (Get-AzContext).Tenant.Id
[Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroup] $rootManagementGroup =
    Get-AzManagementGroup -GroupName $currentTenantId

# Assign the Reader role on all Azure subscriptions to AzureHound
[Microsoft.Azure.Commands.Resources.Models.Authorization.PSRoleAssignment] $readerRoleAssignment = 
    New-AzRoleAssignment -ObjectId $servicePrincipal.Id -Scope $rootManagementGroup.Id -RoleDefinitionId $azureReaderRole.Id

Authentication Certificate

As a final step, the authentication certificate must be created and associated with the application object. Although the certificate is automatically generated by the AzureHound server-side application, as it should be, it is typically uploaded manually through the Entra Admin Center:

Authentication Certificate Upload Screenshot

End-to-End Script

To wrap things up, here is the complete PowerShell script, compiled from the above code snippets:

<#
.SYNOPSIS
Registers the Azure Data Exporter for BloodHound Enterprise (AzureHound) in Entra ID.

.DESCRIPTION
This script registers the Azure Data Exporter for BloodHound Enterprise (AzureHound)
as an on-prem application in Microsoft Entra ID and Azure,
including all the necessary read permissions.

The required modules can be installed from the PowerShell Gallery using the following command:

Install-Module -Scope AllUsers -Repository PSGallery -Force -Name @(
    Microsoft.Graph.Applications,
    Microsoft.Graph.Authentication,
    Microsoft.Graph.Identity.DirectoryManagement,
    Az.Resources,
    Az.Accounts
)

More details at https://bloodhound.specterops.io/install-data-collector/install-azurehound/azure-configuration 

.NOTES
Version: 1.0
Author:  Michael Grafnetter
#>

#Requires -Version 5
#Requires -Modules Microsoft.Graph.Applications,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement,Az.Resources,Az.Accounts  

#region Entra ID

# Connect to Microsoft Entra ID through the Microsoft Graph API
# Note: The -TenantId parameter is also required when using an External ID.
Connect-MgGraph -NoWelcome -ContextScope Process -Scopes @(
   'User.Read',
   'Application.ReadWrite.All',
   'AppRoleAssignment.ReadWrite.All',
   'RoleManagement.ReadWrite.Directory'
)

# Register the AzureHound application
[string] $appName = 'BloodHound Enterprise Collector'
[string] $appDescription = 'Azure Data Exporter for BloodHound Enterprise (AzureHound)'
[string] $homePage = 'https://specterops.io/bloodhound-enterprise'
[hashtable] $infoUrls = @{
    MarketingUrl      = 'https://specterops.io/bloodhound-enterprise'
    TermsOfServiceUrl = 'https://specterops.io/terms-of-service'
    PrivacyStatementUrl = 'https://specterops.io/privacy-policy'
    SupportUrl = 'https://support.bloodhoundenterprise.io/'
}
[hashtable] $webUrls = @{
    HomePageUrl = $homePage
}

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphApplication] $registeredApp =
   New-MgApplication -DisplayName $appName `
                     -Description $appDescription `
                     -Inf $infoUrls `
                     -Web $webUrls `
                     -SignInAudience 'AzureADMyOrg'

# Configure the application logo
[string] $logoUrl = 'https://www.dsinternals.com/assets/images/bloodhound-enterprise-logo-square.png'
[string] $tempLogoPath = New-TemporaryFile
Invoke-WebRequest -Uri $logoUrl -OutFile $tempLogoPath -UseBasicParsing -ErrorAction Stop
try {
    Set-MgApplicationLogo -ApplicationId $registeredApp.Id -ContentType 'image/png' -InFile $tempLogoPath
}
finally {
    # Delete the local copy of the logo from temp
    Remove-Item -Path $tempLogoPath
}

# Make sure the app instance property lock is enabled
Update-MgApplication -ApplicationId $registeredApp.Id -ServicePrincipalLockConfiguration @{
    IsEnabled = $true
    AllProperties = $true
}

# Create the associated service principal object
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphServicePrincipal] $servicePrincipal =
   New-MgServicePrincipal -DisplayName $appName `
                          -AppId $registeredApp.AppId `
                          -AccountEnabled `
                          -ServicePrincipalType Application `
                          -Notes $appDescription `
                          -Homepage $homePage `
                          -Tags 'WindowsAzureActiveDirectoryIntegratedApp','HideApp'

# Fetch the Microsoft Graph applicaton ID, which should be 00000003-0000-0000-c000-000000000000
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphServicePrincipal] $microsoftGraph =
    Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"

# Fetch the Directory.Read.All scope ID, which should be 7ab1d382-f21e-4acd-a863-ba3e13f7da61
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRole] $readDirectoryScope =
    $microsoftGraph.AppRoles | Where-Object Value -eq 'Directory.Read.All'

# Fetch the RoleManagement.Read.All scope ID, which should be c7fbd983-d9aa-4fa7-84b8-17382c103bc4
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRole] $readRolesScope =
    $microsoftGraph.AppRoles | Where-Object Value -eq 'RoleManagement.Read.All'

# Delegate the required API permissions
Update-MgApplication -ApplicationId $registeredApp.Id -RequiredResourceAccess @{
    ResourceAppId = $microsoftGraph.AppId # 00000003-0000-0000-c000-000000000000
    ResourceAccess = @(@{
        id = $readDirectoryScope.Id       # 7ab1d382-f21e-4acd-a863-ba3e13f7da61
        type = 'Role'
    },@{
        id = $readRolesScope.Id           # c7fbd983-d9aa-4fa7-84b8-17382c103bc4
        type = 'Role'
    })
}

# Approve the permissions on the tenant
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRoleAssignment] $readRolesAdminConsent =
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id `
                                            -PrincipalId $servicePrincipal.Id `
                                            -ResourceId $microsoftGraph.Id `
                                            -AppRoleId $readRolesScope.Id

[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphAppRoleAssignment] $readDirectoryAdminConsent =
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id `
                                            -PrincipalId $servicePrincipal.Id `
                                            -ResourceId $microsoftGraph.Id `
                                            -AppRoleId $readDirectoryScope.Id

# Fetch the template ID of the Directory Readers role, which should be 88d8e3e3-8f55-4a1e-953a-9b9898b8876b
[Microsoft.Graph.PowerShell.Models.MicrosoftGraphDirectoryRole] $directoryReadersRole =
    Get-MgDirectoryRole -Filter "displayName eq 'Directory Readers'"

# Get the environment-specific Microsoft Graph API endpoint
# Azure Global: https://graph.microsoft.com
# Azure USGov:  https://graph.microsoft.us 
[string] $graphEndpoint =
    (Get-MgEnvironment -Name (Get-MgContext).Environment).GraphEndpoint

# OData IDs need to be used when assigning role membership,
# e.g., https://graph.microsoft.com/v1.0/serviceprincipals/{46615ae4-da39-4403-8de2-606e10774ae0}
[string] $servicePrincipalOdataId = "$graphEndpoint/v1.0/serviceprincipals/{$($servicePrincipal.Id)}"

# Assign the Directory Readers Entra ID Role to the service principal
New-MgDirectoryRoleMemberByRef -DirectoryRoleId $directoryReadersRole.Id -OdataId $servicePrincipalOdataId

# Fetch the info about the current user
[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphDirectoryObject] $currentUser =
    Invoke-MgGraphRequest -Method GET -Uri '/v1.0/me'

# OData IDs need to be used when assigning application ownership,
# e.g., https://graph.microsoft.com/v1.0/users/{bca3617a-4c54-45eb-9a32-744c1938242e}
[string] $currentUserOdataId = "$graphEndpoint/v1.0/users/{$($currentUser.Id)}"

# Assign the current user as the application object owner
New-MgApplicationOwnerByRef -ApplicationId $registeredApp.Id -OdataId $currentUserOdataId

# Assign the current user as the service principal owner
New-MgServicePrincipalOwnerByRef -ServicePrincipalId $servicePrincipal.Id -OdataId $currentUserOdataId

# Sign out from Microsoft Graph
Disconnect-MgGraph

#endregion Entra ID

#region Azure (Optional)

# Optionally enable browser-based login on Windows 10 and later
Update-AzConfig -EnableLoginByWam $false

# Authenticate against Azure Resource Manager
Connect-AzAccount -Environment AzureCloud -Scope Process

# Fetch the identifier of the Reader role
[string] $rootScope = '/'
[Microsoft.Azure.Commands.Resources.Models.Authorization.PSRoleDefinition] $azureReaderRole =
    Get-AzRoleDefinition -Scope $rootScope -Name 'Reader'

if (-not (Test-Path -Path 'variable:servicePrincipal')) {
    # Fetch the service principal if the Azure part of the script is executed independently
    [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphServicePrincipal] $servicePrincipal = 
        Get-AzADServicePrincipal -DisplayName 'BloodHound Enterprise Collector'
}

# Fetch the Tenant Root Group
[guid] $currentTenantId = (Get-AzContext).Tenant.Id
[Microsoft.Azure.Commands.Resources.Models.ManagementGroups.PSManagementGroup] $rootManagementGroup =
    Get-AzManagementGroup -GroupName $currentTenantId

# Assign the Reader role on all Azure subscriptions to AzureHound
[Microsoft.Azure.Commands.Resources.Models.Authorization.PSRoleAssignment] $readerRoleAssignment = 
    New-AzRoleAssignment -ObjectId $servicePrincipal.Id -Scope $rootManagementGroup.Id -RoleDefinitionId $azureReaderRole.Id

# Sign out from Azure Resource Manager
Disconnect-AzAccount -Scope Process

#endregion Azure (Optional)