TL;DR — One script. Two groups. Full PIM-enabled least-privilege access on any Azure subscription. No portal clicking. No manual mistakes.
Setting up Privileged Identity Management (PIM) in Azure the right way involves a surprisingly long checklist:
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.
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.
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.AllRoleManagement.ReadWrite.DirectoryPrivilegedAccess.ReadWrite.AzureResources.\New-AzurePIMSetup.ps1 -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -Role "Contributor"
That's it. The script handles everything from login to policy configuration.
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
A common question: why not just assign users directly to the PIM group?
The two-group pattern is a deliberate best practice:
#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
Once the script completes, your workflow looks like this:
AAD_SEC_SUB-<SubscriptionName>_<Role> in Entra IDscheduleInfo block if your organization requires time-bound eligibility.Have questions or improvements? Drop a comment below.