Using Privileged Identity Management (PIM) to elevate local admin rights

Privileged Identity Management (PIM) is typically used for elevating user privileges in cloud environments, such as granting temporary admin rights in Azure AD. However, with some creative scripting, you can extend the power of PIM to manage local resources, such as elevating local admin rights on machines in your environment. In this post, we’ll walk through how to leverage PIM for managing local administrator rights by automating the process through scripting. This requires you to have PIM set up in your environment already in regards to access to cloud resources.

How PIM Works for Local Resources

As mentioned, PIM is mostly used for elevating rights in the cloud, but with the right script, you can take advantage of PIM to manage local resources as well. This is particularly useful for scenarios where you want to dynamically manage local admin rights on endpoints based on Azure AD PIM-managed groups, without having to manually handle each machine.

By integrating PowerShell and Microsoft Graph API, you can automate the process of synchronizing Azure AD PIM group members with local admin groups, enabling local admin rights for users who have elevated their permissions via PIM.

Step 1: Create an Enterprise Application with Minimal Rights

Before we get into the scripting, you’ll first need to create an Azure AD Enterprise Application with the appropriate permissions to access Microsoft Graph API. This app will allow the script to retrieve group members from Azure AD.

Here are the minimal rights your enterprise app will need:

  • Group.Read.All: To read Azure AD group memberships.
  • User.Read.All: To fetch user details.

To set up the enterprise app:

  1. Go to the Azure AD portal.
  2. Under App Registrations, create a new registration.
  3. Assign the application the necessary Microsoft Graph API permissions listed above.
  4. After adding the permissions, grant admin consent.

Once your enterprise app is set up, note the Client ID, Tenant ID, and Client Secret—these will be used in the script.

You don’t want to store your Client Secret or other sensitive credentials in plain text within the script. Instead, you can securely save them using Windows Credential Manager.

To do this:

  1. Use Credential Manager to store your Azure AD app’s Client ID and Client Secret.
  2. The script can then retrieve the credentials from Credential Manager when needed, ensuring they remain secure.

Additionally, the script can cache the access token (ticket) generated from the Azure AD app, storing it in a file. This allows the script to reuse the cached token until it expires, reducing the need to fetch a new token with every execution. This helps optimize performance, especially when scheduling frequent runs.

Step 2: Running the Script with Local Group Modification Rights

The script needs to run on a machine that has the rights to modify local groups, meaning it should either be run on a domain-joined machine or one that has local admin privileges.

You’ll also need a configuration file (JSON) that maps the Azure AD groups to the local groups. The script will query Azure AD for the members of a specific PIM-managed group, and then synchronize those members with the local admin group on the machine. Make sure to replace the $TenantId and the $AllowedEmailDomain in the script.

A lot of the logging is commented out, feel free to activate these lines if you want to.

Here’s an overview of how the script works:

  1. Authenticate with Azure AD using the enterprise app credentials.
  2. Retrieve the members of the specified Azure AD group.
  3. Modify local groups: Add or remove members from the local “Administrators” group based on the Azure AD group membership.
  4. Log changes for auditing.

You can download the script and configuration file here:

sync-PIMGroups.ps1:


<#
.SYNOPSIS
    Synchronize Azure AD PIM-managed Groups with Local AD Groups.

.DESCRIPTION
    This script connects to the Microsoft Graph API to retrieve Azure AD groups
    managed by Privileged Identity Management (PIM) based on a naming convention.
    It then synchronizes the members of these Azure AD groups with specified local AD groups.

.PREREQUISITES
    - Azure AD App Registration with the following Application Permissions:
        - Group.Read.All
        - User.Read.All
    - PowerShell CredentialManager Module installed.
    - Active Directory Module for Windows PowerShell installed.
    - Configuration file (`GroupMappingConfig.json`) with group mappings.

.NOTES
    - This script requires administrative privileges to modify local AD groups.
    - Ensure secure handling of credentials.
#>

# ------------------------- Configuration -------------------------

# Path to the configuration file
$ConfigFilePath = "C:\Scripts\GroupMappingConfig.json"

# Path to the token cache file
$TokenCachePath = "C:\Scripts\token_cache.json"

# Directory to store log files
$LogDirectory = "C:\Scripts\Logs"

# Azure AD App Registration Details
$TenantId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"        # Replace with your Tenant ID
$CredentialTarget = "sync-PIM"                          # Credential Manager target name

# Log retention period in days (e.g., keep logs for the last 30 days)
$LogRetentionDays = 5

# Allowed email domain for local AD accounts
$AllowedEmailDomain = "@yourdomain.com"

# ------------------------- Functions -------------------------

function Write-Log {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [string]$Level = "CHANGE"  # Possible levels: CHANGE, WARNING, ERROR
    )

    # If the message is empty or whitespace, skip logging to prevent errors
    if ([string]::IsNullOrWhiteSpace($Message)) {
        return
    }

    # Only log if Level is CHANGE, WARNING, or ERROR
    if ($Level -ne "CHANGE" -and $Level -ne "WARNING" -and $Level -ne "ERROR") {
        return
    }

    # Ensure the log directory exists
    if (-not (Test-Path -Path $LogDirectory)) {
        try {
            New-Item -Path $LogDirectory -ItemType Directory -Force | Out-Null
        }
        catch {
            Write-Host "Failed to create log directory '$LogDirectory': $_" -ForegroundColor Red
            return
        }
    }

    # Define the log file path with current date in UTC
    $DateString = (Get-Date).ToUniversalTime().ToString("yyyyMMdd")
    $LogFilePath = Join-Path -Path $LogDirectory -ChildPath "Sync-PIMGroups_$DateString.log"

    $Timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") + " UTC"
    $LogEntry = "$Timestamp [$Level] $Message"

    # Write to console with color based on level
    switch ($Level) {
        "CHANGE" { Write-Host $LogEntry -ForegroundColor Cyan }
        "WARNING" { Write-Host $LogEntry -ForegroundColor Yellow }
        "ERROR" { Write-Host $LogEntry -ForegroundColor Red }
        default { Write-Host $LogEntry }
    }

    # Append to log file
    try {
        Add-Content -Path $LogFilePath -Value $LogEntry
    }
    catch {
        Write-Host "Failed to write to log file '$LogFilePath': $_" -ForegroundColor Red
    }
}

function Get-GraphToken {
    param (
        [Parameter(Mandatory = $true)]
        [string]$TenantId,

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

    # Import CredentialManager module
    try {
        Import-Module CredentialManager -ErrorAction Stop
    }
    catch {
        Write-Log "Failed to import CredentialManager module: $_" "ERROR"
        return $null
    }

    # Retrieve the stored credentials
    try {
        $StoredCred = Get-StoredCredential -Target $CredentialTarget
        if (-not $StoredCred) {
            Write-Log "No credentials found for target '$CredentialTarget'." "ERROR"
            return $null
        }
    }
    catch {
        Write-Log "Error retrieving credentials: $_" "ERROR"
        return $null
    }

    $ClientId = $StoredCred.Username
    # Convert SecureString to plain text
    $ClientSecret = $StoredCred.GetNetworkCredential().Password

    $Body = @{
        grant_type    = "client_credentials"
        scope         = "https://graph.microsoft.com/.default"
        client_id     = $ClientId
        client_secret = $ClientSecret
    }

    try {
        $TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
            -Method Post -Body $Body -ContentType "application/x-www-form-urlencoded"

        return $TokenResponse
    }
    catch {
        Write-Log "Failed to obtain access token: $_" "ERROR"
        return $null
    }
}

function Get-CachedOrNewGraphToken {
    param (
        [Parameter(Mandatory = $true)]
        [string]$TenantId,

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

        [Parameter(Mandatory = $true)]
        [string]$TokenCachePath
    )
    
    # Check if token cache file exists
    if (Test-Path -Path $TokenCachePath) {
        try {
            $CachedToken = Get-Content -Path $TokenCachePath | ConvertFrom-Json
            $CurrentTimeUTC = (Get-Date).ToUniversalTime()
            $ExpiryTimeUTC = [DateTime]$CachedToken.expires_at

            # Add a buffer of 5 minutes to account for clock skew
            if ($CurrentTimeUTC -lt ($ExpiryTimeUTC.AddMinutes(-5))) {
                return $CachedToken.access_token
            }
            else {
                Write-Log "Cached token is expired or about to expire. Fetching a new token." "CHANGE"
            }
        }
        catch {
            Write-Log "Failed to read token cache. A new token will be fetched. Error: $_" "WARNING"
        }
    }
    else {
        Write-Log "No token cache found. Fetching a new token." "CHANGE"
    }

    # Fetch a new token
    $TokenResponse = Get-GraphToken -TenantId $TenantId -CredentialTarget $CredentialTarget

    if ($TokenResponse) {
        $AccessToken = $TokenResponse.access_token
        $ExpiresIn = [int]$TokenResponse.expires_in  # seconds

        # Calculate exact expiry time in UTC
        $ExpiryTimeUTC = (Get-Date).ToUniversalTime().AddSeconds($ExpiresIn)

        # Create token cache object
        $TokenCache = @{
            access_token = $AccessToken
            expires_at   = $ExpiryTimeUTC
        }

        try {
            # Save token cache to file
            $TokenCache | ConvertTo-Json | Set-Content -Path $TokenCachePath -Force
            Write-Log "New access token cached successfully. Expires at $($ExpiryTimeUTC.ToString('yyyy-MM-dd HH:mm:ss')) UTC" "CHANGE"
        }
        catch {
            Write-Log "Failed to write token cache to file: $_" "WARNING"
        }

        return $AccessToken
    }
    else {
        Write-Log "Failed to obtain a new access token." "ERROR"
        return $null
    }
}

function Get-AzureADGroup {
    param (
        [Parameter(Mandatory = $true)]
        [string]$AccessToken,

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

    $Headers = @{
        Authorization = "Bearer $AccessToken"
    }

    $EncodedGroupName = [System.Web.HttpUtility]::UrlEncode($GroupName)
    $Uri = "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$EncodedGroupName'&`$select=displayName,id"

    try {
        $Response = Invoke-RestMethod -Uri $Uri -Headers $Headers -Method Get
        if ($Response.value.Count -gt 0) {
            #Write-Log "Azure AD Group '$GroupName' found with ID: $($Response.value[0].id)" "CHANGE"
            return $Response.value[0]
        }
        else {
            Write-Log "Azure AD Group '$GroupName' not found." "WARNING"
            return $null
        }
    }
    catch {
        Write-Log "Failed to retrieve Azure AD Group '$GroupName': $_" "ERROR"
        return $null
    }
}

function Get-AzureADGroupMembers {
    param (
        [Parameter(Mandatory = $true)]
        [string]$AccessToken,

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

    #Write-Log "Retrieving members for Azure AD Group ID: $GroupId..." "CHANGE"

    $Headers = @{
        Authorization = "Bearer $AccessToken"
    }

    $Members = @()
    $NextLink = "https://graph.microsoft.com/v1.0/groups/$GroupId/members?$select=displayName,userPrincipalName,onPremisesUserPrincipalName,id,mail,@odata.type"

    while ($NextLink) {
        try {
            $Response = Invoke-RestMethod -Uri $NextLink -Headers $Headers -Method Get
            $Members += $Response.value
            #Write-Log "Retrieved $($Response.value.Count) members from Azure AD Group ID: $GroupId." "CHANGE"
            $NextLink = $Response.'@odata.nextLink'
            if ($NextLink) {
                #Write-Log "Fetching next page of members for Azure AD Group ID: $GroupId." "CHANGE"
            }
        }
        catch {
            Write-Log "Failed to retrieve members for Azure AD Group ID $GroupId $_" "ERROR"
            break
        }
    }

    #Write-Log "Total members retrieved for Azure AD Group ID $GroupId $($Members.Count)" "CHANGE"
    return $Members
}

function Synchronize-LocalGroupMembership {
    param (
        [Parameter(Mandatory = $true)]
        [string]$LocalGroup,

        [Parameter(Mandatory = $false)]
        [Object[]]$AggregatedAzureADMembers = @()
    )

    # Extract Azure AD users' emails, preferring onPremisesUserPrincipalName
    $AzureADEmails = $AggregatedAzureADMembers | ForEach-Object {
        if ($_.onPremisesUserPrincipalName -ne $null -and $_.onPremisesUserPrincipalName.ToLower().EndsWith($AllowedEmailDomain)) {
            $_.onPremisesUserPrincipalName.ToLower()
        }
        elseif ($_.mail -ne $null -and $_.mail.ToLower().EndsWith($AllowedEmailDomain)) {
            $_.mail.ToLower()
        }
        elseif ($_.userPrincipalName -ne $null -and $_.userPrincipalName.ToLower().EndsWith($AllowedEmailDomain)) {
            $_.userPrincipalName.ToLower()
        }
        else {
            Write-Log "User '$($_.displayName)' does not have an email ending with '$AllowedEmailDomain'. Skipping." "WARNING"
            $null
        }
    } | Where-Object { $_ -ne $null } | Select-Object -Unique

    # Get current members of the local AD group (only users)
    try {
        $LocalGroupMembers = Get-ADGroupMember -Identity $LocalGroup -Recursive -ErrorAction Stop |
                             Where-Object { $_.objectClass -eq 'user' } |
                             Get-ADUser -Properties mail, UserPrincipalName |
                             Select-Object -Property SamAccountName, mail, UserPrincipalName
    }
    catch {
        Write-Log "Failed to retrieve members of Local Group '$LocalGroup': $_" "ERROR"
        return
    }

    # Extract local AD users' emails
    $LocalADEmails = $LocalGroupMembers | ForEach-Object {
        if ($_.mail -ne $null -and $_.mail.ToLower().EndsWith($AllowedEmailDomain)) {
            $_.mail.ToLower()
        }
        elseif ($_.UserPrincipalName -ne $null -and $_.UserPrincipalName.ToLower().EndsWith($AllowedEmailDomain)) {
            $_.UserPrincipalName.ToLower()
        }
        else {
            Write-Log "Local AD User '$($_.SamAccountName)' does not have an email ending with '$AllowedEmailDomain'. Skipping." "WARNING"
            $null
        }
    } | Where-Object { $_ -ne $null } | Select-Object -Unique

    # Determine users to add and remove using email comparison
    $EmailsToAdd = $AzureADEmails | Where-Object { $_ -notin $LocalADEmails }
    $EmailsToRemove = $LocalADEmails | Where-Object { $_ -notin $AzureADEmails }

    # Log Emails to Add and Remove
    if ($EmailsToAdd.Count -gt 0) {
        Write-Log "Users to add to '$LocalGroup': $($EmailsToAdd -join ', ')" "CHANGE"
    }

    if ($EmailsToRemove.Count -gt 0) {
        Write-Log "Users to remove from '$LocalGroup': $($EmailsToRemove -join ', ')" "CHANGE"
    }

    # Add users to the local group
    foreach ($Email in $EmailsToAdd) {
        try {
            $ADUser = Get-ADUser -Filter { mail -eq $Email -or UserPrincipalName -eq $Email } -ErrorAction Stop
            Add-ADGroupMember -Identity $LocalGroup -Members $ADUser -ErrorAction Stop
            Write-Log "Added '$Email' to Local Group '$LocalGroup'." "CHANGE"
        }
        catch {
            Write-Log "Failed to add user with email '$Email' to Local Group '$LocalGroup': $_" "ERROR"
        }
    }

    # Remove users from the local group
    foreach ($Email in $EmailsToRemove) {
        try {
            $ADUser = Get-ADUser -Filter { mail -eq $Email -or UserPrincipalName -eq $Email } -ErrorAction Stop
            Remove-ADGroupMember -Identity $LocalGroup -Members $ADUser -Confirm:$false -ErrorAction Stop
            Write-Log "Removed '$Email' from Local Group '$LocalGroup'." "CHANGE"
        }
        catch {
            Write-Log "Failed to remove user with email '$Email' from Local Group '$LocalGroup': $_" "ERROR"
        }
    }

    # If Azure AD group is empty, ensure all local AD members are removed
    if ($AzureADEmails.Count -eq 0 -and $LocalADEmails.Count -gt 0) {
        Write-Log "Azure AD Group corresponding to '$LocalGroup' is empty. Removing all members from Local Group '$LocalGroup'." "CHANGE"
        foreach ($LocalEmail in $LocalADEmails) {
            try {
                $ADUser = Get-ADUser -Filter { mail -eq $LocalEmail -or UserPrincipalName -eq $LocalEmail } -ErrorAction Stop
                Remove-ADGroupMember -Identity $LocalGroup -Members $ADUser -Confirm:$false -ErrorAction Stop
                Write-Log "Removed '$LocalEmail' from Local Group '$LocalGroup'." "CHANGE"
            }
            catch {
                Write-Log "Failed to remove user with email '$LocalEmail' from Local Group '$LocalGroup': $_" "ERROR"
            }
        }
    }

    #Write-Log "Synchronization for Local Group '$LocalGroup' completed." "CHANGE"
}


function Sync-PIMGroups {
    #Write-Log "=== PIM Groups Synchronization Started ===" "CHANGE"
    #Write-Log "Start Time: $(Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') UTC" "CHANGE"

    # Check if configuration file exists
    if (-not (Test-Path -Path $ConfigFilePath)) {
        Write-Log "Configuration file not found at path: $ConfigFilePath" "ERROR"
        return
    }

    # Load configuration
    try {
        $GroupMappings = Get-Content -Path $ConfigFilePath | ConvertFrom-Json
        Write-Log "Loaded group mappings from configuration file." "CHANGE"
    }
    catch {
        Write-Log "Failed to load configuration file: $_" "ERROR"
        return
    }

    # Obtain Access Token (from cache or new)
    $AccessToken = Get-CachedOrNewGraphToken -TenantId $TenantId -CredentialTarget $CredentialTarget -TokenCachePath $TokenCachePath

    if (-not $AccessToken) {
        Write-Log "Authentication failed. Exiting script." "ERROR"
        return
    }

    # Build a mapping from Local Groups to Azure AD Groups
    $LocalGroupToAzureADGroups = @{}
    foreach ($Mapping in $GroupMappings) {
        $AzureADGroupName = $Mapping.AzureADGroupName
        $LocalGroups = $Mapping.LocalGroups

        foreach ($LocalGroup in $LocalGroups) {
            if (-not $LocalGroupToAzureADGroups.ContainsKey($LocalGroup)) {
                $LocalGroupToAzureADGroups[$LocalGroup] = @()
            }
            $LocalGroupToAzureADGroups[$LocalGroup] += $AzureADGroupName
        }
    }

    # Retrieve all Azure AD groups involved
    $AzureADGroups = @{}
    foreach ($AzureADGroupName in ($GroupMappings.AzureADGroupName | Select-Object -Unique)) {
        #Write-Log "Retrieving Azure AD Group: $AzureADGroupName" "CHANGE"

        # Retrieve Azure AD group details
        $AzureADGroup = Get-AzureADGroup -AccessToken $AccessToken -GroupName $AzureADGroupName

        if (-not $AzureADGroup) {
            # Already logged inside Get-AzureADGroup if not found
            continue
        }

        # Get members of the Azure AD group
        $Members = Get-AzureADGroupMembers -AccessToken $AccessToken -GroupId $AzureADGroup.id

        # Store the members
        $AzureADGroups[$AzureADGroupName] = $Members
    }

    # For each local group, aggregate members from all mapped Azure AD groups
    foreach ($LocalGroup in $LocalGroupToAzureADGroups.Keys) {
        #Write-Log "Processing Local Group: $LocalGroup" "CHANGE"

        $AggregatedMembers = @()
        foreach ($AzureADGroupName in $LocalGroupToAzureADGroups[$LocalGroup]) {
            if ($AzureADGroups.ContainsKey($AzureADGroupName)) {
                $AggregatedMembers += $AzureADGroups[$AzureADGroupName]
            }
        }

        # Filter only user objects and ensure it's an array
        $AggregatedUserMembers = @($AggregatedMembers | Where-Object { $_.'@odata.type' -eq "#microsoft.graph.user" })

        # Synchronize the local group regardless of member count
            Synchronize-LocalGroupMembership -LocalGroup $LocalGroup -AggregatedAzureADMembers $AggregatedUserMembers

            }

    $EndTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')
    #Write-Log "End Time: $EndTime UTC" "CHANGE"

    # Optional: Clean up old log files
    Cleanup-OldLogs
}

function Cleanup-OldLogs {
    #Write-Log "Starting cleanup of old log files..." "CHANGE"

    try {
        $CutoffDate = (Get-Date).ToUniversalTime().AddDays(-$LogRetentionDays)
        Get-ChildItem -Path $LogDirectory -Filter "Sync-PIMGroups_*.log" | Where-Object { $_.LastWriteTime.ToUniversalTime() -lt $CutoffDate } | ForEach-Object {
            Remove-Item -Path $_.FullName -Force
            Write-Log "Deleted old log file: $($_.FullName)" "CHANGE"
        }
        #Write-Log "Cleanup of old log files completed." "CHANGE"
    }
    catch {
        Write-Log "Failed to clean up old log files: $_" "ERROR"
    }
}

# ------------------------- Execute -------------------------

# Ensure Verbose output is captured
$VerbosePreference = "Continue"

# Import Active Directory Module
try {
    Import-Module ActiveDirectory -ErrorAction Stop
    #Write-Log "Imported Active Directory module successfully." "CHANGE"
}
catch {
    Write-Log "Failed to import Active Directory module: $_" "ERROR"
    exit 1
}

# Run the main synchronization function
Sync-PIMGroups

GroupMappingConfig.json:


[
    {
        "AzureADGroupName": "az.rbac.dept1.cloud",
        "LocalGroups": ["rbac.dept1.cloud"]
    },
    {
        "AzureADGroupName": "az.rbac.dept2.tech",
        "LocalGroups": ["rbac.dept2.tech"]
    },
    {
        "AzureADGroupName": "az.rbac.support.office",
        "LocalGroups": ["rbac.support.office"]
    }
]

Step 3: Testing the Script Before Scheduling

Before scheduling the script to run automatically, it’s important to test it. A great way to do this is to run the script in PowerShell ISE to see if it works as expected.

Here’s how to test:

  1. Open PowerShell ISE in administrator mode.
  2. Load the script and ensure the configuration file is in the correct path.
  3. Run the script and monitor the output. Check the logs to ensure that users are being added to or removed from the local admin group based on their Azure AD PIM group membership.

Testing in ISE allows you to catch any errors before the script is scheduled to run on its own.

Step 4: Scheduling the Script

To ensure that local admin rights are regularly updated, you should schedule the script to run at regular intervals. Given that PIM elevations are time-sensitive, running the script every 3 minutes is a good rule of thumb. This way, the script checks for any changes in Azure AD group membership and adjusts local admin rights accordingly.

Here’s how to schedule the script:

  1. Open Task Scheduler on the machine.
  2. Create a new task and configure it to run the script as the administrator.
  3. In the Triggers tab, set the script to run every 3 minutes.
  4. In the Actions tab, point to the script file.
  5. Ensure the task is configured to run even when the user is logged off.

By scheduling it this way, the script will ensure that any PIM-approved users will have their local admin rights updated in near real-time.