Overcoming PIM Limitations with Azure Automation: A Fun Experiment - kayasax/EasyPIM GitHub Wiki
As I continued to experiment with EasyPIM in Azure Automation runbook (check here), I found myself facing a common challenge:
Dynamic groups are not supported by PIM.
But, being the tech enthusiast that I am, I couldn't let this limitation stop me.
So, I set out on a mission to find a workaround, and guess what? I did it! π
Imagine this: a runbook that automatically adds and removes PIM assignments for members of a dynamic group. Sounds like magic, right? Well, it's not magicβit's just a bit of clever automation. π§ββοΈβ¨
The Config File π: The solution revolves around a configuration file (a JSON hosted in Azure Key Vault) that describes the roles we want to assign to a group ID. This file is the heart of our operation.
Managed Identity π: The runbook uses its managed identity to securely read the config file. This ensures that our secrets are safe and sound.
Verification and Assignment β : For each group, the runbook verifies if a PIM assignment is correctly assigned to each member. If not, it takes care of it. And if a user leaves the group, the runbook will remove the assignment previously created for them.
-
Like for the Access Package Extension (check here), we will need An Automation account with an assigned System Managed Identity and to configure the PowerShell environement so it includes EasyPIM.
-
We then provide the authorization for the Manage Identity to access the Key Vault where we will store the configuration file, for instance by granting the "Key vault Secret Users" role
-
Our config file is a simple Json describing the roles we want to assign to which GroupID. Note that you can assign several roles at once! π
Sample config.json:
{
"groups": [
{
"id": "da7fc9e4-e8f7-4c44-aaac-b287ef91aea4",
"rolename": "contributor",
"type": "eligible",
"scope": "subscription"
},
{
"id": "d8544173-83e1-4cee-b97d-df301cd9efea",
"rolename": "testrole,reader",
"type": "eligible",
"scope": "subscription"
},
{
"id": "bd33a067-fd1d-4ca6-8286-ea7d0e44d5f6",
"rolename": "testrole",
"type": "eligible",
"scope": "subscription"
}
]
}
If we need to update the config we can use these simple commands:
$secretvalue= Get-Content .\config.json -Raw | ConvertTo-SecureString -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName "KVPIM" -Name "Config" -SecretValue $secretvalue
- Finaly, we will need to create a runbook that will run in our custom environment. Here is the code that I used:
Click to expand
[CmdletBinding()]
param (
)
try {
"=> Logging in to Azure..."
Connect-AzAccount -Identity
write-verbose get-azcontext
$subscriptionId = (Get-AzContext).Subscription.Id
$tenantId = (Get-AzContext).Tenant.Id
"=> Import module EasyPIM..."
import-module easyPIM
"=> Get config file from Azure Key Vault"
$config = Get-AzKeyVaultSecret -VaultName 'KVPIM' -Name 'config'
$conf = $config.SecretValue | ConvertFrom-SecureString -AsPlainText | ConvertFrom-Json
"=> Config file loaded"
# Process each group in the config file
$conf.groups | ForEach-Object {
$groupID = $_.Id
$groupName = (get-azadgroup -ObjectId $groupID).DisplayName
$type = $_.type
if ($type -eq "eligible") {
$newcommand = "New-PIMAzureResourceEligibleAssignment"
$removecommand = "Remove-PIMAzureResourceEligibleAssignment"
}
else {
$newcommand = "New-PIMAzureResourceActiveAssignment"
$removecommand = "Remove-PIMAzureResourceActiveAssignment"
}
"==> Working on group: $groupname ( $groupid )"
$members = Get-AzADGroupMember -GroupObjectId $groupid
"==> Members found in that group : $($members.Count)"
# Are we working with several roles?
$rolenames = $_.rolename
$rolenames.split(',') | ForEach-Object {
$rolename = $_
"====> Role name: $rolename"
# Get ID of the role $rolename assignable at the provided scope
$restUri = "https://management.azure.com//subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleDefinitions?api-version=2022-04-01&`$filter=roleName eq '$rolename'"
$response = (Invoke-AzRestMethod -Uri $restUri -method "get").Content | ConvertFrom-Json
$roleID = $response.value.id
#">> RodeId = $roleID"
if ( ($roleID -eq "") -or ($null -eq $roleID)) {
"Error getting config of $rolename"
#continue with other roles
return
}
# For each member of the group, check if there is an existing assignment
$members | ForEach-Object {
$id = $_.id
$displayName = $_.displayName
"=====> working on group member $displayName ($id)"
#1 check if there are existing assignment request created for that role and this member
$r = (invoke-AzRestMethod -uri "https://management.azure.com//subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleEligibilityScheduleRequests?`$select=principalId,action,roleDefinitionId&`$expand=roleDefinition,activatedUsing,principal,targetSchedule&`$filter=roleDefinitionId eq '$roleid' and principalid eq '$id'&api-version=2020-10-01"-Method get).content
#$r
#filter the result to get only the assignment created by this script based on the justification, to avoid removing assignments created by other means
$justif = "PIMRunbookForGroup_" + $groupId + "_" + $rolename
$r = $r | convertfrom-json | Select-Object -expand value | Select-Object -expand properties | Where-Object { $_.justification -match "$justif" -and $_.status -eq "provisioned" }
if ($r.Count -eq 0) {
#no existing assignment found
"==========>No existing assignment found, creating a new one"
"==========> Adding $type $rolename role to user $displayname ($id) "
$nc = $newcommand + " -tenantID $tenantId -subscriptionID $subscriptionId -principalID '" + $id + "' -rolename '" + $rolename + "' -justification 'PIMRunbookForGroup_" + $groupID + "_" + "$rolename'"
#"invoking $nc"
Invoke-Expression $nc
}
else {
#existing assignment found
"==========> existing assignment found"
"==========> Need to check if the schedule instance is still valid"
#we query the target schedule to check if it's still valid (if we get a response and if the status is provisioned)
#as the request are never deleted, we need to check the latest created one
$targetSchedule = $r | Sort-Object createdon -desc | Select-Object -first 1 | Select-Object -expand targetRoleEligibilityScheduleId
$targetSchedule = (Invoke-AzRestMethod -Uri "https://management.azure.com/$($targetSchedule)?api-version=2020-10-01" -Method get).content | ConvertFrom-Json
# if we dont get a response, or if the status is not provisioned, we need to recreate the assignment
if ( $null -eq $targetSchedule -or $targetSchedule.properties.status -ne "provisioned") {
"the schedule is not valid anymore, we need to recreate the assignment"
"==========> Adding $type $rolename role to user $displayname ($id) "
$nc = $newcommand + " -tenantID $tenantId -subscriptionID $subscriptionId -principalID '" + $id + "' -rolename '" + $rolename + "' -justification 'PIMRunbookForGroup_" + $groupID + "_" + "$rolename'"
#"invoking $nc"
Invoke-Expression $nc
}
else {
"==========> The schedule is still valid, nothing to do"
}
}#end of existing assignment found
" "
}#end of members loop
#We will also need to find if there are existing assignments for that role and group (using our justification field as identifier), for pincipals that are not in the group anymore, and remove them
"==========> Checking existing request for principal not in the group anymore"
$existingAssignments = (invoke-AzRestMethod -uri "https://management.azure.com//subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleEligibilityScheduleRequests?`$select=principalId,action,roleDefinitionId&`$expand=roleDefinition,activatedUsing,principal,targetSchedule&`$filter=roleDefinitionId eq '$roleid' &api-version=2020-10-01"-Method get).content
#"$existingAssignments = $existingAssignments | convertfrom-json | Select-Object -expand value | Select-Object -expand properties | Where-Object { $_.justification -match `"$justif`" -and $_.status -eq `"provisioned`" } "
$existingAssignments = $existingAssignments | convertfrom-json | Select-Object -expand value | Select-Object -expand properties | Where-Object { $_.justification -match "$justif" }
#$existingAssignments
$changeFound = 0
$PrincipalNotInGroup = ($existingAssignments.principalid | Select-Object -Unique) | Where-Object { $_ -notin $members.id }
$PrincipalNotInGroup | ForEach-Object {
$userid = $_ ;
$lastAssigment = $existingAssignments | ? { $_.principalid -eq $userid } | Sort-Object createdon -desc | Select-Object -first 1
if ($lastAssigment.status -ne "Provisioned") {
"Assignment for userId $userId is not provisioned anymore, nothing to do"
}
else {
#Check it the target schedule instance is still valid
$targetSchedule = (Invoke-AzRestMethod -Uri "https://management.azure.com/$($lastAssigment.targetRoleEligibilityScheduleId)?api-version=2020-10-01" -Method get).content | ConvertFrom-Json
#$targetSchedule
if (!( $null -eq $targetSchedule -or $targetSchedule.properties.status -ne "provisioned")) {
"the schedule is still provisioned, we need to remove the assignment"
"==========> Removing $type $rolename role to user ID $userId "
$rc = $removecommand + " -tenantID $tenantId -subscriptionID $subscriptionId -principalID '" + $userId + "' -rolename '" + $rolename + "' -justification 'PIMRunbookForGroup_" + $groupID + "_" + "$rolename'"
$changeFound++
"invoking $rc"
Invoke-Expression $rc
}
else {
"the schedule is not valid anymore, nothing to do"
}
}
}
If ($changeFound -eq 0) {
"==========> No change detected"
}
"##############################################"
}#end of roles loop
}#end of groups loop
"Okay, we're done here!"
}
catch {
Write-Error -Message $_.Exception
throw $_.Exception
}
- It's now time to test your setup, and if everything is OK schedule the execution of this runbook (schedules are hourly based at the minimum, but you can create more schedules starting a different times if you need more frequent updates).
First, let's dive into the nitty-gritty of how PIM works behind the scenes:
When an admin assigns a role in PIM, a schedule request is created. When the start time of this request arrives, a schedule instance is created, which finally makes the role available to the user.
My challenge was to identify the requests and instances created by the runbook so we could modify them as the membership changes.
With limited fields available, I got creative and used the justification field for tagging purposes:
the assignments created will have a justification like "PIMRunbookForGroup_(groupID)_(rolename)".
Ideally, we should also add a scope, but for the demo, it works at the subscription level only.
Another tricky part was detecting if the assignment was removed by other means (for instance another admin remove the assignement from the portal).
I had to query all the assignments for each user and this specific role, and check the status of the latest created one. π΅οΈββοΈπ
This approach not only provides a workaround for the PIM limitation but also ensures that our dynamic groups are always up-to-date with the correct PIM assignments. It's like having a personal assistant who never sleeps and always gets the job done! π€πΌ
So, if you're facing the same PIM limitation, give this solution a try. It's a fun experiment that brings a lot of value to your Azure environment. Plus, who doesn't love a bit of automation magic? β¨π§
Happy automating! π