top of page

Microsoft Entra Identity Governance Fundamentals: Privileged Identity Management

  • Writer: Sebastian F. Markdanner
    Sebastian F. Markdanner
  • Nov 11, 2024
  • 18 min read

Updated: Dec 8, 2024

As the Conditional Access series wraps up, we’re diving headfirst into a new adventure in Identity Management! Join me as I explore the ins and outs of Microsoft Identity Governance, starting with Privileged Identity Management (PIM).

Futuristic robots representing Privileged Identity Management (PIM) and Privileged Access Management (PAM) stand in a high-tech control room with digital security interfaces. The central robot holds a shield symbolizing cybersecurity. Background displays show security icons, metrics, and Microsoft branding.

Privileged Access Management (PAM) is a broad term covering various methods to monitor, manage, and protect access across our environment. PAM solutions includes, but are not limited to, lifecycle management, Multi-Factor Authentication (MFA), Just-In-Time (JIT) Access, Just-Enough-Administration (JEA), and today’s focus—Privileged Identity Management (PIM).


Microsoft offers multiple solutions to support our PAM journey, including Microsoft Entra, Microsoft Sentinel, and Microsoft Entra ID Governance. In this series, I’ll delve into the features and capabilities that help us bolster our identity security.


Without further ado, let’s jump into Microsoft Entra ID Governance’s PIM solution!


Table of content


What is Privileged Identity Management?

PIM provides time- & approval-based, privileged access to our users, particularly our Administrators.

It lets admins elevate their access to Entra ID roles, group member roles, or Azure Role Based Access Control (RBAC) roles.


Configurable Settings

For each role, we're able to configure the following 3 settings, at the role level:


  1. Activation

    In PIM, activation settings determine the specific requirements and permissions needed for role activation. These options allow you to fine-tune role access to meet security and operational needs.

    • Activation Maximum Duration (hours) – Set how long a role activation lasts. By default, this is 8 hours, but you can adjust it based on your security needs.

    • On Activation, require – Specify any extra security checks needed for activation:

      • None – No additional authentication needed.

      • Azure MFA – Requires multi-factor authentication to activate the role.

      • Microsoft Entra Conditional Access – Leverages a conditional access policy for additional verification.

        Default setting depends on the role.

    • Require Justification – Requires the user to provide a reason for role activation. Enabled by default.

    • Require Ticket Information – Adds two fields for users to enter a ticket system and ticket number when activating the role. This information isn’t linked to any ticketing system but can help track requests. Disabled by default.

    • Require Approval – Sets an approval flow for role activation. Approvers can be specific users or groups. Disabled by default.


  2. Assignment

    Assignment settings control who has long-term or “eligible” access to a role and the specifics of their access permissions. Here’s what you can configure:

    • Allow Permanent Eligible Assignment – Determines if eligible assignments can be permanent. Enabled by default. If disabled, you can set an expiration timeframe for eligible assignments.

    • Allow Permanent Active Assignment – Allows permanent active assignments by default. When disabled, you can set an expiration timeframe for active assignments.

    • Require Azure MFA on Active Assignments – Requires MFA when assigning a role. Note that users who already have a valid MFA token won’t be prompted again. Disabled by default.

    • Require Justification on Active Assignments – Similar to the activation justification, but for active assignments. Enabled by default.

  3. Notifications

    Notifications help manage the flow of alerts and updates throughout the lifecycle of a PIM role, from assignment to activation.

    For all notification types there's 3 different options available for configurations:

    • Default Recipients – By default, email notifications go to predefined recipients, but you can adjust this by enabling or disabling them. Enabled by default.

    • Additional Recipients – Add specific recipients for notifications beyond the default recipients. You can list multiple recipients by separating each email address with a semicolon. Not configured by default.

    • Critical Emails Only – Only sends emails when immediate action is required, for example, approvals for role extensions.


Additionally, Access Reviews provide an automated way to manage and review permissions within Microsoft Identity Governance. This feature helps ensure that access rights remain appropriate over time by enabling fully or semi-automated permission evaluations.


Access Reviews are a robust solution on their own, so I’ll be diving into them in detail in a future post. For now, just know that they’re another powerful tool you can leverage with PIM.



Why utilize Privileged Identity Management?

Overprivileged identities are a hacker’s dream. When users have more permissions than they truly need, attackers can exploit these permissions to achieve a range of malicious goals, from data theft to system destruction. Attackers often start by gaining initial access and then move laterally across systems, escalating privileges as they go. With the right permissions, they can target sensitive data, alter configurations, or disrupt operations—making robust access management a priority.


The urgency of managing privileged access is reinforced by the statistics. According to projections, the cost of cyberattacks will reach $10.5 trillion annually by 2025.

The average cost of a single data breach already stands at a whooping $4.88 million in 2024, making each breach an expensive reminder of what’s at stake!


The CrowdStrike Global Threat Report 2024 reveals a 110% increase in Cloud-Conscious Attacks.

These are sophisticated attacks where threat actors exploit cloud environments and specifically target identity-based and social engineering vulnerabilities. Such attacks often include:

  • Initial access through social engineering and different kinds of phishing attacks.

  • Lateral movement through an environment to acquire privileged identities.

  • Escalation and exploitation of these identities for high-value targets, typically sensitive data.


With such threats on the rise, strong access management solutions like PIM are essential to defending against these attacks.


Microsoft’s Take on the Overprivilege Problem

Microsoft’s research adds even more weight to this issue, revealing that only 2% of permissions assigned to users in 2023 were actively used.

This staggering statistic emphasizes the critical need for a more controlled and minimalistic approach to access rights. To address this, Microsoft recommends:

  1. Removing unnecessary permissions – Eliminate all permissions that aren’t essential to the user’s role.

    1. Use Just-in-Time (JIT) access to grant permissions only when needed and only for the minimum time required.

  2. Managing privileged identities with Zero Trust – Apply least-privilege access and explicit verification principles, ensuring users only have the rights needed for their role and that elevated permissions are granted sparingly and temporarily.


PIM directly supports these goals, offering features that allow organizations to:

  • Minimize access by implementing temporary and just-in-time privileges.

  • Require justification, multi-factor authentication (MFA), and approvals for elevated permissions.

  • Regularly review access assignments with automated Access Reviews to ensure permissions align with current job requirements.


PIM as a Core Element in Zero Trust Strategy

By enforcing PIM, organizations can anchor their access management strategy in the three core Zero Trust principles:

  1. Verify Explicitly – Taking advantage of enhanced Conditional Access policies that require additional authentication steps, such as Authentication Context policies, when users elevate to sensitive roles like Global Administrator or Azure RBAC Owner. This added verification helps secure the access pathway.

  2. Use Least Privilege – Instead of assigning blanket roles like Global Administrator, PIM makes it possible to apply role- and task-based privileges. Administrators receive access only to the resources and actions they need, when they need it, which reduces the risk of overprivileged identities and limits the damage potential of compromised accounts.

  3. Assume Breach – By requiring users to request elevation for highly privileged roles, PIM helps limit the duration and scope of elevated permissions. This reduces the window of opportunity for potential attackers, helping prevent data breaches, automated attacks, and unauthorized lateral movement.


In summary, PIM adds essential layers of control to privileged access management, decreasing the risk of overprivileged accounts, improving compliance, and enhancing the overall security posture against identity-based attacks.

As we navigate the increasingly complex technological landscape, PIM is a key ally in protecting our environments and upholding Zero Trust principles.



How to implement Privileged Identity Management?

PIM can be managed via the Entra or Azure portals, or programmatically with Powershell.


Microsoft Entra Portal

Entra is the go-to for all things identity. Accessing the Privileged Identity Management blade in Entra opens up management options, such as configuring activation and assignment settings for roles, monitoring activity, and creating assignments. Here’s a step-by-step guide:


Accessing PIM in Entra


Accessing the Priviliged Identity Management blade we are presented with different options. For our purpose today the Manage menu is our focus.

Choosing any of the options in the menu allows us to manage the respective assignments. A couple things to note for management:

  • For Group management, onboard the groups you want to manage.

  • For Azure, define the assignment scope you want to manage.


Choosing Entra Roles presents a detailed blade with access options:


As shown, the Overview Dashboard provides at-a-glance insights into your environment, showing active users, role activations, role assignments, alerts, and related PIM activities.


Configuring role-specific settings

From the Roles or Settings menu, you can customize role activation requirements, notification options, and assignment scopes.


This allows us to increase or decrease the requirements as well as scoping assignments and notifications, allowing per-role customization across Entra ID Roles, Azure RBAC Roles, and Group Member/Owner roles in PIM.


Creating an assignment

Creating Eligible or Active assignments is possible via the Assignments or Roles menus


Creating the assignment is the most critical step in setting up PIM. This is where you determine the actual assignments.


Based on the role’s configured settings, you have the flexibility to:

  • Assign Eligible or Active roles – Eligible roles allow users to request elevation when needed, while Active roles provides access without the need to elevate access.

  • Set assignment duration – Choose between temporary assignments with defined end dates or permanent roles.


Each assignment you create helps forming the foundation of a controlled and secure environment.


PIM User experience, approval based:

The roles assigned for this example requires approval before it becomes active, taking a look at this from the user end:


When a role requires approval before activation, the user submits a request and waits for approver confirmation. If approval isn’t required, the elevated role is assigned immediately, and the page refreshes. Users with multiple roles can activate them consecutively by selecting each role, closing the validation pop-up, and activating the next, only waiting for the full validation and activation on the last role.


PIM Approver experience:

Once the role activation request have been sent from the user, the approvers for the specified role gets an email, and will then be able to approve or deny via the portal:


Approvers receive an email notification for pending activation requests. Through the portal, they can approve or deny the request. Upon approval, the user’s assignment becomes active for the requested duration. Both activation and extension approvals are managed from a unified menu in the portal.



Microsoft Azure Portal

Before the introduction of unified portals like Entra, many of us managed PIM in the Azure portal. While it’s still possible to use Azure for PIM tasks, I highly recommend becoming familiar with Entra for its identity management focus!



Powershell

Though I'm a sucker for the portals, being able to handle assignments programmatically can be very helpful, especially when handling multiple assignments.


Using powershell with Microsoft Graph provides a very powerful and reliable way to handle assignments in a code based administrative environment, as such I've created a "small" script you're welcome to take advantage of.


This script can handle Users, Groups and Service Principals in a single call, for any of the 3 different PIM assignment types: Entra ID Roles, Group Memberships and Azure RBAC Roles.

I've included a few examples in the script as well.


Expanding the button below will reveal the script, though I highly recommend getting it from my github instead: Manage-PIMRoleAssignments


Although I’m all about the portals, sometimes nothing beats handling assignments programmatically—especially when you’re juggling multiple roles at once!


Using PowerShell with Microsoft Graph makes it easy and reliable to manage assignments in a code-based environment. To make things even simpler, I’ve put together a handy "little" script that does the heavy lifting for you.


This script can manage Users, Groups, and Service Principals in a single call, covering any of the three PIM assignment types: Entra ID Roles, Group Memberships, or Azure RBAC Roles. While it can handle any combination of identities for any one type at a time, you’ll need to run separate calls for each type if managing multiple assignment types.

I've made sure to include some examples in the help for the script, to help you get started!


Check out the full script on GitHub: Manage-PIMRoleAssignments


PIM Management Powershell script

<#
.SYNOPSIS
   Manage Privileged Identity Management (PIM) eligible roles in Microsoft Entra ID, Azure RBAC, and Group Membership.

.NOTES
    Author: Sebastian Flæng Markdanner
    Website: https://chanceofsecurity.com
    Email: Sebastian.Markdanner@chanceofsecurity.com
    Version: 3.1
    Date: 09-11-2024

.DESCRIPTION
   This script allows administrators to create and manage PIM-eligible roles across:
     - Microsoft Entra ID (directory roles)
     - Azure RBAC roles within subscriptions, management groups, resource groups, or resources
     - Group membership eligibility for specified groups in Entra ID

.PARAMETER ScopeType
   Specifies the scope of the role assignment. Valid values are:
      - "EntraID" for Entra ID directory roles
      - "Azure" for Azure RBAC roles
      - "GroupMembership" for PIM-enabled group memberships

    NOTE:
      - Nested groups are NOT supported if the targeted group have Microsoft Entra Role assignments enabled.

.PARAMETER PrincipalIdentifiers
   Array of identifiers for the principals (UPNs for users, display names for groups or applications).

.PARAMETER RoleDefinitionId
   The ID of the role to assign. This parameter is mandatory for Entra ID and Azure RBAC roles.

.PARAMETER GroupAccessId
   Specifies the access level for group membership when ScopeType is "GroupMembership".
   Valid values are:
      - "Owner" for group ownership
      - "Member" for group membership

.PARAMETER GroupDisplayName
   The display name of the group when ScopeType is set to "GroupMembership". This parameter is mandatory for group membership assignments.

.PARAMETER DirectoryScopeId
   The scope of the assignment. Use "/" for tenant-wide scope in Entra ID.

.PARAMETER Scope
   The Azure scope for RBAC role assignments. Can specify:
      - Root scope for the entire tenant (`/`).
      - Management group (`/providers/Microsoft.Management/managementGroups/<ManagementGroupId>`).
      - Subscription (`/subscriptions/<SubscriptionId>`).
      - Resource group (`/subscriptions/<SubscriptionId>/resourceGroups/<ResourceGroupName>`).
      - Specific resource (`/subscriptions/<SubscriptionId>/resourceGroups/<ResourceGroupName>/providers/<ResourceProviderNamespace>/<ResourceType>/<ResourceName>`).
   If not provided, the script defaults to the current subscription in the logged-in context.

.PARAMETER ActionType
   Specifies the type of action to perform. Valid values are:
      - "Active" for assigning an active role
      - "Eligible" for assigning an eligible role
      - "Remove" for removing an existing role assignment.

    NOTE:
      - Eligible Roles are NOT supported for Service principals

.PARAMETER Justification
   A brief description or justification for the role assignment or removal.

.PARAMETER StartDateTime
   The date and time at which the role assignment should begin. Defaults to the current date and time if not specified.

.PARAMETER Duration
   The duration for the role assignment in ISO 8601 format (e.g., "PT10H" for 10 hours). This is applicable to both eligible and active role assignments.

.EXAMPLE
   ./Manage-PIMRoleAssignment.ps1 -ScopeType "GroupMembership" -PrincipalIdentifiers "user1@domain.com", "group2" -ActionType "Eligible" -GroupDisplayName "Engineering" -GroupAccessId "Member" -Justification "Project access"

   Creates an eligible assignment for multiple principals: user "user1@domain.com" and group "group2" as "Members" of the "Engineering" group.

.EXAMPLE
   ./Manage-PIMRoleAssignment.ps1 -ScopeType "EntraID" -PrincipalIdentifiers "group1", "sp1", "user2@domain.com" -RoleDefinitionId "88d8e3e3-8f55-4a1e-953a-9b9898b8876b" -ActionType "Active" -Justification "Directory Reader role assignment for group1" -StartDateTime ([datetime]"2024-12-24T10:00:00Z") -Duration "PT8H"

   Creates an active assignment for the group "group1", the Service Principal "sp1" and the user "user2@domain.com" in Microsoft Entra ID with an 8-hour duration for the Directory Reader role. The assignment starts on December 24, 2024, at 10:00 AM UTC.

.EXAMPLE
   ./Manage-PIMRoleAssignment.ps1 -ScopeType "Azure" -PrincipalIdentifiers "group1", "user3@domain.com" -RoleDefinitionId "acdd72a7-3385-48ef-bd42-f606fba81ae7" -Scope "/subscriptions/<SubscriptionID>" -ActionType "Eligible" -Justification "Read-only access"

   Assigns an eligible role to the group "group1" & user "user3@domain.com" for the reader role in the specified Azure subscription.

.EXAMPLE
   ./Manage-PIMRoleAssignment.ps1 -ScopeType "Azure" -PrincipalIdentifiers "user4@domain.com" -RoleDefinitionId "b24988ac-6180-42a0-ab88-20f7382dd24c" -Scope "/providers/Microsoft.Management/managementGroups/<ManagementGroupId>" -ActionType "Eligible" -Justification "Management group assignment" -Duration ""

   Assigns an eligible Contributor role assignment for the user "user4@domain.com" to the specified Management Group within Azure without an expiration.

.EXAMPLE
   ./Manage-PIMRoleAssignment.ps1 -ScopeType "Azure" -PrincipalIdentifiers "user5@domain.com", "group1" -RoleDefinitionId "b24988ac-6180-42a0-ab88-20f7382dd24c" -Scope "/subscriptions/<SubscriptionId>/resourceGroups/<ResourceGroupName>" -ActionType "Active" -Justification "Resource group access for user5"

   Assigns an active Contributor role assignment for the user "user5@domain.com" and the group "group1" to a specific resource group in Azure with the default 8-hour duration.

.INPUTS
   None. This script does not accept piped input.

.OUTPUTS
   None. This script does not produce output directly.
#>

param (
    [Parameter(Mandatory=$true)][ValidateSet("EntraID", "Azure", "GroupMembership")][string]$ScopeType,
    [Parameter(Mandatory=$true)][string[]]$PrincipalIdentifiers,
    [string]$RoleDefinitionId,
    [ValidateSet("Owner", "Member")][string]$GroupAccessId,
    [string]$GroupDisplayName,
    [string]$DirectoryScopeId = "/",
    [string]$Scope,
    [ValidateSet("Active", "Eligible", "Remove")][string]$ActionType = "Eligible",
    [string]$Justification,
    [datetime]$StartDateTime = (Get-Date),
    [string]$Duration = "PT8H"  # ISO 8601 duration format
)

# Track Graph connection status
$Global:IsGraphConnected = $false

# Custom logging function to provide clear, color-coded output with timestamps
function Write-ScriptLog {
    param(
        [Parameter(Mandatory=$true)][string]$Message,
        [ValidateSet("Info", "Warning", "Error", "Success")][string]$Type = "Info",
        [switch]$NoNewline,
        [string]$VerboseMessage
    )
    
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    
    $colors = @{
        "Info"    = "Cyan"
        "Warning" = "Yellow"
        "Error"   = "Red"
        "Success" = "Green"
    }
    
    $prefix = switch($Type) {
        "Info"    { "INFO" }
        "Warning" { "WARN" }
        "Error"   { "ERROR" }
        "Success" { "SUCCESS" }
    }
    
    # Format and display the main message
    $formatMessage = "[$timestamp] $prefix : $Message"
    if ($NoNewline) {
        Write-Host $formatMessage -ForegroundColor $colors[$Type] -NoNewline
    } else {
        Write-Host $formatMessage -ForegroundColor $colors[$Type]
    }

    # Write verbose message if provided and -Verbose is used
    if ($VerboseMessage -and $Verbose) {
        Write-Host "[$timestamp] VERBOSE : $VerboseMessage" -ForegroundColor "Gray"
    }
}

# Manages Microsoft Graph connection state
# Ensures necessary scopes are available for operations
function Ensure-GraphConnection {
    param (
        # Default scopes required for PIM operations
        [string[]]$Scopes = @(
            "User.Read.All",
            "Group.Read.All", 
            "Application.Read.All",
            "GroupMember.ReadWrite.All",
            "Directory.AccessAsUser.All",
            "RoleManagement.Read.All"
        )
    )
    
    if (-not $Global:IsGraphConnected) {
        try {
            Write-ScriptLog "Connecting to Microsoft Graph..." -Type "Info"
            Connect-MgGraph -Scopes $Scopes -ErrorAction Stop
            $Global:IsGraphConnected = $true
            Write-ScriptLog "Successfully connected to Microsoft Graph." -Type "Success"
        } catch {
            Write-ScriptLog "Failed to connect to Microsoft Graph: $_" -Type "Error"
        }
    } else {
        Write-ScriptLog "Microsoft Graph is already connected." -Type "Info"
    }
}

# Safely disconnects from Microsoft Graph when operations are complete
function Disconnect-GraphIfNeeded {
    if ($Global:IsGraphConnected) {
        try {
            Write-ScriptLog "Disconnecting from Microsoft Graph..." -Type "Info"
            Disconnect-MgGraph
            $Global:IsGraphConnected = $false
            Write-ScriptLog "Successfully disconnected from Microsoft Graph." -Type "Success"
        } catch {
            Write-ScriptLog "Error disconnecting from Microsoft Graph: $_" -Type "Error"
        }
    }
}

# Ensures required PowerShell modules are available and loaded
# Handles version conflicts by importing latest available version
function Ensure-Modules {
    param(
        [string[]]$Modules  # Array of required module names
    )

    foreach ($module in $Modules) {
        # Get latest installed version
        $latestModule = Get-Module -ListAvailable -Name $module | 
            Sort-Object Version -Descending | 
            Select-Object -First 1

        # Remove current version to prevent conflicts
        if (Get-Module -Name $module) {
            Remove-Module -Name $module -Force -ErrorAction SilentlyContinue
        }

        # Import latest version
        if ($latestModule) {
            Write-ScriptLog "Importing latest version of module $module (Version: $($latestModule.Version))" -Type "Info"
            Import-Module -Name $latestModule.Path -ErrorAction Stop
        } else {
            Write-ScriptLog "Module $module is not installed. Please install it before running this script." -Type "Error"
        }
    }
}

# Retrieves group ID using display name with pagination support
# Handles large directories by processing results in chunks
function Get-GroupId {
    param (
        [string]$GroupDisplayName  # Display name of the target group
    )
    
    Ensure-GraphConnection -Scopes "Group.Read.All"
    $nextPage = "https://graph.microsoft.com/v1.0/groups?`$top=100"
    
    do {
        $groupRequest = Invoke-MgGraphRequest -Uri $nextPage -Method Get
        $groups = $groupRequest.Value
        $nextPage = $groupRequest.'@odata.nextLink'
        $group = $groups | Where-Object { $_.displayName -eq $GroupDisplayName }
    } until ($group -or -not $nextPage)
    
    if ($null -eq $group) { 
        throw "Group with display name '$GroupDisplayName' not found." 
    }
    return $group.Id
}

# Checks for existing role assignments to prevent duplicates
# Returns existing assignment if found, null otherwise
function Get-ExistingRoleAssignment {
    param (
        [string]$PrincipalId,
        [string]$RoleDefinitionId,
        [string]$DirectoryScopeId = "/"
    )

    Ensure-GraphConnection

    try {
        Write-ScriptLog "Checking for existing role assignment" -Type "Info"
        Write-ScriptLog "PrincipalId: $PrincipalId" -Type "Info"
        Write-ScriptLog "Scope: $DirectoryScopeId" -Type "Info"
        Write-ScriptLog "RoleDefinitionId: $RoleDefinitionId" -Type "Info"

        $query = "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments"
        $assignments = Invoke-MgGraphRequest -Uri $query -Method Get

        # Filter assignments based on all criteria
        $existingAssignment = $assignments.Value | Where-Object {
            $_.principalId -eq $PrincipalId -and 
            $_.roleDefinitionId -eq $RoleDefinitionId -and 
            $_.directoryScopeId -eq $DirectoryScopeId
        }

        if ($existingAssignment) {
            Write-ScriptLog "Existing role assignment found for PrincipalId: $PrincipalId" -Type "Success"
            return $existingAssignment
        } else {
            Write-ScriptLog "No existing role assignment found for PrincipalId: $PrincipalId" -Type "Info"
            return $null
        }

    } catch {
        Write-ScriptLog "Error checking for existing role assignment: $_" -Type "Error"
    }
}

# Retrieves data for each principal (user, group, or service principal)
# Supports multiple principal types and handles pagination
function Get-PrincipalsData {
    param (
        [string[]]$PrincipalIdentifiers,  # Array of principal identifiers
        [string]$ScopeType               # Type of scope being processed
    )

    Write-ScriptLog "Starting principal data retrieval process..." -Type "Info"
    
    # Ensure required modules and Graph connection
    Ensure-Modules -Modules @(
        "Microsoft.Graph.Authentication",
        "Microsoft.Graph.Users",
        "Microsoft.Graph.Groups",
        "Microsoft.Graph.Applications",
        "Microsoft.Graph.Identity.Governance"
    )
    Ensure-GraphConnection

    $principalsData = @()

    # Helper function for paginated API queries
    function Perform-PaginatedLookup {
        param (
            [string]$baseUrl,
            [string]$filterKey,
            [string]$filterValue
        )
        $nextPage = "$baseUrl`?`$top=100"
        do {
            $request = Invoke-MgGraphRequest -Uri $nextPage -Method Get
            $items = $request.Value
            $nextPage = $request.'@odata.nextLink'
            $result = $items | Where-Object { $_.$filterKey -eq $filterValue }
        } until ($result -or -not $nextPage)
        return $result
    }

    # Process each principal identifier
    foreach ($PrincipalIdentifier in $PrincipalIdentifiers) {
        Write-ScriptLog "Processing principal: $PrincipalIdentifier" -Type "Info"

        $principalInfo = @{
            Identifier = $PrincipalIdentifier
            Id = $null
            Type = $null
        }

        # Try to find principal as user
        try {
            Write-ScriptLog "Looking up user: $PrincipalIdentifier" -Type "Info"
            $user = Perform-PaginatedLookup -baseUrl "https://graph.microsoft.com/v1.0/users" -filterKey "userPrincipalName" -filterValue $PrincipalIdentifier
            if ($user) {
                $principalInfo.Id = $user.Id
                $principalInfo.Type = "User"
                Write-ScriptLog "Found user with ID: $($user.Id)" -Type "Success"
            }
        } catch {
            Write-ScriptLog "User lookup failed: $_" -Type "Warning"
        }

        # If not found as user, try as group
        if (-not $principalInfo.Id) {
            try {
                Write-ScriptLog "Looking up group: $PrincipalIdentifier" -Type "Info"
                $group = Perform-PaginatedLookup -baseUrl "https://graph.microsoft.com/v1.0/groups" -filterKey "displayName" -filterValue $PrincipalIdentifier
                if ($group) {
                    $principalInfo.Id = $group.Id
                    $principalInfo.Type = "Group"
                    Write-ScriptLog "Found group with ID: $($group.Id)" -Type "Success"
                }
            } catch {
                Write-ScriptLog "Group lookup failed: $_" -Type "Warning"
            }
        }

        # If not found as user or group, try as service principal
        if (-not $principalInfo.Id) {
            try {
                Write-ScriptLog "Looking up service principal: $PrincipalIdentifier" -Type "Info"
                $sp = Perform-PaginatedLookup -baseUrl "https://graph.microsoft.com/v1.0/servicePrincipals" -filterKey "displayName" -filterValue $PrincipalIdentifier
                if ($sp) {
                    $principalInfo.Id = $sp.Id
                    $principalInfo.Type = "ServicePrincipal"
                    Write-ScriptLog "Found service principal with ID: $($sp.Id)" -Type "Success"
                }
            } catch {
                Write-ScriptLog "Service principal lookup failed: $_" -Type "Warning"
            }
        }

        if ($principalInfo.Id -and $principalInfo.Type) {
            $principalsData += $principalInfo
        } else {
            Write-ScriptLog "No matching principal found for: $PrincipalIdentifier" -Type "Warning"
        }
    }

    Write-ScriptLog "Principal data retrieval complete." -Type "Success"
    return $principalsData
}

# Removes a Service Principal from a specified Microsoft Entra ID group
# Handles direct removal without using PIM schedules
function Remove-ServicePrincipalFromGroup {
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$GroupId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DirectoryObjectId
    )

    try {
        Write-ScriptLog "Ensuring Graph connection with necessary permissions..." -Type "Info"
        Ensure-GraphConnection -Scopes @(
            "GroupMember.ReadWrite.All",
            "Directory.AccessAsUser.All"
        )

        Write-ScriptLog "Attempting to remove Service Principal from group..." -Type "Info"
        
        Remove-MgGroupMemberDirectoryObjectByRef `
            -GroupId $GroupId `
            -DirectoryObjectId $DirectoryObjectId `
            -ErrorAction Stop

        Write-ScriptLog "Successfully removed Service Principal from group" -Type "Success"
    }
    catch {
        $simplifiedError = switch -Wildcard ($_.Exception.Message) {
            "*401*Unauthorized*" { "Authorization failed. Please check permissions and admin consent." }
            "*404*" { "Group or Service Principal not found." }
            "*403*" { "Access forbidden. Insufficient permissions." }
            default { "Failed to remove Service Principal from group." }
        }
        
        Write-ScriptLog $simplifiedError -Type "Error" -VerboseMessage $_.Exception.Message
        throw $simplifiedError
    }
}

# Main function to process Graph assignments
# Handles different assignment types and scopes
function Process-GraphAssignments {
    param (
        [array]$principalsData
    )

    Write-ScriptLog "Starting Graph assignments processing..." -Type "Info"

    $scheduleInfo = @{
        startDateTime = $StartDateTime
        expiration    = @{ 
            type     = "AfterDuration"
            duration = $Duration 
        }
    }

    if ($ScopeType -eq "GroupMembership" -and $GroupDisplayName) {
        try {
            $GroupId = Get-GroupId -GroupDisplayName $GroupDisplayName
            Write-ScriptLog "Successfully resolved Group ID: $GroupId" -Type "Success"
        }
        catch {
            Write-ScriptLog "Failed to find group: $GroupDisplayName" -Type "Error" -VerboseMessage $_.Exception.Message
            return
        }
    }

    foreach ($principalInfo in $principalsData) {
        if (-not $principalInfo.Id) {
            Write-ScriptLog "Invalid principal: $($principalInfo.Identifier)" -Type "Warning"
            continue
        }

        try {
            Write-ScriptLog "Processing $ScopeType assignment for principal: $($principalInfo.Identifier)" -Type "Info"

            switch ($ScopeType) {
                "EntraID" {
                    # Special handling for Service Principals in Entra ID
                    if ($principalInfo.Type -eq "ServicePrincipal") {
                        Write-ScriptLog "Processing Service Principal in Entra ID context" -Type "Info"
                        
                        # Check for existing role assignments to prevent duplicates
                        $existingAssignment = Get-ExistingRoleAssignment `
                            -PrincipalId $principalInfo.Id `
                            -RoleDefinitionId $RoleDefinitionId `
                            -DirectoryScopeId $DirectoryScopeId

                        if ($ActionType -eq "Remove") {
                            if ($existingAssignment) {
                                Write-ScriptLog "Removing existing role assignment" -Type "Info"
                                Remove-MgRoleManagementDirectoryRoleAssignment -UnifiedRoleAssignmentId $existingAssignment.Id -ErrorAction Stop
                                Write-ScriptLog "Successfully removed role assignment" -Type "Success"
                            }
                            else {
                                Write-ScriptLog "No existing assignment found to remove" -Type "Info"
                            }
                        }
                        elseif ($ActionType -eq "Active") {
                            if ($existingAssignment) {
                                Write-ScriptLog "Active role assignment already exists, skipping creation" -Type "Info"
                            }
                            else {
                                Write-ScriptLog "Creating new active role assignment" -Type "Info"
                                $params = @{
                                    principalId      = $principalInfo.Id
                                    RoleDefinitionId = $RoleDefinitionId
                                    DirectoryScopeId = $DirectoryScopeId
                                    justification    = $Justification
                                    scheduleInfo     = $scheduleInfo
                                }
                                New-MgRoleManagementDirectoryRoleAssignment -BodyParameter $params -ErrorAction Stop
                                Write-ScriptLog "Successfully created active role assignment" -Type "Success"
                            }
                        }
                        elseif ($ActionType -eq "Eligible") {
                            Write-ScriptLog "Eligible assignments aren't supported for Service Principals - skipping." -Type "Warning"
                        }
                    }
                    else {
                        # Handle non-Service Principal assignments through eligibility schedule
                        Write-ScriptLog "Processing eligibility schedule request for non-Service Principal" -Type "Info"
                        $params = @{
                            principalId      = $principalInfo.Id
                            RoleDefinitionId = $RoleDefinitionId
                            DirectoryScopeId = $DirectoryScopeId
                            action           = if ($ActionType -eq "Remove") { "AdminRemove" } else { "AdminAssign" }
                            justification    = $Justification
                            scheduleInfo     = $scheduleInfo
                        }
                        New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -BodyParameter $params
                        Write-ScriptLog "Successfully processed eligibility schedule request" -Type "Success"
                    }
                }

                "GroupMembership" {
                    Write-ScriptLog "Processing group membership assignment" -Type "Info"
                    # Handle Service Principal direct removal from groups
                    if ($ActionType -eq "Remove" -and $principalInfo.Type -eq "ServicePrincipal") {
                        Write-ScriptLog "Removing Service Principal from group" -Type "Info"
                        Remove-ServicePrincipalFromGroup -GroupId $GroupId -DirectoryObjectId $principalInfo.Id
                    }
                    else {
                        # Process group membership assignments for other principal types
                        $params = @{
                            accessId      = $GroupAccessId
                            principalId   = $principalInfo.Id
                            groupId       = $GroupId
                            action        = if ($ActionType -eq "Remove") { "AdminRemove" } else { "AdminAssign" }
                            justification = $Justification
                            scheduleInfo  = $scheduleInfo
                        }
                        New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
                        Write-ScriptLog "Successfully processed group membership request" -Type "Success"
                    }
                }

                "Azure" {
                    Write-ScriptLog "Processing Azure RBAC assignment" -Type "Info"
                    # Generate unique identifier for the assignment
                    $guid = [guid]::NewGuid().ToString()
                    
                    # Determine the scope for role assignment
                    if (-not $Scope) {
                        $subscriptionId = (Get-AzContext).Subscription.Id
                        $fullyQualifiedRoleDefinitionId = "/subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleDefinitions/$RoleDefinitionId"
                        Write-ScriptLog "Using current subscription scope: $subscriptionId" -Type "Info"
                    }
                    else {
                        $fullyQualifiedRoleDefinitionId = "$Scope/providers/Microsoft.Authorization/roleDefinitions/$RoleDefinitionId"
                        Write-ScriptLog "Using provided scope for role assignment" -Type "Info"
                    }

                    # Prepare common parameters for Azure role assignments
                    $basicRequestParams = @{
                        Name                      = $guid
                        Scope                     = $Scope
                        PrincipalId               = $principalInfo.Id
                        RoleDefinitionId          = $fullyQualifiedRoleDefinitionId
                        Justification             = $Justification
                        ScheduleInfoStartDateTime = $StartDateTime.ToString("o")
                        ExpirationDuration        = $Duration
                        ExpirationType            = "AfterDuration"
                    }

                    # Process based on action type
                    switch ($ActionType) {
                        "Eligible" {
                            Write-ScriptLog "Creating eligible Azure role assignment" -Type "Info"
                            New-AzRoleEligibilityScheduleRequest @basicRequestParams -RequestType "AdminAssign" -ErrorAction Stop
                        }
                        "Active" {
                            Write-ScriptLog "Creating active Azure role assignment" -Type "Info"
                            New-AzRoleAssignmentScheduleRequest @basicRequestParams -RequestType "AdminAssign" -ErrorAction Stop
                        }
                        "Remove" {
                            # Attempt to remove both eligible and active assignments
                            Write-ScriptLog "Attempting to remove Azure role assignments" -Type "Info"
                            try {
                                New-AzRoleEligibilityScheduleRequest @basicRequestParams -RequestType "AdminRemove" -ErrorAction Stop
                                Write-ScriptLog "Successfully removed eligible assignment" -Type "Success"
                            }
                            catch {
                                Write-ScriptLog "No eligible assignment found or removal failed: $_" -Type "Warning"
                            }
                            try {
                                New-AzRoleAssignmentScheduleRequest @basicRequestParams -RequestType "AdminRemove" -ErrorAction Stop
                                Write-ScriptLog "Successfully removed active assignment" -Type "Success"
                            }
                            catch {
                                Write-ScriptLog "No active assignment found or removal failed: $_" -Type "Warning"
                            }
                        }
                    }
                }
            }
            
            Write-ScriptLog "Successfully completed processing for $($principalInfo.Identifier)" -Type "Success"
        }
        catch {
            $simplifiedError = switch -Wildcard ($_.Exception.Message) {
                "*ResourceNotFound*" { "Resource not found." }
                "*RoleAssignmentDoesNotExist*" { "Role assignment not found." }
                "*Unauthorized*" { "Authorization failed." }
                "*Forbidden*" { "Access forbidden." }
                default { "Failed to process role assignment." }
            }
            
            Write-ScriptLog "Error for $($principalInfo.Identifier): $simplifiedError" -Type "Error" -VerboseMessage $_.Exception.Message
        }
    }
}

# Script entry point
Write-ScriptLog "Starting PIM role assignment management script..." -Type "Info"
$principalsData = Get-PrincipalsData -PrincipalIdentifiers $PrincipalIdentifiers -ScopeType $ScopeType
Process-GraphAssignments -principalsData $principalsData
Disconnect-GraphIfNeeded
Write-ScriptLog "Script execution complete." -Type "Success"



Conclusion: Identity Governance - Unlocked!

And that wraps up the first chapter of our dive into Identity Governance!


Today, we explored the essentials of Privileged Identity Management and how it keeps privileged access secure and manageable. With features like just-in-time access, approval workflows, and visibility into elevated roles, PIM is an invaluable tool in building a Zero Trust strategy that safeguards our identities.


But this is only the beginning! By embracing identity governance, we’re creating a layered defense that stands strong against evolving threats. Each layer we add brings us closer to a comprehensive and resilient security posture—from defining privileged access to continuously reviewing permissions.


Stay tuned as we keep building out the tools and tactics that make up a solid identity governance framework. There’s plenty more to uncover on this journey, and each step strengthens our approach to Identity and Access Management!


And now, another bad joke for a quick laugh to close out our journey today:


What is a hacker’s favorite season?

Phishin' season! 😎


Keep following Cloudy With a Chance of Security for more updates on securing your identity landscape, and share this series with your peers as we continue building stronger access controls, one step at a time!

Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating
bottom of page