Security Azure

Just-in-Time Azure Resource Access: PIM for Azure Roles, Groups, and Approval Workflows

Most teams stand up Privileged Identity Management for Entra directory roles - Global Administrator, User Administrator - and stop there. But the blast radius in a mature Azure estate is on the resource control plane: a standing Owner on a production subscription can delete a resource group, exfiltrate Key Vault secrets, and rewrite RBAC. This guide extends JIT elevation to Azure resource roles, scopes eligibility down to individual resource groups, gates activation behind approval and ticketing, and brings in PIM for Groups for privileges that don’t map cleanly to a single Azure role.

The two PIM control planes

PIM governs two separate systems, and conflating them is the most common design error.

PIM for Entra roles PIM for Azure resources
Governs Directory roles (Global Admin, etc.) Azure RBAC role assignments
Scope Tenant / administrative units Management group, subscription, RG, resource
Backing API Microsoft Graph (roleManagement/directory) ARM (Microsoft.Authorization/*)
Max activation Up to 24 hours Up to 24 hours (per role policy)

The resource side is what we focus on here. It maps directly onto the ARM hierarchy, which is what makes scoping powerful: you can make someone eligible for Contributor on one resource group without giving them anything on the subscription.

The mental shift: stop thinking “who is an Owner” and start thinking “who is eligible to become an Owner, for how long, with whose approval, against which ticket.” Standing assignments become the exception you justify, not the default.

Prerequisites: Microsoft Entra ID P2 (or Entra ID Governance) for every user who activates, and Owner or User Access Administrator on the target scope to manage its eligible assignments and policy.

1. Onboard subscriptions and discover what’s already there

PIM auto-discovers subscriptions once you have the right directory role, but the first practical step is inventorying the standing assignments you’re about to replace. Do not start by deleting - start by seeing.

# Every role assignment on a subscription, including inherited from the MG
az role assignment list \
  --scope /subscriptions/00000000-0000-0000-0000-000000000000 \
  --include-inherited \
  --query "[].{principal:principalName, role:roleDefinitionName, type:principalType, scope:scope}" \
  -o table

Flag every Owner, Contributor, and User Access Administrator held by a human (filter principalType == User). Those are your conversion candidates. Service principals and managed identities stay as standing assignments - PIM activation requires interactive sign-in, so workload identities cannot activate eligible roles.

In the portal, the same surface is Entra admin center -> Identity governance -> Privileged Identity Management -> Azure resources, where you select the subscription (or management group) to manage.

2. Create scoped eligible assignments

This is the core move. Instead of an active Contributor on the whole subscription, grant eligible Contributor on a single resource group. The principal sees the role in PIM but holds no permission until they activate.

RG_SCOPE="/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-payments-prod"
PRINCIPAL_OID="11111111-1111-1111-1111-111111111111"   # the user's object ID

# Contributor role definition ID (stable, well-known GUID)
CONTRIB="b24988ac-6180-42a0-ab88-20f7382dd24c"

az role assignment create \
  --assignee-object-id "$PRINCIPAL_OID" \
  --assignee-principal-type User \
  --role "$CONTRIB" \
  --scope "$RG_SCOPE"

Caveat: az role assignment create creates a standing (active) assignment, not an eligible one. The classic CLI predates PIM. To create eligible assignments you go through the ARM PIM provider - the portal, the REST API, or IaC (covered in step 6). I show the active form here only because you’ll use it for the rare standing exceptions; the default path is eligible.

The eligible-assignment ARM resource is Microsoft.Authorization/roleEligibilityScheduleRequests. You can create one with a raw ARM call:

GUID=$(uuidgen)
az rest --method put \
  --url "https://management.azure.com${RG_SCOPE}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/${GUID}?api-version=2022-04-01-preview" \
  --body '{
    "properties": {
      "principalId": "11111111-1111-1111-1111-111111111111",
      "roleDefinitionId": "'"${RG_SCOPE}"'/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c",
      "requestType": "AdminAssign",
      "scheduleInfo": {
        "startDateTime": null,
        "expiration": { "type": "AfterDuration", "duration": "P180D" }
      },
      "justification": "JIT eligibility for payments platform team"
    }
  }'

Note roleDefinitionId is fully qualified with the scope, requestType is AdminAssign (an admin granting eligibility, versus SelfActivate when the user activates), and expiration gives the eligibility a hard 180-day end date so it doesn’t live forever.

3. Tune the role policy per scope

Eligibility without guardrails is just deferred standing access. The role management policy at each scope controls what activation requires: maximum duration, MFA, justification, ticketing, and approval. Configure the high-value roles (Owner, Contributor, User Access Administrator) tightly, and lower-risk roles more loosely.

In the portal: PIM -> Azure resources -> <subscription> -> Settings -> <role> -> Edit. A defensible production baseline for Owner:

Activation
  Max activation duration:        2 hours
  Require MFA on activation:      Yes (or "Require Authentication Context")
  Require justification:          Yes
  Require ticket information:     Yes
  Require approval to activate:   Yes  -> approvers: "Cloud Platform Approvers" group

Assignment
  Allow permanent eligible assignment:  No  (force an expiry on eligibility)
  Expire eligible assignments after:    180 days
  Allow permanent active assignment:    No

Authentication Context is the modern way to require MFA: instead of the policy’s built-in “require MFA” toggle, you reference a Conditional Access authentication context (for example c1) so activation is gated by a full CA policy - phishing-resistant methods, compliant device, named location. That keeps your MFA strength definition in one place (CA) rather than split across PIM.

Owner and User Access Administrator are the roles that can grant themselves more access. Always require approval on those two, and never make the approver a member of the same team that activates.

A critical gap to know about: role management policies cannot be updated through Bicep or ARM templates. The Microsoft.Authorization/roleManagementPolicyAssignments resource is readable but the rules are not writable via standard ARM deployment. You set policy through the portal or the Authorization REST API (PATCH .../roleManagementPolicies/<id>); you automate assignments via IaC. Plan your tooling accordingly - I see teams burn a sprint trying to push policy through azapi before discovering this.

4. PIM for Groups: JIT into privileged access groups

Some privileges don’t map to one Azure role. You want a single activation that grants Contributor on three resource groups plus a Key Vault data-plane role, or you want JIT ownership of a group so someone can manage its membership only when needed. PIM for Groups solves this: a group is onboarded to PIM, principals are made eligible for membership or ownership, and the group itself holds the standing role assignments.

The pattern:

  1. Create a role-assignable security group (it must be isAssignableToRole or onboarded as a privileged access group).
  2. Give that group the standing Azure RBAC assignments it needs (the three RGs + the Key Vault role).
  3. In PIM, make engineers eligible members. Activation drops them into the group for a bounded window; on expiry they fall out and lose every role the group carried.
az ad group create \
  --display-name "pag-payments-operators" \
  --mail-nickname "pag-payments-operators" \
  --is-assignable-to-role true

Then assign the group its standing roles (Contributor on each RG, Key Vault Secrets User on the vault), and onboard it in PIM -> Groups -> <group> -> Settings, configuring the Member role with the same activation/approval rules as in step 3.

PIM for Groups activation is capped at 8 hours maximum - shorter than the 24-hour ceiling for direct Azure resource roles. For nested ownership scenarios (eligible owner of a group so you can rotate its members JIT) this is usually plenty.

This also cleans up nested ownership: rather than standing owners on dozens of access groups, make a platform team eligible-owner across them. They activate ownership only to make a membership change, and that activation is logged and approvable like any other.

5. A break-glass path that still gets logged

PIM is in the critical path of getting access, so a PIM, Graph, or Conditional Access outage cannot lock everyone out of production. The answer is not a quiet permanent Owner that bypasses everything - it’s a break-glass account that has standing access but is loud.

Design it so the emergency path is auditable:

// Sentinel / Log Analytics: alert on any break-glass sign-in
let breakGlassUPNs = dynamic(["breakglass-01@contoso.onmicrosoft.com",
                              "breakglass-02@contoso.onmicrosoft.com"]);
SigninLogs
| where UserPrincipalName in~ (breakGlassUPNs)
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress,
          ResultType, Location

The test that matters: tabletop a “PIM is down” scenario. If your engineers cannot reach the break-glass credential (sealed in a separate vault, two-person retrieval) and your alert does not actually page someone, you have a break-glass account that is just an unmonitored backdoor.

6. Automate eligibility with IaC and the PIM APIs

Click-ops eligibility does not survive a real estate. Two automation surfaces matter, and they cover different objects.

Azure resource roles via ARM/Bicep/Terraform. Eligible assignments are first-class ARM resources, so you can declare them. In Terraform, the azurerm_pim_eligible_role_assignment resource handles this cleanly:

data "azurerm_subscription" "prod" {
  subscription_id = "00000000-0000-0000-0000-000000000000"
}

data "azurerm_role_definition" "contributor" {
  name  = "Contributor"
  scope = data.azurerm_subscription.prod.id
}

resource "azurerm_pim_eligible_role_assignment" "payments_contrib" {
  scope              = "${data.azurerm_subscription.prod.id}/resourceGroups/rg-payments-prod"
  role_definition_id = data.azurerm_role_definition.contributor.role_definition_id
  principal_id       = "11111111-1111-1111-1111-111111111111"

  schedule {
    expiration {
      duration_days = 180
    }
  }

  justification = "Payments platform team - JIT Contributor (managed by IaC)"
}

The Bicep/ARM equivalent is the roleEligibilityScheduleRequests resource shown in step 2. Remember the limitation from step 3: you can declare assignments in IaC, but the policy (duration, approval, MFA) is not writable through ARM templates - drive it via the REST API or portal.

PIM for Groups via Microsoft Graph. Group eligibility lives in Graph, not ARM. Make a principal an eligible member with an eligibilityScheduleRequest:

az rest --method post \
  --url "https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/eligibilityScheduleRequests" \
  --headers "Content-Type=application/json" \
  --body '{
    "accessId": "member",
    "principalId": "11111111-1111-1111-1111-111111111111",
    "groupId": "22222222-2222-2222-2222-222222222222",
    "action": "adminAssign",
    "scheduleInfo": {
      "startDateTime": "2026-05-21T00:00:00Z",
      "expiration": { "type": "afterDuration", "duration": "P180D" }
    },
    "justification": "Eligible member of pag-payments-operators"
  }'

Key fields: accessId is member or owner (this is how you grant nested JIT ownership), action is adminAssign, and expiration bounds the eligibility itself. The same endpoint family handles assignmentScheduleRequests for the rare standing assignment.

Source-control your eligibility. A pull request that adds someone as eligible-Owner is itself an approval artifact and an audit trail - far better than a portal change nobody can reconstruct six months later.

7. Recurring access reviews with auto-removal

Eligibility rots. People change teams, projects end, and “temporary” eligibility quietly becomes permanent. Access reviews are the garbage collector. Schedule a recurring review over the eligible assignments at each high-value scope, and configure it to auto-apply results and remove access when a reviewer doesn’t respond.

In the portal: Identity governance -> Access reviews -> New access review, scoped to “Azure resource roles” for the subscription/RG, reviewing eligible assignments.

Review:            Eligible "Owner" + "Contributor" on rg-payments-prod
Reviewers:         Resource owners (or named "Cloud Platform Approvers")
Recurrence:        Quarterly
Auto-apply:        Yes
If reviewers don't respond:  Remove access
Justification required:      Yes (reviewer must state why access is kept)

“If reviewers don’t respond -> Remove access” is the setting that actually shrinks standing risk. A review that defaults to keep on silence is theater. Make the safe default removal, and require a justification to retain.

For PIM for Groups, run the equivalent review over the group’s eligible members and owners. Reviews and the underlying eligibility share the same governance plane, so a removal here drops the eligibility everywhere it propagated.

8. Audit activations in Sentinel and alert on elevation

Every activation is an audit event. The goal is to make Owner/Contributor elevation visible in seconds, not discoverable in a quarterly export. Stream the Azure Activity log and Entra audit logs into Log Analytics / Microsoft Sentinel, then alert on the activations that matter.

PIM for Azure resource activations surface in the Activity log under the Microsoft.Authorization provider. This rule flags any activation of Owner or User Access Administrator:

AzureActivity
| where OperationNameValue has "roleAssignmentScheduleRequests"
| where ActivityStatusValue == "Success"
| extend props = parse_json(Properties)
| where tostring(props.requestbody) has_any ("Owner", "User Access Administrator")
| project TimeGenerated, Caller, ResourceGroup,
          SubscriptionId = tostring(props.subscriptionId), OperationNameValue

For Entra-role and PIM-for-Groups activity, query the audit logs instead:

AuditLogs
| where Category == "RoleManagement"
| where ActivityDisplayName has "Add member to role completed (PIM activation)"
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| project TimeGenerated, ActivityDisplayName, actor,
          target = tostring(TargetResources[0].displayName), Result

Turn these into scheduled analytics rules. The two alerts every estate should have: activation of Owner/User Access Administrator on production, and any modification of a PIM role policy (someone weakening duration or removing approval is a strong tampering signal).

Enterprise scenario

A payments platform team converted every production subscription to eligible-only Owner and Contributor, approval gated, and the auditors were happy. Two weeks later the on-call engineer hit a wall during a Sev2: activation of Contributor on rg-payments-prod sat in “pending approval” for forty minutes because the only approver group member was on a flight. The constraint they had missed: PIM approvals are synchronous and single-stage, with no built-in escalation or timeout-to-auto-approve. A break-glass account exists for full outages, but burning it for a routine activation hiccup is exactly the loud event you do not want during an incident.

The fix was twofold. First, the approver group got real depth - on-call leads from a different team plus the platform manager, so duty separation held but coverage did not depend on one person. Second, they split the role policy: incident-response Contributor on the production RGs requires approval, but a separate, tightly scoped eligible assignment for the active on-call rotation activates with MFA and justification only, no approval, capped at one hour.

# Grant the current on-call principal self-activatable (no-approval) eligibility,
# bounded to the rotation window; renewed each handoff by the pipeline.
az rest --method put \
  --url "https://management.azure.com${RG_SCOPE}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/$(uuidgen)?api-version=2022-04-01-preview" \
  --body '{"properties":{"principalId":"'"$ONCALL_OID"'","roleDefinitionId":"'"${RG_SCOPE}"'/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c","requestType":"AdminAssign","scheduleInfo":{"expiration":{"type":"AfterDuration","duration":"P14D"}},"justification":"On-call rotation - JIT incident Contributor"}}'

The Sentinel rule from step 8 still fires on every activation, so the no-approval path stays visible. Approval is a control, not a goal; the goal is least standing privilege with an auditable, survivable elevation path.

Verify

Before you call it done, prove the controls actually behave:

# 1. The eligible principal holds NO standing permission yet
az role assignment list \
  --assignee 11111111-1111-1111-1111-111111111111 \
  --scope "$RG_SCOPE" --query "[].roleDefinitionName" -o tsv
# Expect: empty (eligibility is not an active assignment)

# 2. Eligible assignments exist at the scope
az rest --method get \
  --url "https://management.azure.com${RG_SCOPE}/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" \
  --query "value[].{principal:properties.principalId, role:properties.roleDefinitionId}" -o table

Then the human-loop checks no script can do for you:

Rollout checklist

Pitfalls and next steps

The end state is an estate with effectively zero standing Owners on production: access is something engineers request and earn for the duration of a task, gated by approval, bounded by time, and fully reconstructable from the audit log. Pair this with Conditional Access authentication contexts for the activation gate and Defender for Cloud for the posture it protects, and least privilege stops being an aspiration and becomes the default state of the system.

PIMPAMAzure RBACLeast PrivilegeAccess ReviewsGovernance

Comments

Keep Reading