Automate Azure PIM Group Setup with PowerShell

TL;DR — One script. Two groups. Full PIM-enabled least-privilege access on any Azure subscription. No portal clicking. No manual mistakes.


The Problem

Setting up Privileged Identity Management (PIM) in Azure the right way involves a surprisingly long checklist:

  • Create a security group for your users
  • Create a role-assignable group for PIM
  • Nest the security group inside the PIM group
  • Assign an eligible role on the subscription
  • Configure PIM policies (notifications, approval, MFA...)

Do this for five subscriptions across three roles and you're clicking around the portal for an hour — and probably doing it slightly differently each time. Inconsistent naming, missed policy settings, forgotten memberships.

There's a better way.


The Solution

A single PowerShell script that takes two inputs:

Parameter Example
SubscriptionId xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Role Contributor

And produces two consistently named groups every single time:

AAD_SEC_SUB-<SubscriptionName>_<Role>   ← Add your users here
AAD_PIM_SUB-<SubscriptionName>_<Role>   ← PIM-enabled, eligible role assigned

The SEC group is your user container — this is where you manage membership. The PIM group wraps it with Just-In-Time access. Users request activation through PIM, and admins get notified by email every time someone elevates their access.


Prerequisites

Make sure you have the required PowerShell modules installed:

Install-Module Az.Accounts, Az.Resources
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Groups, Microsoft.Graph.Identity.Governance

You'll also need the following permissions in Entra ID / Azure:

  • Group.ReadWrite.All
  • RoleManagement.ReadWrite.Directory
  • PrivilegedAccess.ReadWrite.AzureResources

Usage

.\New-AzurePIMSetup.ps1 -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -Role "Contributor"

That's it. The script handles everything from login to policy configuration.


What Happens Under the Hood

1. Login        → Connect-AzAccount + Connect-MgGraph
2. Lookup       → Resolve Subscription ID to Display Name
3. Validate     → Stop with error if either group already exists
4. Create SEC   → Standard security group (mailEnabled: false)
5. Create PIM   → Role-assignable group (isAssignableToRole: true)
6. Nest         → SEC group added as member of PIM group
7. Assign       → Eligible role set on subscription via PIM REST API
8. Policy       → Admin email notification enabled on activation

Group Design — Why Two Groups?

A common question: why not just assign users directly to the PIM group?

The two-group pattern is a deliberate best practice:

  • Separation of concerns — user management lives in the SEC group, access governance lives in the PIM group
  • Scalability — the same SEC group can be reused across multiple PIM groups without re-managing individual users
  • Auditability — it's immediately clear who has standing access vs. who has eligible access
  • Least privilege by default — nobody has active permissions until they explicitly activate through PIM

The Script

#Requires -Modules Az.Accounts, Az.Resources, Microsoft.Graph.Authentication, Microsoft.Graph.Groups, Microsoft.Graph.Identity.Governance

<#
.SYNOPSIS
    Creates a Security group and a PIM-enabled group, and assigns the PIM group
    an eligible role on the specified Azure subscription.

.DESCRIPTION
    - AAD_SEC_SUB-<SubName>_<Role>  ->  Standard security group (add users here)
    - AAD_PIM_SUB-<SubName>_<Role>  ->  Role-assignable group with PIM enabled
      The SEC group is added as a member of the PIM group.
      The PIM group is assigned an eligible role on the subscription.
      PIM policy is configured with admin email notification on activation.

.PARAMETER SubscriptionId
    Azure Subscription ID (GUID).

.PARAMETER Role
    Azure RBAC role, e.g. "Contributor", "Reader", "Owner".

.EXAMPLE
    .\New-AzurePIMSetup.ps1 -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -Role "Contributor"
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory, HelpMessage = "Azure Subscription ID (GUID)")]
    [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')]
    [string]$SubscriptionId,

    [Parameter(Mandatory, HelpMessage = "Azure RBAC role, e.g. Contributor")]
    [string]$Role
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

#region -- Helper functions ---------------------------------------------------

function Write-Step {
    param([string]$Message)
    Write-Host "`n>  $Message" -ForegroundColor Cyan
}

function Write-Success {
    param([string]$Message)
    Write-Host "   OK  $Message" -ForegroundColor Green
}

function Write-Info {
    param([string]$Message)
    Write-Host "   i   $Message" -ForegroundColor Gray
}

#endregion

#region -- Login --------------------------------------------------------------

Write-Step "Signing in to Azure and Microsoft Graph..."

Connect-AzAccount -ErrorAction Stop | Out-Null

Connect-MgGraph -Scopes @(
    "Group.ReadWrite.All",
    "RoleManagement.ReadWrite.Directory",
    "PrivilegedAccess.ReadWrite.AzureResources"
) -ErrorAction Stop | Out-Null

Write-Success "Signed in."

#endregion

#region -- Get subscription display name -------------------------------------

Write-Step "Fetching subscription information..."

$subscription = Get-AzSubscription -SubscriptionId $SubscriptionId -ErrorAction Stop
$subName      = $subscription.Name

Write-Success "Found: '$subName' ($SubscriptionId)"

#endregion

#region -- Build group names -------------------------------------------------

$safeSubName  = $subName -replace '[^a-zA-Z0-9\-]', ''
$safeRole     = $Role    -replace '[^a-zA-Z0-9\-]', ''

$secGroupName = "AAD_SEC_SUB-${subName}_${Role}"
$pimGroupName = "AAD_PIM_SUB-${subName}_${Role}"

$secNickname  = "AAASECSUB${safeSubName}${safeRole}"
$pimNickname  = "AAAPIMSUB${safeSubName}${safeRole}"

Write-Info "Security group : $secGroupName"
Write-Info "PIM group      : $pimGroupName"

#endregion

#region -- Check if groups already exist -------------------------------------

Write-Step "Checking whether the groups already exist..."

$existingSec = Get-MgGroup -Filter "displayName eq '$secGroupName'" -ErrorAction SilentlyContinue
$existingPim = Get-MgGroup -Filter "displayName eq '$pimGroupName'" -ErrorAction SilentlyContinue

if ($existingSec) {
    throw "ERROR: Group '$secGroupName' already exists (Id: $($existingSec.Id)). Script stopped."
}
if ($existingPim) {
    throw "ERROR: Group '$pimGroupName' already exists (Id: $($existingPim.Id)). Script stopped."
}

Write-Success "No conflicts found."

#endregion

#region -- Create Security group ---------------------------------------------

Write-Step "Creating Security group: $secGroupName..."

$secGroup = New-MgGroup -BodyParameter @{
    displayName     = $secGroupName
    description     = "User group for $Role access to subscription '$subName'. Add users here."
    mailEnabled     = $false
    securityEnabled = $true
    mailNickname    = $secNickname
} -ErrorAction Stop

Write-Success "Created with Id: $($secGroup.Id)"

#endregion

#region -- Create PIM group (role-assignable) --------------------------------

Write-Step "Creating PIM group (role-assignable): $pimGroupName..."

$pimGroup = New-MgGroup -BodyParameter @{
    displayName        = $pimGroupName
    description        = "PIM group for eligible $Role on subscription '$subName'. Managed via PIM."
    mailEnabled        = $false
    securityEnabled    = $true
    mailNickname       = $pimNickname
    isAssignableToRole = $true
} -ErrorAction Stop

Write-Success "Created with Id: $($pimGroup.Id)"

#endregion

#region -- Add SEC group as member of PIM group ------------------------------

Write-Step "Adding '$secGroupName' as member of '$pimGroupName'..."

New-MgGroupMember -GroupId $pimGroup.Id -DirectoryObjectId $secGroup.Id -ErrorAction Stop

Write-Success "Membership created."

#endregion

#region -- Assign eligible role on subscription via PIM ----------------------

Write-Step "Assigning eligible '$Role' on subscription via Azure PIM..."

Set-AzContext -SubscriptionId $SubscriptionId | Out-Null

$roleDefinition = Get-AzRoleDefinition -Name $Role -ErrorAction Stop
if (-not $roleDefinition) {
    throw "ERROR: Role '$Role' was not found in Azure RBAC."
}

$scope     = "/subscriptions/$SubscriptionId"
$token     = (Get-AzAccessToken -ResourceUrl "https://management.azure.com/").Token
$requestId = [System.Guid]::NewGuid().ToString()

$eligibilityBody = @{
    properties = @{
        principalId      = $pimGroup.Id
        roleDefinitionId = "$scope/providers/Microsoft.Authorization/roleDefinitions/$($roleDefinition.Id)"
        requestType      = "AdminAssign"
        justification    = "PIM setup via script for subscription '$subName'"
        scheduleInfo     = @{
            startDateTime = (Get-Date).ToUniversalTime().ToString("o")
            expiration    = @{
                type = "NoExpiration"
            }
        }
    }
} | ConvertTo-Json -Depth 10

$eligibilityUrl = "https://management.azure.com$scope/providers/Microsoft.Authorization/" +
                  "roleEligibilityScheduleRequests/${requestId}?api-version=2020-10-01"

Invoke-RestMethod -Uri $eligibilityUrl -Method Put -Body $eligibilityBody -Headers @{
    Authorization  = "Bearer $token"
    "Content-Type" = "application/json"
} -ErrorAction Stop | Out-Null

Write-Success "Eligible '$Role' assigned to PIM group on '$subName'."

#endregion

#region -- Configure PIM policy: admin notification on activation ------------

Write-Step "Configuring PIM policy (admin notification on activation)..."

$pimPolicies = Get-MgPolicyRoleManagementPolicy `
    -Filter "scopeId eq '$($pimGroup.Id)' and scopeType eq 'Group'" `
    -ErrorAction Stop

if (-not $pimPolicies -or $pimPolicies.Count -eq 0) {
    Write-Warning "   No PIM policy found for the group yet - this may be a replication delay."
    Write-Warning "   Configure notifications manually in Entra ID > Groups > $pimGroupName > PIM."
} else {
    $policy    = $pimPolicies[0]
    $rules     = Get-MgPolicyRoleManagementPolicyRule -UnifiedRoleManagementPolicyId $policy.Id
    $notifRule = $rules | Where-Object { $_.Id -eq "Notification_Admin_EndUser_Assignment" }

    if ($notifRule) {
        $updateBody = @{
            "@odata.type"              = "#microsoft.graph.unifiedRoleManagementPolicyNotificationRule"
            id                         = "Notification_Admin_EndUser_Assignment"
            notificationType           = "Email"
            recipientType              = "Admin"
            notificationLevel          = "All"
            isDefaultRecipientsEnabled = $true
        } | ConvertTo-Json -Depth 5

        Update-MgPolicyRoleManagementPolicyRule `
            -UnifiedRoleManagementPolicyId $policy.Id `
            -UnifiedRoleManagementPolicyRuleId "Notification_Admin_EndUser_Assignment" `
            -BodyParameter ($updateBody | ConvertFrom-Json) `
            -ErrorAction Stop

        Write-Success "Admin notification on activation enabled."
    } else {
        Write-Warning "   Notification rule not found. Configure manually in Entra ID PIM."
    }
}

#endregion

#region -- Summary ------------------------------------------------------------

Write-Host ""
Write-Host "=======================================================" -ForegroundColor DarkCyan
Write-Host "  SUMMARY" -ForegroundColor Cyan
Write-Host "=======================================================" -ForegroundColor DarkCyan
Write-Host ""
Write-Host "  Subscription   : $subName ($SubscriptionId)" -ForegroundColor White
Write-Host "  Role           : $Role" -ForegroundColor White
Write-Host ""
Write-Host "  Groups created:" -ForegroundColor White
Write-Host "    SEC  $secGroupName" -ForegroundColor Yellow
Write-Host "         Id: $($secGroup.Id)"
Write-Host "    PIM  $pimGroupName" -ForegroundColor Yellow
Write-Host "         Id: $($pimGroup.Id)"
Write-Host ""
Write-Host "  Next steps:" -ForegroundColor White
Write-Host "    1. Add users to the SEC group in Entra ID."
Write-Host "    2. Users activate $Role via PIM in the Azure portal."
Write-Host "    3. Admins receive an email notification on each activation."
Write-Host ""
Write-Host "=======================================================" -ForegroundColor DarkCyan

#endregion

After Running the Script

Once the script completes, your workflow looks like this:

  1. Add users to AAD_SEC_SUB-<SubscriptionName>_<Role> in Entra ID
  2. Users activate their eligible role via My Access or the Azure portal
  3. Admins receive an email notification for every activation
  4. Access expires automatically — no standing permissions sitting around

Notes & Limitations

  • Replication delay — the PIM policy for a newly created group can take a few minutes to appear in Entra ID. If the script warns about this, the notification setting can be applied manually under Entra ID → Groups → [PIM group] → Privileged Identity Management.
  • No expiration set — the eligible role assignment is created without an end date. Adjust the scheduleInfo block if your organization requires time-bound eligibility.
  • Idempotency — the script is intentionally not idempotent. If a group already exists it stops with an error, keeping you in full control of what gets created.

Have questions or improvements? Drop a comment below.