top of page

Mastering Microsoft Azure RBAC & Entra ID Roles: Automated Role Assignment Reporting Across Your Tenant

  • Writer: Sebastian F. Markdanner
    Sebastian F. Markdanner
  • Jan 6
  • 17 min read

As the season for audits approaches (though, let’s be honest, auditing should be an all-year-round endeavor), I’m excited to share a practical solution for managing role assignments across your tenant.

Surreal landscape with a giant eye, colorful swirls, planets, wind turbine, and icons labeled "Identities." Energetic, futuristic mood.

Managing role assignments can feel overwhelming, especially when multiple administrators are involved in assigning, monitoring, auditing, and managing roles. It’s rarely a one-person job, and the complexities only grow with the scale of your organization.


Combine that with increasing regulatory demands for auditing, reporting, and overall security—like the EU’s NIS-2 directive—and it’s clear why visibility and control over assigned privileges are more critical than ever.


To address these challenges, I’ve developed a solution in PowerShell that simplifies and streamlines this process. This solution can be automated and scheduled using Azure Automation Accounts or executed on-demand, whether via the Automation Account or locally, depending on your specific needs.


Let me suggest an optimized structure for your sections that incorporates your focus keyword phrase and improves readability while maintaining the logical flow.


Table of Contents


Understanding The Role Assignment Reporting Automation Solution

Let’s dive into the full script, which is also available on my GitHub:

The Complete PowerShell Solution

<#
.SYNOPSIS
Collects and exports Azure RBAC roles and Microsoft 365 Administrator roles to CSV and Excel files.

.DESCRIPTION
This script retrieves role assignments (active and eligible) from Azure subscriptions and Microsoft Entra using Azure RBAC and Microsoft Graph APIs. 
It processes the data, generates reports, and optionally emails the results. 
The script ensures that required modules are installed or updated, and it can be run locally or in an automated pipeline.

.PARAMETER TenantId
The Tenant ID (GUID) of the Entra ID tenant.

.PARAMETER ClientId
The Client ID of the service principal.

.PARAMETER Client_secret
The client secret associated with the registered application.

.PARAMETER SaveFiles
Indicates whether to save the output files locally. Default is `$true`.

.PARAMETER outDir
The directory where output files will be saved. Default is `C:\Temp`.

.PARAMETER localRun
Indicates whether the script is run locally. Default is `$true`.

.PARAMETER mailFrom
The email address from which the results will be sent. Optional.

.PARAMETER mailTo
The recipient's email address for sending the results. Mandatory if `mailFrom` is specified.

.PARAMETER mailSubject
The subject of the email. Default is "Admin Roles overview".

.PARAMETER excelFileName
The prefix for the name of the final excel file. Default is "Entra And RBAC Admin Roles".

.EXAMPLE
# Run the script locally and save output files:
.\CollectRoleAssignments.ps1 -TenantId "your-tenant-id" -ClientId "your-client-id" -Client_secret "your-client-secret" -localRun $true -SaveFiles $true

.EXAMPLE
# Run the script locally and email the results:
.\CollectRoleAssignments.ps1 -TenantId "your-tenant-id" -ClientId "your-client-id" -Client_secret "your-client-secret" -localRun $true -mailFrom "noreply@yourdomain.com" -mailTo "admin@yourdomain.com"

.EXAMPLE
# Run the script in a pipeline and save files to a temporary directory:
.\CollectRoleAssignments.ps1 -TenantId "your-tenant-id" -ClientId "your-client-id" -Client_secret "your-client-secret" -localRun $false

.NOTES
Author:     Sebastian Flæng Markdanner
Website:    https://chanceofsecurity.com
Version:    1.3

- Entra ID Service Principal with Azure RBAC Reader role on subscriptions, Management group or Root.
- Microsoft Graph API permissions:
  - Application.Read.All
  - AuditLog.Read.All
  - Directory.Read.All
  - PrivilegedAccess.Read.AzureAD
  - RoleManagement.Read.All
  - User.Read.All
  - Mail.Send (if emailing results).

- Required PowerShell modules:
  - Az.Accounts
  - Az.Resources
  - Microsoft.Graph.Authentication
  - Microsoft.Graph.Users
  - Microsoft.Graph.Identity.DirectoryManagement
  - Microsoft.Graph.Identity.SignIns
  - Microsoft.Graph.Reports
  - Microsoft.Graph.Identity.Governance
  - ImportExcel

.OUTPUTS
- Excel file: Combined report of Azure RBAC and Entra roles.
#>

#Region Script Parameters
param (
    [Parameter(Mandatory=$true)][string]$TenantId,
    [Parameter(Mandatory=$true)][string]$ClientId,
    [Parameter(Mandatory=$true)][string]$Client_secret,
    [Parameter(Mandatory=$true)][bool]$localRun = $true,
    [bool]$SaveFiles = $true,
    [string]$outDir = ("C:\Temp"),
    [string]$mailFrom = $null,
    [string]$mailTo,
    [string]$mailSubject = ("Admin Roles Overview"),
    [string]$excelFileName = ("Entra And RBAC Admin Roles")
)
#EndRegion

#Region Script Configuration
$WarningPreference = "SilentlyContinue"

# Setup output directory
if ($localRun -ne $true) { 
    $tempDir = [System.IO.Path]::GetTempPath()
    $outDir = Join-Path -Path $tempDir -ChildPath "AzureReports" 
}
if (-not (Test-Path -Path $outDir)) {
    New-Item -ItemType Directory -Path $outDir | Out-Null
}
$excelFilePath = Join-Path $outDir "$($excelFileName)_$((Get-Date).ToString('HH.mm_dd-MM-yyyy')).xlsx"

# Required modules
$requiredModules = @(
    "Az.Accounts",
    "Az.Resources",
    "Microsoft.Graph.Authentication",
    "Microsoft.Graph.Users",
    "Microsoft.Graph.Identity.DirectoryManagement",
    "Microsoft.Graph.Identity.SignIns",
    "Microsoft.Graph.Identity.Governance",
    "ImportExcel"
)
#EndRegion

#Region Helper Functions
function Get-GraphData {
    param($uri, $authHeader)
    
    $data = @()
    do {
        try {
            $result = Invoke-RestMethod -Uri $uri -Headers $authHeader -Method Get
            if ($null -eq $result) { return $null }
            
            if ($result.value) {
                $data += $result.value
            }
            
            $uri = $result.'@odata.nextLink'
            if ($uri) {
                Start-Sleep -Milliseconds 500
            }
        }
        catch {
            Write-Error "Failed to get data from Graph API: $_"
            throw
        }
    } while ($uri)
    
    return $data
}

function SafeRemoveModule { 
    param([string]$moduleName) 
    if (Get-Module $moduleName -ErrorAction SilentlyContinue) { 
        Remove-Module $moduleName -Force -ErrorAction SilentlyContinue 
    } 
}

function Test-Admin { 
    $currentUser = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
    return $currentUser.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) 
}

function Sanitize-TableName { 
    param([string]$name) 
    return $name -replace '[^a-zA-Z0-9_]', '_' 
}

function Get-AzAccessToken {
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string]$ClientSecret
    )
    $body = @{
        grant_type = "client_credentials"
        client_id = $ClientId
        client_secret = $ClientSecret
        resource = "https://management.azure.com/"
    }
    $url = "https://login.microsoftonline.com/$TenantId/oauth2/token"
    $response = Invoke-RestMethod -Method Post -Uri $url -ContentType "application/x-www-form-urlencoded" -Body $body
    return $response.access_token
}

function Check-Module {
    param([string]$moduleName)
    $installedModule = Get-InstalledModule -Name $moduleName -ErrorAction SilentlyContinue
    $onlineModule = Find-Module -Name $moduleName -ErrorAction SilentlyContinue
    if ($installedModule -and $onlineModule) {
        if ($installedModule.Version -lt $onlineModule.Version) { return $true }
    } 
    elseif (-not $installedModule) { return $true }
    return $false
}

function Install-OrUpdateModule {
    param([string]$moduleName)
    if (!(Get-Module -Name $moduleName -ListAvailable)) {
        Install-Module -Name $moduleName -Force -AllowClobber -Scope CurrentUser
    }
    Import-Module -Name $moduleName -Force
}
#EndRegion

#Region Initialize Environment
# Install/Update required modules if running locally
if ($localRun) {
    $totalSteps = $requiredModules.Count
    $currentStep = 0
    Write-Progress -Id 0 -Activity "Managing required modules" -Status "Starting" -PercentComplete 0

    foreach ($module in $requiredModules) {
        $currentStep++
        $overallProgress = ($currentStep / $totalSteps) * 100
        Write-Progress -Id 0 -Activity "Managing required modules" -Status "Processing $module" -PercentComplete $overallProgress
        Install-OrUpdateModule -moduleName $module
        Write-Progress -Id 1 -Activity "Imported $module" -Status "Complete" -Completed
    }

    Write-Progress -Id 0 -Activity "Managing required modules" -Status "Complete" -Completed
    Install-Module -Name PowerShellGet -Force -AllowClobber -Scope CurrentUser -WarningAction Ignore
}
#EndRegion

#Region Authentication
# Set up credentials and tokens
Write-Verbose "Setting up authentication..."

# Function to get Graph API token
function Get-GraphToken {
    param (
        [string]$TenantId,
        [string]$ClientId,
        [string]$ClientSecret
    )
    
    $body = @{
        grant_type    = "client_credentials"
        client_id     = $ClientId
        client_secret = $ClientSecret
        scope         = "https://graph.microsoft.com/.default"
    }
    
    $params = @{
        Uri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        Method = 'Post'
        Body = $body
        ContentType = 'application/x-www-form-urlencoded'
        UseBasicParsing = $true
    }
    
    try {
        $response = Invoke-RestMethod @params
        return $response.access_token
    }
    catch {
        Write-Error "Failed to acquire Graph token: $_"
        throw
    }
}

try {
    # Get Graph API token
    Write-Verbose "Acquiring Graph API token..."
    $graphToken = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $Client_secret
    $authHeader = @{
        'Authorization' = "Bearer $graphToken"
        'Content-Type' = 'application/json'
    }
    
    # Connect to Azure
    Write-Verbose "Connecting to Azure..."
    $secureSecret = ConvertTo-SecureString -String $Client_secret -AsPlainText -Force
    $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $ClientId, $secureSecret
    
    $null = Connect-AzAccount -ServicePrincipal -Credential $credential -Tenant $TenantId -WarningAction SilentlyContinue
    
    Write-Verbose "Authentication completed successfully"
}
catch {
    Write-Error "Authentication failed: $_"
    throw
}
#EndRegion

#Region Check Entra P2 Service Plan
$servicePlanId = "eec0eb4f-6444-4f95-aba0-50c24d67f998"
$subscriptionsResponse = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/v1.0/subscribedSkus" -Headers $authHeader
$servicePlanEnabled = $subscriptionsResponse.value | Where-Object {
    $_.ServicePlans | Where-Object { $_.ServicePlanId -eq $servicePlanId -and $_.ProvisioningStatus -eq "Success" }
} | ForEach-Object { $true }

$foreground = if ($servicePlanEnabled) { "Green" } else { "DarkMagenta" }
Write-Host "The service plan Azure AD Premium P2 is $(if ($servicePlanEnabled) { "enabled" } else { "not enabled" }) for the tenant." -ForegroundColor $foreground
#EndRegion

#Region Data Collection
# Collect EntraID data
Write-Verbose "Collecting all users, groups, and service principals from EntraID"
try {
    $allUsers = Get-GraphData -uri 'https://graph.microsoft.com/v1.0/users?$select=id,userPrincipalName,mail,displayName' -authHeader $authHeader
    Write-Verbose "Collected $($allUsers.Count) users"
    $allGroups = Get-GraphData -uri 'https://graph.microsoft.com/v1.0/groups?$select=id,displayName' -authHeader $authHeader
    Write-Verbose "Collected $($allGroups.Count) groups"
    $allServicePrincipals = Get-GraphData -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,appId,displayName' -authHeader $authHeader
    Write-Verbose "Collected $($allServicePrincipals.Count) service principals"
    if ($allUsers.Count -eq 0 -and $allGroups.Count -eq 0 -and $allServicePrincipals.Count -eq 0) { throw "No data collected from EntraID" }
} catch {
    Write-Error "Error collecting data from EntraID: $_"
    return
}

# Process principals
$allPrincipals = @()
$allPrincipals += $allUsers | ForEach-Object { 
    [PSCustomObject]@{ 
        id = $_.id
        type = 'User'
        identifier = $_.userPrincipalName 
    } 
}
$allPrincipals += $allGroups | ForEach-Object { 
    [PSCustomObject]@{ 
        id = $_.id
        type = 'Group'
        identifier = $_.displayName 
    } 
}
$allPrincipals += $allServicePrincipals | ForEach-Object { 
    [PSCustomObject]@{ 
        id = $_.id
        type = 'ServicePrincipal'
        identifier = $_.appId 
    } 
}

# Collect Azure Subscriptions and RBAC Roles
Write-Verbose "Collecting Azure Subscriptions"
$subscriptions = Get-AzSubscription
$totalSubscriptions = $subscriptions.Count
Write-Verbose "$totalSubscriptions found"
$subCount = 0
$rbacRoles = @()

# Process each subscription for RBAC roles
foreach ($subscription in $subscriptions) {
    $null = Set-AzContext -SubscriptionId $subscription.SubscriptionId
    $subCount++
    Write-Progress -id 0 -Activity "Processing subscriptions" -Status "$subCount of $totalSubscriptions processed. Currently processing subscription: $($subscription.Name)." -PercentComplete ($subCount / $totalSubscriptions * 100)
    
    # Process active role assignments
    $roleAssignments = Get-AzRoleAssignment
    foreach ($roleAssignment in $roleAssignments) {
        $accountName = if ($roleAssignment.ObjectType -in @("Group", "ServicePrincipal", "Unknown")) {
            $roleAssignment.DisplayName
        } else {
            $roleAssignment.SignInName
        }

        $displayName = if ($roleAssignment.ObjectType -in @("Group", "ServicePrincipal", "Unknown")) {
            "$($roleAssignment.ObjectType): $($roleAssignment.DisplayName)"
        } else {
            if ($accountName -like "*#EXT#@*") {
                "External User: $($roleAssignment.DisplayName)"
            } else {
                "User: $($roleAssignment.DisplayName)"
            }
        }

        if ($accountName -like "*#EXT#@*") {
            $externalUser = $allUsers | Where-Object { $_.id -eq $roleAssignment.ObjectId }
            if ($externalUser -and $externalUser.mail) { 
                $accountName = $externalUser.mail 
            }
        }

        $rbacRoles += [PSCustomObject]@{
            AccountName = $accountName
            DisplayName = $displayName
            SubscriptionName = $subscription.Name
            RoleDefinitionName = $roleAssignment.RoleDefinitionName
            AssignmentType = "Active"
            LastSignIn = $null
            Scope = $roleAssignment.Scope
            ObjectType = $roleAssignment.ObjectType
        }
    }

    # Process eligible role assignments
    if ($servicePlanEnabled) {
        Write-Verbose "Collecting eligible Azure RBAC roles using ARM API for subscription $($subscription.SubscriptionId)"
        $armToken = Get-AzAccessToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $Client_secret
        $armUri = "https://management.azure.com/subscriptions/$($subscription.SubscriptionId)/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01&`$expand=principal,roleDefinition"
    
        try {
            $response = Invoke-RestMethod -Uri $armUri -Method Get -Headers @{ Authorization = "Bearer $armToken" }
            if ($response.PSObject.Properties.Name -contains 'value' -and $response.value -is [array]) {
                foreach ($schedule in $response.value) {
                    Write-Verbose "Processing eligible role: $($schedule.properties.expandedProperties.roleDefinition.displayName) for principal: $($schedule.properties.expandedProperties.principal.displayName)"

                    $accountName = $schedule.properties.expandedProperties.principal.displayName
                    $displayName = $accountName

                    $entraUser = $allUsers | Where-Object { $_.displayName -eq $accountName } | Select-Object -First 1
                    if ($entraUser) {
                        $accountName = if ($entraUser.userPrincipalName -like "*#EXT#*") { 
                            $entraUser.mail 
                        } else { 
                            $entraUser.userPrincipalName 
                        }
                        $displayName = if ($entraUser.userPrincipalName -like "*#EXT#*") { 
                            "External User: $($entraUser.displayName)" 
                        } else { 
                            "User: $($entraUser.displayName)" 
                        }
                    } else {
                        $entraGroup = $allGroups | Where-Object { $_.displayName -eq $accountName } | Select-Object -First 1
                        if ($entraGroup) {
                            $accountName = $entraGroup.displayName
                            $displayName = "Group: $($entraGroup.displayName)"
                        } else {
                            $entraServicePrincipal = $allServicePrincipals | Where-Object { $_.displayName -eq $accountName } | Select-Object -First 1
                            if ($entraServicePrincipal) {
                                $accountName = $entraServicePrincipal.appId
                                $displayName = "ServicePrincipal: $($entraServicePrincipal.displayName)"
                            }
                        }
                    }

                    $rbacRoles += [PSCustomObject]@{
                        AccountName = $accountName
                        DisplayName = $displayName
                        SubscriptionName = $subscription.Name
                        RoleDefinitionName = $schedule.properties.expandedProperties.roleDefinition.displayName
                        AssignmentType = "Eligible"
                        LastSignIn = $null
                        Scope = $schedule.properties.expandedProperties.scope.id
                        ObjectType = $schedule.properties.expandedProperties.principal.type
                    }
                }
            }
        } catch {
        Write-Warning "Error processing eligible roles for subscription $($subscription.SubscriptionId): $_"
        }
    } else {
    Write-Verbose "Skipping eligible Azure RBAC roles as Entra P2 service plan is not enabled."
    }
}

# Collect Entra ID roles
Write-Verbose "Collecting Entra ID role assignments..."
$roles = Get-GraphData -uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$expand=principal' -authHeader $authHeader
$roles1 = Get-GraphData -uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$expand=roleDefinition' -authHeader $authHeader
if ($servicePlanEnabled) {
    $eligibleRoles = Get-GraphData -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?$expand=principal,roleDefinition' -authHeader $authHeader
} else {
    Write-Verbose "Skipping eligible Entra ID roles as Entra P2 service plan is not enabled."
}
#EndRegion

#Region Data Processing
# Process RBAC Roles
$userSignIns = Get-GraphData -uri 'https://graph.microsoft.com/beta/users?$select=id,userPrincipalName,signInActivity' -authHeader $authHeader

# Remove duplicates from RBAC roles and group them
$rbacRoles = $rbacRoles | Group-Object -Property AccountName, RoleDefinitionName, Scope | ForEach-Object {
    $_.Group[0]
}

# Update RBAC roles with sign-in data
foreach ($role in $rbacRoles) {
    if ($role.ObjectType -eq "User") {
        $userSignIn = $userSignIns | Where-Object { $_.userPrincipalName -eq $role.AccountName }
        if ($userSignIn) {
            $role.LastSignIn = $userSignIn.signInActivity.lastSignInDateTime
        }
    }
}

# Sort RBAC roles by PrincipalType, AccountName, DisplayName, AssignmentType and Scope
$rbacRoles = $rbacRoles | Sort-Object -Property @{
    Expression = {
        switch ($_.ObjectType) {
            "User" { 
                if ($_.AccountName -like "*#EXT#*") { "2-External User" }
                else { "1-User" }
            }
            "Group" { "3-Group" }
            "ServicePrincipal" { "4-ServicePrincipal" }
            default { "5-$($_.ObjectType)" }
        }
    }
}, AccountName, DisplayName, AssignmentType, Scope

# Process Entra ID Roles
$combinedEntraRoles = @()
foreach ($role in $roles) {
    $roleDef = ($roles1 | Where-Object {$_.id -eq $role.id}).roleDefinition
    $combinedRole = $role | Select-Object *, @{Name='roleDefinitionNew'; Expression={ $roleDef }}
    $combinedRole | Add-Member -MemberType NoteProperty -Name "AssignmentType" -Value "Active"
    $combinedEntraRoles += $combinedRole
}

foreach ($role in $eligibleRoles) {
    $combinedRole = $role | Select-Object *, @{Name='roleDefinitionNew'; Expression={ $role.roleDefinition }}
    $combinedRole | Add-Member -MemberType NoteProperty -Name "AssignmentType" -Value "Eligible"
    $combinedEntraRoles += $combinedRole
}

Write-Verbose "Processing Entra ID roles output..."
$entraReport = @()
$groupedRoles = $combinedEntraRoles | Group-Object -Property { $_.principal.id }

foreach ($group in $groupedRoles) {
    $firstRole = $group.Group[0]
    
    $reportLine = [ordered]@{
        "Principal" = switch ($firstRole.principal.'@odata.type') {
            '#microsoft.graph.user' {
                if ($firstRole.principal.userPrincipalName -like "*#EXT#*") {
                    $firstRole.principal.mail
                } else {
                    $firstRole.principal.userPrincipalName
                }
            }
            '#microsoft.graph.servicePrincipal' { $firstRole.principal.appId }
            '#microsoft.graph.group' { $firstRole.principal.id }
            Default { $null }
        }
        "PrincipalDisplayName" = switch ($firstRole.principal.'@odata.type') {
            '#microsoft.graph.user' { 
                if ($firstRole.principal.userPrincipalName -like "*#EXT#*") {
                    "External User: $($firstRole.principal.displayName)"
                } else {
                    "User: $($firstRole.principal.displayName)"
                }
            }
            '#microsoft.graph.servicePrincipal' { "ServicePrincipal: $($firstRole.principal.displayName)" }
            '#microsoft.graph.group' { "Group: $($firstRole.principal.displayName)" }
            Default { $firstRole.principal.displayName }
        }
        "PrincipalType" = if ($firstRole.principal.'@odata.type' -like "*user*" -and 
            $firstRole.principal.userPrincipalName -like "*#EXT#*") {
            "External User"
        } else {
            $firstRole.principal.'@odata.type'.Split(".")[-1]
        }
        "LastSignIn" = ""
        "ActiveRoles" = ($group.Group | Where-Object { $_.AssignmentType -eq "Active" } | 
            Select-Object -ExpandProperty roleDefinitionNew | 
            Select-Object -ExpandProperty displayName | 
            Sort-Object -Unique) -join ", "
        "EligibleRoles" = ($group.Group | Where-Object { $_.AssignmentType -eq "Eligible" } | 
            Select-Object -ExpandProperty roleDefinitionNew | 
            Select-Object -ExpandProperty displayName | 
            Sort-Object -Unique) -join ", "
        "IsBuiltIn" = $firstRole.roleDefinitionNew.isBuiltIn
        
    }

    # Add sign-in information if it's a user
    if ($reportLine.PrincipalType -in @("user", "External User")) {
        $userSignIn = $userSignIns | Where-Object { $_.userPrincipalName -eq $reportLine.Principal }
        if ($userSignIn) {
            $reportLine["LastSignIn"] = $userSignIn.signInActivity.lastSignInDateTime
        }
    }

    $entraReport += [PSCustomObject]$reportLine
}

# Sort Entra report by PrincipalType and then Principal
$entraReport = $entraReport | 
    Where-Object { $_.ActiveRoles -or $_.EligibleRoles } |
    Sort-Object -Property @{
        Expression = {
            switch ($_.PrincipalType) {
                "user" { "1-User" }
                "External User" { "2-External User" }
                "group" { "3-Group" }
                "servicePrincipal" { "4-ServicePrincipal" }
                default { "5-$($_.PrincipalType)" }
            }
        }
    }, Principal

# Export both reports to Excel
Write-Progress -Id 0 -Activity "Processing subscriptions" -Completed
$excelFilePath = Join-Path $outDir "Entra And RBAC Admin Roles_$((Get-Date).ToString('HH.mm_dd-MM-yyyy')).xlsx"

$rbacRoles | Export-Excel -Path $excelFilePath -WorksheetName "RBAC Roles" -AutoSize -TableStyle Medium2
$entraReport | Export-Excel -Path $excelFilePath -WorksheetName "Entra Roles" -AutoSize -TableStyle Medium2 -Append
#EndRegion

#Region Email Distribution
if ($mailFrom) {
    # Prepare email content
    $base64string = [Convert]::ToBase64String([IO.File]::ReadAllBytes($excelFilePath))
    $URLsend = "https://graph.microsoft.com/v1.0/users/" + $mailFrom + "/sendMail"
    $FileName = (Get-Item -Path $excelFilePath).name

    # Configure email parameters
    $mailParams = @{
        message = @{
            subject = $mailSubject + " " + (Get-Date -Format "dddd dd/MM/yyyy")
            body = @{
                contentType = "Text"
                content = "Automatic mail with user roles attached"
            }
            toRecipients = @(
                @{
                    emailAddress = @{
                        address = $mailTo
                    }
                }
            )
            attachments = @(
                @{
                    "@odata.type" = "#microsoft.graph.fileAttachment"
                    name = $FileName
                    contentBytes = $base64string
                }
            )
        }
        saveToSentItems = "false"
    }

    # Send email using Graph API
    Invoke-RestMethod -Method POST -uri $URLsend -Headers $authHeader -Body ($mailParams | ConvertTo-Json -Depth 10)
    Write-Host "Email sent to $mailTo with results attached." -ForegroundColor DarkYellow

    # Clean up Excel file if not running locally or files shouldn't be saved
    if (-not $SaveFiles -or -not $localRun) { 
        Remove-Item -Path $excelFilePath -Force 
    }
} else {
    Write-Host "Results exported to $excelFilePath" -ForegroundColor Green
}
#EndRegion

As you can see, the script is more comprehensive than I initially let on. Here’s a breakdown of its functionality:


  1. Core Functionality

    • The script utilizes a Service Principal to retrieve all active and eligible (if licensed) role assignments across Entra ID and Azure.

    • It also collects last sign-in data for identities and processes the results into a combined, formatted Excel file.

    • The output can either be saved locally or sent as an email attachment, depending on your requirements.


  2. Modules and APIs

    • Built using Microsoft Graph and Az PowerShell modules, the script ensures all necessary modules are installed and up-to-date when running locally.

    • In Azure Automation Accounts, you’ll need to install these modules beforehand—a step we’ll revisit later.


  3. Authentication

    • Authentication is handled via an App Registration Service Principal with a Client Secret.

    • Once Microsoft introduces Graph Powershell SDK support for Managed Identity as federated credentials, I’ll update the solution to leverage Managed Identity. This enhancement will increase security and reduce administrative overhead.


This solution provides a robust way to automate and simplify role assignment reporting across your tenant, ensuring you’re always prepared for audits, whether scheduled or on-demand. Up next, I’ll walk you through setting up and running the script in both local and automated environments.



Setting Up The Environment

Required Permissions and Access

To use the solution, a Service Principal is required to collect role assignments. This involves several steps for proper configuration:


Configuring the Service Principal

  1. Create an App Registration

    • Navigate to the Microsoft Entra portal and go to App registrations under the Applications blade.

      Microsoft Entra admin center screen showing "App registrations" with one app, "Valimail," listed under "All applications." Options include "New registration."

    • Click New registration, provide a meaningful name, and select Register.

      Web page for registering an app. Fields for name and redirect URI. Options for account types. Blue "Register" button at bottom.

    • After registration, take note of the Client ID and Tenant ID—these are needed to run the script.


  2. Grant API Permissions

    • Navigate to the API permissions menu in the newly created App registration.

      Azure portal interface showing "Role Assignment Solution" details. IDs and permissions are listed. API permissions are highlighted in red.

  3. Select +Add a permission, choose Microsoft Graph, and assign the following Application permissions:

    • Application.Read.All

    • AuditLog.Read.All

    • Directory.Read.All

    • Mail.Send

    • PrivilegedAccess.Read.AzureAD

    • RoleManagement.Read.All

    • User.Read.All

      • Don’t forget to Grant admin consent for the permissions!

    Configured permissions table for Microsoft Graph APIs, showing application read, audit log, and user data permissions granted.

  4. Create a Client Secret

    • Navigate to Certificates & secrets, select Client secrets, and click + New client secret.

    • Software interface for adding a client secret in "Role Assignment Solution." Details include description and expiry options.

      Copy the secret value—you’ll need this to run the script.


    • Alternatively, you can use PowerShell to create a client secret with an extended lifetime. Here’s a script for that, available here on my GitHub:

<#
.SYNOPSIS
Creates a client secret for an Entra ID app registration.

.DESCRIPTION
This script creates a client secret for an Entra ID app registration using the application's Client ID (ClientId).
It calculates the secret's expiration date based on the specified duration, generates the secret, and copies the secret value to the clipboard.

.PARAMETER ClientId
The Application (Client) ID of the Entra ID app registration.

.PARAMETER Description
A custom description or identifier for the client secret.

.PARAMETER Duration
The number of years the client secret will remain valid. Default is 99 years.

.EXAMPLE
.\CreateClientSecret.ps1 `
	-ClientId "12345678-90ab-cdef-1234-567890abcdef" `
	-Description "MyAppSecret" `
	-Duration 99

Creates a client secret for the specified app with a validity period of 99 years and copies the secret value to the clipboard.

.NOTES
Author:     Sebastian Flæng Markdanner
Website:    https://chanceofsecurity.com
Version:    1.0

- Requires the Az PowerShell module.
- User must be authenticated to Azure with sufficient permissions to manage app registrations.
#>

param (
    [Parameter(Mandatory = $true)]
    [string]$ClientId,

    [Parameter(Mandatory = $true)]
    [string]$Description,
    
    [Parameter(Mandatory = $true)]
    [int]$Duration = 99
)

# Authenticate to Azure
Connect-AzAccount

# Calculate start and end dates
$StartDate = Get-Date
$EndDate = $StartDate.AddYears($Duration)

# Encode the description to Base64
$EncodedDescription = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Description))

# Create client secret
$ClientSecret = Get-AzADApplication -ApplicationId $ClientId | New-AzADAppCredential -StartDate $StartDate -EndDate $EndDate -CustomKeyIdentifier $EncodedDescription

# Copy secret to clipboard
$ClientSecret.SecretText | Set-Clipboard

# Notify user and wait
Write-Output "Client secret created and copied to clipboard. Script will end in 10 seconds."
Start-Sleep -Seconds 10

Granting Entra ID Permissions

The Service Principal requires the Directory Reader role in Entra ID. Assign it with the following PowerShell command:

# Assign Directory Reader role
$servicePrincipalId = "<AppRegistrationObjectID>"
$directoryReaderRole = "88d8e3e3-8f55-4a1e-953a-9b9898b8876b"
New-AzureADDirectoryRoleMember `
	-ObjectId $directoryReaderRole `
	-RefObjectId $servicePrincipalId

Configuring Azure RBAC Access

To collect Azure RBAC roles, assign the Reader role to the Service Principal at the desired scope (e.g., subscription, management group, or root level). For maximum coverage, assign the role at the root (/) level:

New-AzRoleAssignment `
	-ObjectID "<AppRegistrationObjectID>" `
	-RoleDefinitionName "Reader" `
	-Scope "/"

Setting Up Email Permissions

The Mail.Send API permission allows the Service Principal to send emails. To restrict its ability to send emails as any user (a major security risk), you must limit it to specific email addresses.


This can be achieved using PowerShell. Here’s a script (found on my GitHub here) to automate the process:

<#
.SYNOPSIS
Creates a mail-enabled security group, a shared mailbox, and an application access policy in Exchange Online.

.DESCRIPTION
This script connects to Exchange Online, creates a mail-enabled security group, a shared mailbox, and an application access policy with specified parameters. It hides the mail-enabled security group from the address list and restricts access for the app to the group members.

.PARAMETER GroupName
The name of the mail-enabled security group to be created.

.PARAMETER GroupAlias
The alias for the mail-enabled security group.

.PARAMETER GroupEmail
The email address for the mail-enabled security group.

.PARAMETER SharedMailboxName
The email address for the shared mailbox.

.PARAMETER SharedMailboxDisplayName
The display name for the shared mailbox.

.PARAMETER SharedMailboxAlias
The alias for the shared mailbox.

.PARAMETER ClientId
The Application (Client) ID of the Entra ID app registration.

.EXAMPLE
.\RestrictServicePrincipalEmail.ps1 `
    -GroupName "SMTP Graph" `
    -GroupAlias "smtp-graph" `
    -GroupEmail "smtp-graph@contoso.com" `
    -SharedMailboxName "privroles@contoso.com" `
    -SharedMailboxDisplayName "Privileged Roles Monitoring" `
    -SharedMailboxAlias "privroles" `
    -ClientId "12345678-abcd-efgh-ijkl-9876543210ab" `

.NOTES
Author: Sebastian Flæng Markdanner
Website: https://chanceofsecurity.com
Version: 1.0

- Requires the ExchangeOnlineManagement PowerShell module.
#>

param (
    [Parameter(Mandatory = $true)]
    [string]$GroupName,

    [Parameter(Mandatory = $true)]
    [string]$GroupAlias,

    [Parameter(Mandatory = $true)]
    [string]$GroupEmail,

    [Parameter(Mandatory = $true)]
    [string]$SharedMailboxName,

    [Parameter(Mandatory = $true)]
    [string]$SharedMailboxDisplayName,

    [Parameter(Mandatory = $true)]
    [string]$SharedMailboxAlias,

    [Parameter(Mandatory = $true)]
    [string]$ClientId
)

# Connect to Exchange Online
Connect-ExchangeOnline

# Creates a new mail-enabled security group
New-DistributionGroup -Name $GroupName -Alias $GroupAlias -Type Security

# Set email address and hide the mail-enabled security group from the address list
Set-DistributionGroup -Identity $GroupName -EmailAddresses SMTP:$GroupEmail -HiddenFromAddressListsEnabled $true

# Creates a new shared mailbox
New-Mailbox -Shared -Name $SharedMailboxName -DisplayName $SharedMailboxDisplayName -Alias $SharedMailboxAlias

# Add the shared mailbox to the mail-enabled security group
Add-DistributionGroupMember -Identity $GroupName -Member $SharedMailboxName

# Create the application access policy
New-ApplicationAccessPolicy -AppId $ClientId -PolicyScopeGroupId $GroupEmail -AccessRight RestrictAccess -Description "Restrict this app to send mails only to members of the group $GroupName"

# Output confirmation
Write-Output "Resources created successfully: mail-enabled security group '$GroupName' and Shared Mailbox '$SharedMailboxName'. Application Access Policy applied."

This script ensures the Service Principal can only send emails as the shared mailbox or addresses within the mail-enabled security group. To change the allowed email, simply add it to the group.


With these prerequisites in place, your Service Principal is now fully configured and ready to run the role assignment reporting solution.



Implementing the Solution

This script is designed to work seamlessly both locally and in an automated Azure Automation Account setup.


Manual Execution Guide

Running the script manually is straightforward. Here are a few examples:

  1. Saving the output file locally to C:\Temp:

.\CollectRoleAssignments.ps1 `
	-TenantId "your-tenant-id" `
	-ClientId "your-client-id" `
	-Client_secret "your-client-secret" `
	-localRun $true

  1. Emailing the output to the auditor team’s shared mailbox without saving the generated report:

.\CollectRoleAssignments.ps1 `
	-TenantId "your-tenant-id" `
	-ClientId "your-client-id" `
	-Client_secret "your-client-secret" `
	-LocalRun $true `
	-mailFrom "automation@yourdomain.com" `
	-mailTo "AuditTeam@yourdomain.com" `
	-SaveFiles $false

Automating with Azure Automation

While manual execution works well, automating the process ensures continuous and consistent reporting. Here’s how to set it up in an Azure Automation Account:

  1. Create the Automation Account

    • In the Azure portal, search for Automation Accounts, and select Create.

      Search bar showing "automation," with tabs for All, Services, and Marketplace. Highlighted result: "Automation Accounts" under Services.

    • Complete the setup process.

      Screenshot of "Create an Automation Account" screen with a "Validation passed" message. Details include name, region, and network settings.

  2. Configure Modules

    • Navigate to the Runtime Environment (preferred) or Modules (Old) blade in the Automation Account.

      Azure Automation interface showing overview and runtime environments. "Create a Runtime environment" button highlighted. Text on job execution.
      Azure dashboard showing modules under "aa-roleassignmentreporting." Modules are listed by name and status, mostly marked as available.

    • If using the Runtime Environment, create one that includes the following required modules, otherwise install the modules directly:

      • Az.Accounts

      • Az.Resources

      • Microsoft.Graph.Authentication

      • Microsoft.Graph.Users

      • Microsoft.Graph.Identity.DirectoryManagement

      • Microsoft.Graph.Identity.SignIns

      • Microsoft.Graph.Reports

      • Microsoft.Graph.Identity.Governance

      • ImportExcel

        Interface showing "Create a Runtime environment" with package list and versions, including Az 11.2.0. Warnings on upload limits.

  1. Create and Upload the Runbook

    • Go to the Runbooks menu and select Create.

      Azure portal interface showing "aa-roleassignmentreporting" Runbooks. "Create" button highlighted. Sidebar lists automation options.

    • Upload the script, choosing the Runtime Environment that contains the required modules.

      Azure interface for creating a runbook. Options for PowerShell, file browsing, and naming are visible. The text explains runbook usage.

  2. Test the Runbook

    • Before publishing, test the Runbook in the Test pane.

      PowerShell Runbook editing interface, showing code snippets for role assignments. Menu with CMDLETS, RUNBOOKS, and ASSETS. Test pane highlighted.

    • For automation runs, set the following parameters:

      • LocalRun and SaveFiles to $false.

      • The OutDir is automatically overwritten to a temporary location.

    User interface for a test run with parameter fields on the left and instructions on the right. Text prompts to click 'Start' for results.

  3. Publish and Schedule the Runbook

    • Once testing is successful, publish the Runbook. - This allows for on-demand runs, which will prompt for parameters

      Azure Runbook interface showing 'CollectRoleAssignments' with 'Start' button highlighted. Input fields for tenant, client ID, and secret are empty.

  • To automate execution, navigate to the Schedules menu:

    Azure portal interface displaying the "CollectRoleAssignments" runbook schedule. No schedules listed. Options: "Add a schedule" and "Refresh."

  • Select Add a schedule and configure start date, recurrence, and expiration.

    Screen showing schedule setup for “Monthly-Reporting”. Details include start date, time zone, and recurrence options. No schedules yet.

  • Once a schedule is linked, access the Parameters and Run Settings menu. Here, you’ll configure the parameters that will be reused every time the schedule runs.

    Form with fields for TENANTID, CLIENTID, CLIENT_SECRET. Red error messages indicate missing values. Blue "OK" button below.


Understanding the Reports

The script generates a detailed and formatted Excel report that is divided into two sheets: RBAC Roles and Entra Roles. Here’s an overview of how each sheet is structured and its key features:


Azure RBAC Role Assignments

This sheet provides detailed insights into role assignments across Azure resources.

A table lists names, emails, subscriptions, active roles, and last sign-in dates. Blue and white rows with text showing user details.

Columns:

  • AccountName: Displays the identity's UPN, ObjectID or App DisplayName

  • DisplayName: Displays the identity’s name with a prefix (e.g., User, External User, Service Principal, Group).

  • SubscriptionName: The subscription where the role is assigned.

  • RoleDefinitionName: The role assigned to the identity (e.g., Global Administrator, Reader).

  • AssignmentType: Whether the role is Active or Eligible.

  • LastSignIn: Indicates the last time the identity signed in.

  • Scope: The level at which the role is assigned (e.g., subscription, resource group).

  • ObjectType: Specifies the type of identity (User, External User, Group, Service Principal).


Key Details:

  • The prefix in DisplayName distinguishes identity types:

    • User: Standard users in the directory.

    • External User: Collaborators from outside the organization.

    • Service Principal: Applications or automated accounts.

    • Group: Security groups with role assignments.

    • Unknown: Foreign identities such as GDAP partner relationships, which are not translatable. These still display scope and roles but lack additional data.


From a production environment, the RBAC Roles sheet demonstrates:

Spreadsheet with columns for user roles and permissions. Active statuses in blue, eligible in red. Text includes "RG1" to "RG5".

All of these are for the same external user.

This demonstrates:

  • Active and Eligible roles across multiple scopes.

  • Duplicates removed—each role and scope is listed uniquely based on AssignmentType and role.


Entra ID Role Management

The Entra roles feature fewer scopes than we typically use. We have global roles and Administrative Units for scoping, meaning each Identity is shown once, with all active assignments consolidated into one cell, and eligible assignments in another.

Let's examine the same two tenants as earlier:

Table displaying user data: emails, names, principal types, last sign-ins, active roles. Blue background, some text blurred for privacy.

Let’s take a closer look at some key columns and what they mean:

  • Principal:

    This could be an email, App ID, or group object ID. For external users, emails are displayed directly for clarity.

    Example:

    john.smith_contoso.com#EXT#@woodgrove.com becomes john.smith@contoso.com.


  • PrincipalDisplayName

    The display name of the identity, with prefixes matching the options listed in the RBAC Roles sheet (email, App ID, or object ID).


  • PrincipalType

    This indicates the type of identity (e.g., User, App, Group).


  • LastSignIn

    Shows the last time the identity signed into the tenant.


  • ActiveRoles

    A combined list of all active role assignments.


  • EligibleRoles

    A combined list of all roles the identity is eligible for but not actively assigned to.


  • IsBuiltIn

    Identifies whether the role is a built-in role or a custom one.


Here’s a snapshot from a production tenant to better illustrate these concepts:

Spreadsheet with columns: PrincipalDisplayName, PrincipalType, LastSignIn, ActiveRoles. Displays various user roles and last sign-in dates.

This view highlights:

  • The variety of identity types.

  • How they’re organized and sorted.

  • The formatting when multiple roles are assigned to a single identity.


Understanding this structure makes it much easier to keep track of roles and identities in your tenant. It’s a simple way to maintain clarity in what can quickly become a maze of assignments.



Conclusion: Transforming Your Role Management Strategy

Managing role assignments across your tenant doesn’t have to feel like herding cats. With this solution, you can take control of your Azure RBAC and Entra roles, streamline audits, and stay ahead of regulatory demands like NIS-2. Whether you’re running the script locally or automating it with Azure Automation Accounts, you’ll have clear and actionable insights at your fingertips.


And as always, here's another bad joke!


What did Git say to the repository?

Let me commit to our relationship! 😎


If you’ve found this solution helpful, be sure to check out my GitHub repository for more tools, tips, and updates. And don’t forget to stay tuned for my next posts, as I've got big plans coming up!


Until then, happy auditing—and may your roles be secure and your Excel sheets perfectly formatted.

2 Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating
shy.grape8614
Mar 20
Rated 5 out of 5 stars.

Great stuff! Thanks for sharing ! Quick question, do you have any solution similar to this but that looks at which Graph permissions have been assigned to each application? I'm asking this because at least in our environment, none of the apps have Entra roles assigned, but they are granted graph permissions willy nilly.

Edited
Like
Sebastian F. Markdanner
Sebastian F. Markdanner
Mar 20
Replying to

Thank you, glad you found it helpful :) Currently no, but it's something I've got on my list - I'm working on an update to the solution, both in terms of the aesthetic and capabilities, but as with anything else time is a limiter!

Like
bottom of page